Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions app/c/[ws]/routines/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 루틴 — 크루에게 반복 지시를 예약하고, 원클릭으로 즉시 실행한다.
// 템플릿 원클릭 생성 → 폼 프리필. 실행 결과는 vault 기억으로 남는다.
import { use, useEffect, useState } from 'react';
import Link from 'next/link';
import { Icon, Avatar, Spinner, Skeleton, useScrollLock, ConfirmModal, DropUp, api, imeGuard, timeAgo } from '../../../ui';
import { useLang } from '../../../i18n';

Expand All @@ -24,7 +25,7 @@ function scheduleLabel(s, t, DOW) {

export default function Routines({ params }) {
const { ws } = use(params);
const { t, lang } = useLang();
const { t, lang, fmtMoney } = useLang();
const DOW = [t('routines.dow.sun'), t('routines.dow.mon'), t('routines.dow.tue'), t('routines.dow.wed'), t('routines.dow.thu'), t('routines.dow.fri'), t('routines.dow.sat')];
const TEMPLATES = [
{ title: t('routines.template1.title'), prompt: t('routines.template1.prompt'), schedule: { type: 'daily', time: '09:00' } },
Expand Down Expand Up @@ -85,6 +86,7 @@ export default function Routines({ params }) {
times: tpl?.schedule?.times ?? [tpl?.schedule?.time ?? '09:00'],
dows: tpl?.schedule?.dows ?? [tpl?.schedule?.dow ?? 1],
everyMinutes: tpl?.schedule?.everyMinutes ?? 30,
maxRuns: 20, maxUsd: '',
});
}

Expand All @@ -99,6 +101,7 @@ export default function Routines({ params }) {
times: r.schedule?.times ?? [r.schedule?.time ?? '09:00'],
dows: r.schedule?.dows ?? [r.schedule?.dow ?? 1],
everyMinutes: r.schedule?.everyMinutes ?? 30,
maxRuns: r.loop?.maxRuns ?? 20, maxUsd: r.loop?.maxUsd ?? '',
});
}

Expand All @@ -115,6 +118,8 @@ export default function Routines({ params }) {
// 한국 시간으로"). 안 보내면 서버가 자기 시간대로 해석하는데, 웹·클라우드 경로에선 그게
// 사용자 시간대가 아니다(UTC 서버면 09:00이 KST 18:00에 터진다).
: { type: form.type, times: form.times, dows: form.dows.map(Number), tz: Intl.DateTimeFormat().resolvedOptions().timeZone },
// 루프 상한 — interval에만 보낸다(서버는 다른 타입의 loop를 무시하지만, 보내지 않는 편이 의도가 분명하다)
...(form.type === 'interval' ? { loop: { maxRuns: Number(form.maxRuns) || 20, maxUsd: form.maxUsd === '' ? null : Number(form.maxUsd) } } : {}),
};
if (form.id) {
const res = await fetch(`/api/companies/${ws}/routines`, {
Expand Down Expand Up @@ -250,11 +255,24 @@ export default function Routines({ params }) {
</div>
)}
{form.type === 'interval' && (
<label style={{ display: 'grid', gap: 4 }}>
<span className="microlabel">{t('routines.intervalEvery')}</span>
<input suppressHydrationWarning type="number" min={10} max={1440} step={5} value={form.everyMinutes}
onChange={(e) => setForm({ ...form, everyMinutes: e.target.value })} style={{ ...selStyle, width: 110 }} />
</label>
<>
<label style={{ display: 'grid', gap: 4 }}>
<span className="microlabel">{t('routines.intervalEvery')}</span>
<input suppressHydrationWarning type="number" min={10} max={1440} step={5} value={form.everyMinutes}
onChange={(e) => setForm({ ...form, everyMinutes: e.target.value })} style={{ ...selStyle, width: 110 }} />
</label>
{/* 루프 상한 — 회차(1~200)·루프 예산(USD, 비우면 월 예산만). 한국어 모드는 원화 병기(fmtMoney 규칙) */}
<label style={{ display: 'grid', gap: 4 }}>
<span className="microlabel">{t('routines.loop.maxRuns')}</span>
<input suppressHydrationWarning type="number" min={1} max={200} value={form.maxRuns}
onChange={(e) => setForm({ ...form, maxRuns: e.target.value })} style={{ ...selStyle, width: 90 }} />
</label>
<label style={{ display: 'grid', gap: 4 }} title={t('routines.loop.maxUsdHint')}>
<span className="microlabel">{t('routines.loop.maxUsd')}{lang === 'ko' && form.maxUsd !== '' && Number(form.maxUsd) > 0 ? ` · ${fmtMoney(Number(form.maxUsd))}` : ''}</span>
<input suppressHydrationWarning type="number" min={0} step={0.5} value={form.maxUsd} placeholder="—"
onChange={(e) => setForm({ ...form, maxUsd: e.target.value })} style={{ ...selStyle, width: 110 }} />
</label>
</>
)}
{form.type !== 'interval' && (
<div style={{ display: 'grid', gap: 4 }}>
Expand Down Expand Up @@ -335,7 +353,7 @@ export default function Routines({ params }) {
) : (
<table className="table">
<thead>
<tr><th>{t('routines.colTitle')}</th><th style={{ width: 130 }}>{t('routines.colCrew')}</th><th style={{ width: 120 }}>{t('routines.colSchedule')}</th><th style={{ width: 170 }}>{t('routines.colLastRun')}</th><th style={{ width: 84 }}>{t('routines.colState')}</th><th style={{ width: 164 }} /></tr>
<tr><th>{t('routines.colTitle')}</th><th style={{ width: 130 }}>{t('routines.colCrew')}</th><th style={{ width: 190 }}>{t('routines.colSchedule')}</th><th style={{ width: 170 }}>{t('routines.colLastRun')}</th><th style={{ width: 84 }}>{t('routines.colState')}</th><th style={{ width: 164 }} /></tr>
</thead>
<tbody>
{routines.map((r) => (
Expand All @@ -349,7 +367,10 @@ export default function Routines({ params }) {
<Avatar name={nameOf(r.agentSlug)} sm />{nameOf(r.agentSlug)}
</span>
</td>
<td className="mono" style={{ fontSize: 11.5 }}>{scheduleLabel(r.schedule, t, DOW)}</td>
<td className="mono" style={{ fontSize: 11.5 }}>
{scheduleLabel(r.schedule, t, DOW)}
{r.schedule?.type === 'interval' && r.loop && <LoopStatus ws={ws} loop={r.loop} t={t} fmtMoney={fmtMoney} />}
</td>
<td style={{ fontSize: 11.5, color: 'var(--fg-2)' }}>
{r.lastRun ? (
<span title={r.lastResult}>
Expand All @@ -358,8 +379,9 @@ export default function Routines({ params }) {
) : <span style={{ color: 'var(--fg-3)' }}>—</span>}
</td>
<td>
<button className={`pill${r.enabled ? ' ok' : ''}`} onClick={() => toggle(r)} style={{ cursor: 'pointer' }}>
<span className="dot" />{r.enabled ? t('routines.on') : t('routines.off')}
<button className={`pill${r.enabled ? ' ok' : ''}`} onClick={() => toggle(r)} style={{ cursor: 'pointer' }}
title={!r.enabled && r.loop?.stoppedReason ? t('routines.loop.resume') : undefined}>
<span className="dot" />{r.enabled ? t('routines.on') : (r.loop?.stoppedReason ? t('routines.loop.resume') : t('routines.off'))}
</button>
</td>
<td style={{ textAlign: 'right' }}>
Expand All @@ -383,6 +405,27 @@ export default function Routines({ params }) {
);
}

/** 루프 진행 — `3/20회 · $0.42` + 상태 칩. 막힘이면 사유와 결재함 안내(결재 카드는 데크에 있다). */
function LoopStatus({ ws, loop, t, fmtMoney }) {
const reason = loop.stoppedReason;
const key = reason === 'done' ? 'done' : reason === 'blocked' ? 'blocked' : (reason === 'maxRuns' || reason === 'maxUsd') ? 'limit' : reason === 'manual' ? 'manual' : 'running';
const color = key === 'running' ? 'var(--primary-strong)' : key === 'done' ? 'var(--ok, var(--fg-2))' : key === 'blocked' ? 'var(--danger)' : 'var(--fg-3)';
return (
<div style={{ display: 'grid', gap: 3, marginTop: 4, fontFamily: 'var(--font)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>
{t('routines.loop.progress', { runs: loop.runs ?? 0, max: loop.maxRuns, cost: fmtMoney(loop.spentUsd ?? 0) })}
<span className="chip" style={{ color, borderColor: color, fontSize: 10, padding: '0 6px', height: 18 }}>{t(`routines.loop.${key}`)}</span>
</span>
{key === 'blocked' && (
<span style={{ fontSize: 11, color: 'var(--fg-2)', display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span>{t('routines.loop.blockedHint', { reason: loop.stoppedDetail ?? '' })}</span>
<Link href={`/c/${ws}`} style={{ color: 'var(--primary-strong)', textDecoration: 'underline' }}>{t('routines.loop.approveInbox')}</Link>
</span>
)}
</div>
);
}

const selStyle = {
height: 34, padding: '0 10px', background: 'var(--card-2)',
border: '1px solid var(--border)', borderRadius: 8, outline: 'none', fontSize: 13, color: 'var(--fg)',
Expand Down
13 changes: 13 additions & 0 deletions app/i18n.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,19 @@ const DICT = {
'routines.runNow': ['지금 즉시 실행', 'Run now'],
'routines.running': ['실행 중 — 결과는 기억에도 남습니다', 'Running — the result is also saved to memory'],
'routines.resultTitle': ['결과 · 기억에 기록됨', 'Result · Recorded in Memory'],
// 루프(interval 루틴의 자율 반복)
'routines.loop.maxRuns': ['최대 반복', 'Max runs'],
'routines.loop.maxUsd': ['루프 예산(USD)', 'Loop budget (USD)'],
'routines.loop.maxUsdHint': ['비우면 회사 월 예산만 적용', 'Leave empty to apply only the monthly budget'],
'routines.loop.progress': ['{runs}/{max}회 · {cost}', '{runs}/{max} runs · {cost}'],
'routines.loop.running': ['진행 중', 'Running'],
'routines.loop.done': ['완료', 'Done'],
'routines.loop.blocked': ['막힘', 'Blocked'],
'routines.loop.limit': ['한도', 'Limit'],
'routines.loop.manual': ['정지됨', 'Stopped'],
'routines.loop.blockedHint': ['결정 필요: {reason}', 'Needs a decision: {reason}'],
'routines.loop.approveInbox': ['결재함에서 승인', 'Approve in inbox'],
'routines.loop.resume': ['지금 재개', 'Resume now'],

// ── 마켓
'market.topLabel': ['추천 · {source}', 'Recommended · {source}'],
Expand Down
9 changes: 9 additions & 0 deletions src/approval-actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import { emitNotify } from './notify.mjs'; // 후속 턴 결과를 원 채널(
(capabilities.mjs). 옛 결재함에 남은 capability 항목은 여기서 특별 처리 없이 그냥 해소된다. */
export async function resolveWithFollowUp(wsId, id, approve) {
const item = await resolveApproval(wsId, id, approve);
if (item.kind === 'loop') {
// 루프 막힘(LOOP: blocked) 결재 — 승인이면 루틴을 다시 켠다(다음 틱에 재개), 거절이면 정지 유지.
// 후속 턴을 돌리지 않는다: 재개된 루프의 다음 회차가 곧 후속이고, 여기서 턴을 더 쓰면 비용 이중.
if (approve && item.payload?.routineId) {
const { resumeLoop } = await import('./routines.mjs');
await resumeLoop(wsId, item.payload.routineId).catch((e) => console.error('[argo] 루프 재개 실패:', e.message));
}
return item;
}
if (item.kind !== 'tool') {
followUp(wsId, item, approve).catch((e) => console.error('[argo] 결재 후속 턴 실패:', e.message));
}
Expand Down
14 changes: 10 additions & 4 deletions src/chat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -669,26 +669,30 @@ export function makeCrewServer(wsId, fromSlug, fromName, colleagues, hop = 0, ch
// 사장이 언제든 끄거나 고칠 수 있으므로(가시성) 결재 없이 실행한다 — hire_crew와 달리 되돌리기 쉽다.
const scheduleTask = tool(
'schedule_task',
'나중에 할 일을 예약한다(예약 발송·리마인드·정기 보고·반복 루프). once=지정 날짜에 1회, daily=매일, weekly=지정 요일, interval=N분마다 반복(루프 작업 — 모니터링·주기 점검). 시각은 한국 시간 HH:MM. 때가 되면 지정 크루가 prompt를 새 턴으로 실행한다. 예약 후에는 "언제 무엇을 하도록 걸어두었다"고 한 줄로 알려라.',
'나중에 할 일을 예약한다(예약 발송·리마인드·정기 보고·반복 루프). once=지정 날짜에 1회, daily=매일, weekly=지정 요일, interval=N분마다 반복(루프 작업 — 모니터링·주기 점검). 시각은 한국 시간 HH:MM. 때가 되면 지정 크루가 prompt를 새 턴으로 실행한다. interval 루프는 매 회차 마지막 줄 `LOOP: continue|done|blocked`로 스스로 끝내며 maxRuns(기본 20)·maxUsd 상한에서 자동 정지한다. 예약 후에는 "언제 무엇을 하도록 걸어두었다"고 한 줄로 알려라.',
{
title: z.string().describe('예약 이름 — 루틴 목록에 보인다'),
prompt: z.string().describe('실행할 지시 — 지금이 아니라 그때 읽힌다는 전제로 자세히 쓴다. 루프면 매 회차가 이 지시를 새로 읽는다'),
type: z.enum(['once', 'daily', 'weekly', 'interval']).describe('once=1회, daily=매일, weekly=매주, interval=N분마다'),
time: z.string().optional().describe('실행 시각 HH:MM (한국 시간, 24시간제) — interval이 아니면 필수'),
everyMinutes: z.number().optional().describe('interval일 때 필수 — 반복 간격(분, 10~1440)'),
maxRuns: z.number().optional().describe('interval 루프의 최대 회차(1~200, 기본 20) — 도달하면 자동 정지'),
maxUsd: z.number().optional().describe('interval 루프의 누적 비용 상한(USD, 선택) — 없으면 회사 월 예산만 적용'),
date: z.string().optional().describe('once일 때 필수 — 실행 날짜 YYYY-MM-DD'),
dows: z.array(z.number()).optional().describe('weekly일 때 요일 배열(0=일 … 6=토), 예: 평일은 [1,2,3,4,5]'),
agentSlug: z.string().optional().describe('실행할 크루 slug(기본 = 나 자신)'),
},
async ({ title, prompt, type, time, date, dows, everyMinutes, agentSlug }) => {
async ({ title, prompt, type, time, date, dows, everyMinutes, agentSlug, maxRuns, maxUsd }) => {
try {
const r = await addRoutine(wsId, {
agentSlug: agentSlug || fromSlug, title, prompt,
schedule: { type, ...(time ? { time } : {}), ...(date ? { date } : {}), ...(dows?.length ? { dows } : {}), ...(everyMinutes ? { everyMinutes } : {}) },
// interval = 자율 루프 — 회차·예산 상한을 기본으로 건다(무한 반복 방지). 다른 타입엔 addRoutine이 무시
...(type === 'interval' ? { loop: { maxRuns: maxRuns ?? 20, ...(maxUsd != null ? { maxUsd } : {}) } } : {}),
});
const when = type === 'once' ? `${r.schedule.date} ${r.schedule.time}`
: type === 'weekly' ? `매주 ${(r.schedule.dows ?? []).join(',')} ${r.schedule.time}`
: type === 'interval' ? `${r.schedule.everyMinutes}분마다`
: type === 'interval' ? `${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회`
: `매일 ${r.schedule.time}`;
return text(`예약 완료 — "${title}" (${when}, 담당 ${agentSlug || fromSlug}). 루틴 화면에서 사장이 끄거나 고칠 수 있다. 사장에게 언제 무엇을 하도록 걸어뒀는지 한 줄로 알려라.`);
} catch (e) {
Expand Down Expand Up @@ -1190,6 +1194,7 @@ ${lang === 'en'
}

let reply = '';
let costUsd = null; // 이 턴의 청구 금액 — 루프 루틴의 예산 합산용. 구독(OAuth)·openrouter·CLI 턴은 null(=0으로 합산)
let creditTurn = false; // OpenRouter 402 턴 표식 — 일지 기록 제외용(2R N3: 오류 원문이 기억으로 정제되지 않게)
let sid = resumeId; // 새 세션이면 null에서 시작 — 외래 sessionId를 내 것으로 재스탬프하지 않는다
const toolCounts = {}; // 이 턴의 도구 사용 횟수 — 크루 프로필 "많이 쓴 도구"의 원천
Expand Down Expand Up @@ -1310,6 +1315,7 @@ ${lang === 'en'
// 틀린 금액 표시·예산 차감은 이번에 죽인 신고 계열의 재발이다. 실비(P2)는 /generation API로.
usage: msg.usage, costUsd: runner === 'openrouter' ? null : msg.total_cost_usd, ms: Date.now() - t0, tools: toolCounts, billed,
});
if (billed && runner !== 'openrouter' && Number.isFinite(msg.total_cost_usd)) costUsd = msg.total_cost_usd;
}
if (msg.subtype === 'success') reply = msg.result;
else {
Expand Down Expand Up @@ -1430,5 +1436,5 @@ ${lang === 'en'
// diff와 합집합 — 도구 관측(즉시성)과 파일시스템 diff(Bash·MCP 포함 완전성)를 합친다. 필터는
// servableArtifact 하나로 통일(칩=서빙 일치 — 탐색 G8), 상한·정렬은 artDiff와 같은 규칙.
for (const r of await artDiff()) artifacts.add(r);
return { reply, sessionId: sid, handover, artifacts: capLatest(artAfter, [...artifacts].filter(servableArtifact)) }; // 합집합도 최신 우선 12(알파벳 컷이 최신을 떨구던 것 — 검수 LOW-2)
return { reply, sessionId: sid, handover, costUsd, artifacts: capLatest(artAfter, [...artifacts].filter(servableArtifact)) }; // 합집합도 최신 우선 12(알파벳 컷이 최신을 떨구던 것 — 검수 LOW-2)
}
9 changes: 6 additions & 3 deletions src/cli-directives.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { listAgents } from './hub.mjs';
import { callConnectorTool } from './connectors.mjs'; // 커넥터 단일 실행 경로 — SDK 표면과 같은 함수

/** 지시 블록 문법 — ```argo 펜스 안 JSON 1건. 여러 블록 허용.
스케줄: {"action":"schedule","every":"30m"|"time":"09:00","days":[1,3],"title":"...","prompt":"..."}
스케줄: {"action":"schedule","every":"30m"|"time":"09:00","days":[1,3],"title":"...","prompt":"...","maxRuns":20,"maxUsd":5} (maxRuns/maxUsd는 every 루프에만·선택)
쪽지: {"action":"mail","to":"슬러그","cc":["..."],"message":"..."}
커넥터: {"action":"tool","server":"gmail","tool":"search_threads","args":{…}} */
const BLOCK_RE = /```argo[ \t]*\r?\n([\s\S]*?)```/g;
Expand Down Expand Up @@ -118,14 +118,17 @@ export async function runDirectives(wsId, fromSlug, directives, { lang = 'ko', b
const prompt = String(d.prompt ?? '').trim();
if (!prompt) throw new Error(en ? 'prompt is required' : 'prompt가 필요합니다');
const target = d.crew ? find(d.crew) : null;
const schedule = normalizeSchedule(toSchedule(d));
const r = await addRoutine(wsId, {
agentSlug: target?.slug ?? fromSlug,
title: String(d.title ?? prompt).replace(/\s+/g, ' ').slice(0, 60),
prompt,
schedule: normalizeSchedule(toSchedule(d)),
schedule,
// SDK의 schedule_task와 패리티 — interval은 자율 루프(기본 20회 상한, maxRuns/maxUsd 지시 필드 수용)
...(schedule.type === 'interval' ? { loop: { maxRuns: d.maxRuns ?? 20, ...(d.maxUsd != null ? { maxUsd: d.maxUsd } : {}) } } : {}),
});
const when = r.schedule.type === 'interval'
? (en ? `every ${r.schedule.everyMinutes} min` : `${r.schedule.everyMinutes}분마다`)
? (en ? `every ${r.schedule.everyMinutes} min, up to ${r.loop?.maxRuns ?? 20} runs` : `${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회`)
: (r.schedule.times ?? []).join('·');
notes.push(en ? `✓ Routine registered — ${r.title} (${when})` : `✓ 루틴 등록됨 — ${r.title} (${when})`);
} else if (action === 'mail') {
Expand Down
Loading
Loading