axmap-cli 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import { spawnSync } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
9
9
  import { nativeConsoleHelper } from './windows-console.mjs'
10
- import type { SessionRecord, RequestRecord, ConsoleSnapshot, HookInput, TranscriptEntry } from './session-types.mjs'
10
+ import type { SessionRecord, RequestRecord, ConsoleSnapshot, HookInput, TranscriptEntry, SessionState } from './session-types.mjs'
11
11
 
12
12
  const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url))
13
13
  export function consoleCall(pid: number, action?: 'snapshot' | 'send', payload?: Record<string, unknown>): ConsoleSnapshot
@@ -20,19 +20,20 @@ export function consoleCall(pid: number, action = 'snapshot', payload: Record<st
20
20
  '-File', helper, '-TargetPid', String(pid), '-Action', action], {
21
21
  input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
22
22
  })
23
- let data: (Omit<Partial<ConsoleSnapshot>, 'ignored'> & { error?: string; ignored?: boolean }) | undefined
23
+ let data: (Omit<Partial<ConsoleSnapshot>, 'ignored'> & { error?: string; ignored?: boolean; notDelivered?: boolean }) | undefined
24
24
  try { data = JSON.parse(r.stdout) } catch { /* report the underlying failure */ }
25
- if (r.error || r.status !== 0 || data?.error || !data) throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패')
25
+ if (r.error || r.status !== 0 || data?.error || !data) throw Object.assign(new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패'),
26
+ { notDelivered: !r.error && data?.notDelivered === true })
26
27
  if (action === 'ancestor' && data.ignored) return { ignored: true }
27
28
  if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string')) throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.')
28
29
  return data as ConsoleSnapshot
29
30
  }
30
- function identityID({ pid, startedAt }: Pick<ConsoleSnapshot, 'pid' | 'startedAt'>): string {
31
- const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).digest('hex')
31
+ function identityID({ pid, startedAt, provider = 'agy' }: Pick<ConsoleSnapshot, 'pid' | 'startedAt' | 'provider'>): string {
32
+ const hex = createHash('sha256').update(`${provider}-console:${pid}:${startedAt}`).digest('hex')
32
33
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`
33
34
  }
34
35
  const target = (id: string) => path.join(bridgeHome(), 'sessions', `${id}.json`)
35
- export function consoleIdle(screen: string): boolean {
36
+ export function consoleIdle(screen: string, live?: ConsoleSnapshot): boolean {
36
37
  // Use the last input box only. Old answers may contain quoted prompt boxes.
37
38
  const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)]
38
39
  if (borders.length < 2) return false
@@ -40,7 +41,15 @@ export function consoleIdle(screen: string): boolean {
40
41
  if (!top || !bottom) return false
41
42
  const input = screen.slice(top.index + top[0].length, bottom.index).trim()
42
43
  const footer = screen.slice(bottom.index + bottom[0].length)
43
- return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer)
44
+ const empty = /^[>❯]$/.test(input)
45
+ // Claude paints a suggestion after the cursor in an otherwise empty editor.
46
+ // Require the cursor to remain at that editor's start, never just match text.
47
+ const placeholder = live?.provider === 'claude' && live.cursorX === 2 &&
48
+ /^[>❯]\s+Try "[^"\r\n]+"$/.test(input) &&
49
+ live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length
50
+ const claudeEditor = live?.provider === 'claude' && live.cursorX === 2 &&
51
+ live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length
52
+ return (empty || placeholder) && (live?.provider === 'claude' ? claudeEditor : /\? for shortcuts/.test(footer)) && !/esc to (cancel|interrupt)/i.test(footer)
44
53
  }
45
54
  export function attachConsole({ pid, cwd }: { pid: number; cwd: string }) {
46
55
  const live = consoleCall(pid)
@@ -48,17 +57,34 @@ export function attachConsole({ pid, cwd }: { pid: number; cwd: string }) {
48
57
  const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
49
58
  const repo = repoKey(cwd)
50
59
  if (previous.repos && !previous.repos.includes(repo)) throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.')
51
- const idle = consoleIdle(live.screen)
52
- const s: SessionRecord = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
60
+ const idle = consoleIdle(live.screen, live)
61
+ const s: SessionRecord = { ...previous, id, provider: live.provider || 'agy', transport: 'console', pid, startedAt: live.startedAt,
53
62
  workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
54
63
  state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' }
55
64
  writeJSON(target(id), s)
56
65
  return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces }
57
66
  }
58
- export function registerExternalHook(input: HookInput, event: string) {
59
- if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
67
+ export function registerExternalHook(input: HookInput, event: string, provider = 'agy') {
68
+ if (!['SessionStart', 'PreInvocation', 'UserPromptSubmit', 'Stop', 'SessionEnd'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
60
69
  const identity = consoleCall(process.pid, 'ancestor')
61
70
  if (identity.ignored) return {}
71
+ if (identity.provider && identity.provider !== provider) return {}
72
+ if (provider === 'claude') {
73
+ const conversationId = input.session_id
74
+ const log = input.transcript_path
75
+ const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
76
+ const relative = typeof log === 'string' ? path.relative(path.join(home, 'projects'), path.resolve(log)) : '..'
77
+ if (!conversationId || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId) ||
78
+ !log || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(log) !== `${conversationId}.jsonl` ||
79
+ !input.cwd || !path.isAbsolute(input.cwd)) throw new Error('Claude 훅의 대화 기록·작업 폴더 신원이 올바르지 않습니다.')
80
+ const id = identityID(identity)
81
+ const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
82
+ const state = event === 'SessionEnd' ? 'closed' : event === 'Stop' || event === 'SessionStart' ? 'settling' : 'running'
83
+ writeJSON(target(id), { ...previous, ...identity, id, provider: 'claude', transport: 'console', conversationId,
84
+ transcript: path.resolve(log), workspaces: [path.resolve(input.cwd)], repos: [repoKey(input.cwd)],
85
+ state, fullyIdle: false, event, registeredAt: new Date().toISOString() })
86
+ return { id }
87
+ }
62
88
  const id = identityID(identity)
63
89
  const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
64
90
  const workspaces = input.workspacePaths || previous.workspaces
@@ -77,7 +103,8 @@ export function registerExternalHook(input: HookInput, event: string) {
77
103
  export function externalStatus(session: Pick<SessionRecord, 'id'>) {
78
104
  const s = readJSON<SessionRecord>(target(session.id))
79
105
  const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
80
- const state: 'idle' | 'unknown' = consoleIdle(live.screen) ? 'idle' : 'unknown'
106
+ const eligible = s.provider !== 'claude' || ['SessionStart', 'Stop'].includes(s.event || '')
107
+ const state: SessionState = s.state === 'closed' ? 'closed' : eligible && consoleIdle(live.screen, live) ? 'idle' : 'unknown'
81
108
  writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state, fullyIdle: state === 'idle' })
82
109
  return { id: s.id, state }
83
110
  }
@@ -87,7 +114,8 @@ function consolePID(s: SessionRecord): number {
87
114
  }
88
115
  export function checkConsole(s: SessionRecord): ConsoleSnapshot {
89
116
  const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
90
- if (!consoleIdle(live.screen)) throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.')
117
+ if (s.provider === 'claude' && !['SessionStart', 'Stop'].includes(s.event || '')) throw new Error('Claude가 실행·승인 대기 중입니다.')
118
+ if (!consoleIdle(live.screen, live)) throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.')
91
119
  return live
92
120
  }
93
121
  export function validateConsolePrompt(prompt: string, from: string): void {
@@ -95,6 +123,7 @@ export function validateConsolePrompt(prompt: string, from: string): void {
95
123
  }
96
124
  export function findConsoleTranscript(s: SessionRecord, request: RequestRecord): SessionRecord {
97
125
  if (s.transcript) return s
126
+ if (s.provider === 'claude') return s
98
127
  const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain')
99
128
  if (!fs.existsSync(brain)) return s
100
129
  const marker = `[axMap request ${request.id}]`
@@ -122,7 +151,8 @@ export function submitConsole(s: SessionRecord, request: RequestRecord, prefligh
122
151
  // Bracketed paste preserves multiline prompts and prevents embedded Enter keys
123
152
  // from becoming separate submissions. Only the final CR submits the message.
124
153
  const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`
125
- consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen, text })
154
+ consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
155
+ expectedCursorX: live.cursorX, expectedCursorRow: live.cursorRow, text })
126
156
  writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state: 'running', fullyIdle: false })
127
157
  return { accepted: true, pid: s.pid }
128
158
  }
@@ -10,12 +10,16 @@ import { bridgeHome, hostPipe, repoKey, writeJSON } from './bridge.mjs';
10
10
  import { normalizeUsage, usageDelta } from './session-usage.mjs';
11
11
  import { errorMessage } from './session-types.mjs';
12
12
  import { connectCodexProxy } from './codex-connection.mjs';
13
+ const CLAUDE_USAGE_KEYS = ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens', 'thinking_tokens'];
13
14
  // 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
14
15
  const pipeFor = hostPipe;
15
16
  const uuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '');
16
- export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false, cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
17
- if (!['codex', 'agy'].includes(provider))
18
- throw new Error('지원하는 세션은 codex 또는 agy입니다.');
17
+ export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false, permissionMode = 'default', cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
18
+ if (!['codex', 'agy', 'claude'].includes(provider))
19
+ throw new Error('지원하는 세션은 codex, agy 또는 claude입니다.');
20
+ // 읽기 전용이 기본이다(Codex 의 sandbox read-only 와 같은 자리). 고쳐도 되는 세션은 사람이 serve 할 때 고른다.
21
+ if (!['default', 'plan', 'acceptEdits'].includes(permissionMode))
22
+ throw new Error('claude 권한은 default, plan 또는 acceptEdits입니다.');
19
23
  if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
20
24
  throw new Error('양의 토큰/시간 한도가 필요합니다.');
21
25
  if (typeof role !== 'string' || role.length > 1000)
@@ -28,7 +32,26 @@ export async function startManagedSession({ provider, executable = provider, pre
28
32
  workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false, hostPid: process.pid,
29
33
  registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens };
30
34
  const jobs = new Map();
35
+ const queue = [];
36
+ const persistJob = (job) => writeJSON(path.join(bridgeHome(), 'jobs', `${job.id}.json`), job);
37
+ let draining = false;
38
+ const drain = () => {
39
+ if (closed || draining || pending || s.state !== 'idle' || !queue.length)
40
+ return;
41
+ draining = true;
42
+ const request = queue.shift();
43
+ jobs.delete(request.id);
44
+ void handle({ action: 'submit', token, request }).catch(e => {
45
+ const job = { id: request.id, to: id, state: 'failed', error: errorMessage(e), completedAt: new Date().toISOString() };
46
+ jobs.set(request.id, job);
47
+ persistJob(job);
48
+ emit({ event: 'result', request: job });
49
+ }).finally(() => { draining = false; if (!pending)
50
+ setImmediate(drain); });
51
+ };
31
52
  let attachedUsageOrigin = null;
53
+ // Claude Code 는 실행마다 그 실행분만 준다. 다른 provider 처럼 누적값으로 다루려고 여기서 더한다.
54
+ let claudeTotals = null;
32
55
  let initialThreadState = 'idle';
33
56
  const calls = new Map();
34
57
  let child, sequence = 0, pending = null, closed = false, buffer = '', stderr = '', turnTimer;
@@ -67,12 +90,14 @@ export async function startManagedSession({ provider, executable = provider, pre
67
90
  attachedUsageOrigin = usage;
68
91
  }
69
92
  s.usageKnown = usage !== null;
93
+ persistJob(pending);
70
94
  emit({ event: 'result', request: { ...pending } });
71
95
  pending = null;
72
96
  s.state = 'idle';
73
97
  s.fullyIdle = true;
74
98
  delete s.pendingApproval;
75
99
  save();
100
+ setImmediate(drain);
76
101
  };
77
102
  const shutdown = (reason) => {
78
103
  if (closed)
@@ -80,6 +105,11 @@ export async function startManagedSession({ provider, executable = provider, pre
80
105
  finish('unknown', undefined, reason);
81
106
  closed = true;
82
107
  clearTimeout(turnTimer);
108
+ for (const request of queue.splice(0)) {
109
+ const job = { id: request.id, to: id, state: 'failed', error: reason, completedAt: new Date().toISOString() };
110
+ jobs.set(request.id, job);
111
+ persistJob(job);
112
+ }
83
113
  for (const c of calls.values()) {
84
114
  clearTimeout(c.timer);
85
115
  c.reject(new Error(reason));
@@ -129,31 +159,49 @@ export async function startManagedSession({ provider, executable = provider, pre
129
159
  }
130
160
  if (message.action !== 'submit')
131
161
  throw new Error('지원하지 않는 로컬 세션 요청입니다.');
162
+ // 🔴 여기부터 접수(pending) 전까지의 거절은 모델을 부르지 않은 확정 거절이다. refused 를 붙여 보낸 쪽이
163
+ // unknown 이 아니라 failed 로 기록하게 한다 — unknown 으로 남으면 그 세션의 다음 요청이 「이전 요청 미확정」으로
164
+ // 영영 막힌다(토큰 한도에 걸린 Claude 세션에 보낸 요청이 실제로 그렇게 막혔다).
165
+ const refuse = (text) => Object.assign(new Error(text), { refused: true });
132
166
  if (jobs.has(message.request?.id))
133
- throw new Error('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.');
167
+ throw refuse('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.');
168
+ const queuedRequest = message.request;
169
+ if (!uuid(queuedRequest?.id) || queuedRequest.to !== id || typeof queuedRequest.prompt !== 'string' || !queuedRequest.prompt.trim() || Buffer.byteLength(queuedRequest.prompt) > 32768)
170
+ throw refuse('요청 형식이 올바르지 않습니다.');
171
+ if (message.queueIfBusy && !closed && (pending || draining || queue.length || s.state === 'running' || s.state === 'awaiting_user')) {
172
+ if (queue.length >= 100)
173
+ throw refuse('명령 큐가 가득 찼습니다.');
174
+ const job = { id: queuedRequest.id, to: id, state: 'queued' };
175
+ persistJob(job);
176
+ jobs.set(job.id, job);
177
+ queue.push(queuedRequest);
178
+ emit({ event: 'queued', request: job });
179
+ return { accepted: true, queued: true, session: id, position: queue.length };
180
+ }
134
181
  if (closed || s.state !== 'idle' || pending)
135
- throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.');
182
+ throw refuse('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.');
136
183
  if (existingThreadId) {
137
184
  if (checkingSubmission)
138
- throw new Error('다른 요청의 상태를 확인 중입니다.');
185
+ throw refuse('다른 요청의 상태를 확인 중입니다.');
139
186
  checkingSubmission = true;
140
187
  try {
141
188
  const read = await rpc('thread/read', { threadId: existingThreadId });
142
189
  if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle')
143
- throw new Error('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.');
190
+ throw refuse('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.');
144
191
  }
145
192
  finally {
146
193
  checkingSubmission = false;
147
194
  }
148
195
  }
149
196
  if (!s.usageKnown)
150
- throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.');
197
+ throw refuse('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.');
151
198
  if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens)
152
- throw new Error('세션 토큰 한도에 도달했습니다.');
199
+ throw refuse('세션 토큰 한도에 도달했습니다.');
153
200
  const request = message.request;
154
201
  if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768)
155
- throw new Error('요청 형식이 올바르지 않습니다.');
202
+ throw refuse('요청 형식이 올바르지 않습니다.');
156
203
  pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown && (!existingThreadId || !!s.usage), result: '' };
204
+ persistJob(pending);
157
205
  jobs.set(request.id, pending);
158
206
  s.state = 'running';
159
207
  s.fullyIdle = false;
@@ -162,6 +210,8 @@ export async function startManagedSession({ provider, executable = provider, pre
162
210
  turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs);
163
211
  if (provider === 'agy')
164
212
  write({ event: 'user', message: { content: prompt } });
213
+ else if (provider === 'claude')
214
+ write({ type: 'user', message: { role: 'user', content: prompt } });
165
215
  else
166
216
  rpc('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], ...(!existingThreadId ? { effort: 'low' } : {}) })
167
217
  .then(r => { if (pending?.id === request.id)
@@ -185,7 +235,7 @@ export async function startManagedSession({ provider, executable = provider, pre
185
235
  return;
186
236
  handled = true;
187
237
  try {
188
- void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n'));
238
+ void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e), ...(e?.refused ? { refused: true } : {}) }) + '\n'));
189
239
  }
190
240
  catch (e) {
191
241
  socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n');
@@ -198,7 +248,9 @@ export async function startManagedSession({ provider, executable = provider, pre
198
248
  fs.chmodSync(pipeFor(id), 0o600);
199
249
  save();
200
250
  const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
201
- : ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])]);
251
+ : provider === 'claude' ? ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
252
+ '--permission-mode', permissionMode, ...(model ? ['--model', model] : [])]
253
+ : ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])]);
202
254
  child = existingThreadId ? connectCodexProxy({ executable, prefix, socketPath: socketPath, cwd }) : spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
203
255
  env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } });
204
256
  child.stdin.on('error', e => shutdown(e.message));
@@ -220,6 +272,30 @@ export async function startManagedSession({ provider, executable = provider, pre
220
272
  continue;
221
273
  try {
222
274
  const m = JSON.parse(line);
275
+ if (provider === 'claude') {
276
+ const c = m;
277
+ // init 은 실행마다 온다. 첫 init 의 대화 ID 를 붙잡고, 바뀌면 다른 대화에 답이 섞인 것이라 닫는다.
278
+ if (c.type === 'system' && c.subtype === 'init') {
279
+ if (!s.conversationId) {
280
+ s.conversationId = c.session_id;
281
+ save();
282
+ emit({ event: 'conversation', conversationId: s.conversationId });
283
+ }
284
+ else if (c.session_id !== s.conversationId)
285
+ throw new Error('claude 대화 ID가 바뀌었습니다.');
286
+ }
287
+ if (c.type === 'result' && pending) {
288
+ if (c.session_id !== s.conversationId)
289
+ throw new Error('claude 대화 ID가 바뀌었습니다.');
290
+ const turn = c.usage ? { ...c.usage, thinking_tokens: c.usage.output_tokens_details?.thinking_tokens ?? 0 } : null;
291
+ const known = !!turn && CLAUDE_USAGE_KEYS.every(k => Number.isSafeInteger(turn[k]) && turn[k] >= 0);
292
+ if (known)
293
+ claudeTotals = Object.fromEntries(CLAUDE_USAGE_KEYS.map(k => [k, (claudeTotals?.[k] || 0) + turn[k]]));
294
+ const ok = c.subtype === 'success' && !c.is_error;
295
+ finish(ok ? 'completed' : 'failed', c.result, ok ? undefined : String(c.result || c.subtype), known ? claudeTotals : null);
296
+ }
297
+ continue;
298
+ }
223
299
  if (provider === 'agy') {
224
300
  if (m.event === 'init') {
225
301
  s.conversationId = m.conversation_id;
@@ -264,6 +340,7 @@ export async function startManagedSession({ provider, executable = provider, pre
264
340
  s.state = status?.type === 'active' ? 'running' : status?.type === 'idle' ? 'idle' : 'unknown';
265
341
  s.fullyIdle = s.state === 'idle';
266
342
  save();
343
+ setImmediate(drain);
267
344
  }
268
345
  if (m.method === 'thread/tokenUsage/updated') {
269
346
  if (pending)
@@ -317,8 +394,20 @@ export async function startManagedSession({ provider, executable = provider, pre
317
394
  s.state = initialThreadState === 'active' ? 'running' : 'idle';
318
395
  s.fullyIdle = s.state === 'idle';
319
396
  save();
397
+ setImmediate(drain);
320
398
  emit({ event: 'ready', conversationId: s.conversationId });
321
399
  }
400
+ else if (provider === 'claude') {
401
+ // Claude Code 는 첫 입력이 올 때까지 아무것도 내보내지 않는다. 프로세스가 떴으면 대기로 둔다 —
402
+ // 대화 ID 는 첫 실행의 init 에서 정해진다. 기다리는 동안 모델은 부르지 않는다.
403
+ await new Promise((resolve, reject) => { child.once('spawn', () => resolve()); child.once('error', reject); });
404
+ if (closed)
405
+ throw new Error(`claude 시작 실패: ${stderr.slice(-1000)}`);
406
+ s.state = 'idle';
407
+ s.fullyIdle = true;
408
+ save();
409
+ emit({ event: 'ready', conversationId: null });
410
+ }
322
411
  else {
323
412
  const deadline = Date.now() + 30000;
324
413
  while (s.state === 'starting' && !closed && Date.now() < deadline)
@@ -11,7 +11,7 @@ import { errorMessage } from './session-types.mjs'
11
11
  import { connectCodexProxy } from './codex-connection.mjs'
12
12
  import type { ProviderProcess } from './codex-connection.mjs'
13
13
  import type { ChildProcessWithoutNullStreams } from 'node:child_process'
14
- import type { Provider, SessionRecord, RequestState, LocalMessage, JobRecord } from './session-types.mjs'
14
+ import type { Provider, SessionRecord, RequestState, LocalMessage, JobRecord, LocalRequest } from './session-types.mjs'
15
15
  import type { Usage } from './session-types.mjs'
16
16
 
17
17
  interface ManagedSessionOptions {
@@ -19,6 +19,7 @@ interface ManagedSessionOptions {
19
19
  cwd?: string; maxTokens?: number; timeoutMs?: number; onEvent?: (event: Record<string, unknown>) => void
20
20
  existingThreadId?: string; socketPath?: string
21
21
  observeActive?: boolean
22
+ permissionMode?: string
22
23
  }
23
24
  interface ProviderMessage {
24
25
  id?: string | number; method?: string; event?: string; conversation_id?: string
@@ -30,14 +31,19 @@ interface ProviderMessage {
30
31
  }
31
32
  }
32
33
  interface AgyResult { conversation_id: string; status: string; response?: unknown; error?: string; usage?: unknown }
34
+ interface ClaudeMessage { type?: string; subtype?: string; session_id?: string; result?: unknown; is_error?: boolean
35
+ usage?: { output_tokens_details?: { thinking_tokens?: number }; [key: string]: unknown } }
36
+ const CLAUDE_USAGE_KEYS = ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens', 'thinking_tokens'] as const
33
37
 
34
38
  // 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
35
39
  const pipeFor = hostPipe
36
40
  const uuid = (value: string | undefined) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '')
37
41
 
38
42
  export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false,
39
- cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => {} }: ManagedSessionOptions) {
40
- if (!['codex', 'agy'].includes(provider)) throw new Error('지원하는 세션은 codex 또는 agy입니다.')
43
+ permissionMode = 'default', cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => {} }: ManagedSessionOptions) {
44
+ if (!['codex', 'agy', 'claude'].includes(provider)) throw new Error('지원하는 세션은 codex, agy 또는 claude입니다.')
45
+ // 읽기 전용이 기본이다(Codex 의 sandbox read-only 와 같은 자리). 고쳐도 되는 세션은 사람이 serve 할 때 고른다.
46
+ if (!['default', 'plan', 'acceptEdits'].includes(permissionMode)) throw new Error('claude 권한은 default, plan 또는 acceptEdits입니다.')
41
47
  if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 토큰/시간 한도가 필요합니다.')
42
48
  if (typeof role !== 'string' || role.length > 1000) throw new Error('역할은 1000자 이하여야 합니다.')
43
49
  if (existingThreadId && (provider !== 'codex' || !uuid(existingThreadId) || !socketPath || model || args)) throw new Error('기존 Codex thread와 로컬 socket이 필요하며 모델·실행 인수를 바꾸지 않습니다.')
@@ -47,7 +53,22 @@ export async function startManagedSession({ provider, executable = provider, pre
47
53
  workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false, hostPid: process.pid,
48
54
  registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens }
49
55
  const jobs = new Map<string, JobRecord>()
56
+ const queue: LocalRequest[] = []
57
+ const persistJob = (job: JobRecord) => writeJSON(path.join(bridgeHome(), 'jobs', `${job.id}.json`), job)
58
+ let draining = false
59
+ const drain = () => {
60
+ if (closed || draining || pending || s.state !== 'idle' || !queue.length) return
61
+ draining = true
62
+ const request = queue.shift()!
63
+ jobs.delete(request.id)
64
+ void handle({ action: 'submit', token, request }).catch(e => {
65
+ const job: JobRecord = { id: request.id, to: id, state: 'failed', error: errorMessage(e), completedAt: new Date().toISOString() }
66
+ jobs.set(request.id, job); persistJob(job); emit({ event: 'result', request: job })
67
+ }).finally(() => { draining = false; if (!pending) setImmediate(drain) })
68
+ }
50
69
  let attachedUsageOrigin: Usage | null = null
70
+ // Claude Code 는 실행마다 그 실행분만 준다. 다른 provider 처럼 누적값으로 다루려고 여기서 더한다.
71
+ let claudeTotals: Record<string, number> | null = null
51
72
  let initialThreadState = 'idle'
52
73
  const calls = new Map<string | number, { resolve: (result: unknown) => void; reject: (reason: Error) => void; timer: NodeJS.Timeout }>()
53
74
  let child: ProviderProcess | undefined, sequence = 0, pending: JobRecord | null = null, closed = false, buffer = '', stderr = '', turnTimer: NodeJS.Timeout | undefined
@@ -73,13 +94,18 @@ export async function startManagedSession({ provider, executable = provider, pre
73
94
  delete pending.baselineKnown
74
95
  if (usage) { s.usage = usage; if (existingThreadId && !attachedUsageOrigin) attachedUsageOrigin = usage }
75
96
  s.usageKnown = usage !== null
97
+ persistJob(pending)
76
98
  emit({ event: 'result', request: { ...pending } })
77
- pending = null; s.state = 'idle'; s.fullyIdle = true; delete s.pendingApproval; save()
99
+ pending = null; s.state = 'idle'; s.fullyIdle = true; delete s.pendingApproval; save(); setImmediate(drain)
78
100
  }
79
101
  const shutdown = (reason: string) => {
80
102
  if (closed) return
81
103
  finish('unknown', undefined, reason)
82
104
  closed = true; clearTimeout(turnTimer)
105
+ for (const request of queue.splice(0)) {
106
+ const job: JobRecord = { id: request.id, to: id, state: 'failed', error: reason, completedAt: new Date().toISOString() }
107
+ jobs.set(request.id, job); persistJob(job)
108
+ }
83
109
  for (const c of calls.values()) { clearTimeout(c.timer); c.reject(new Error(reason)) }
84
110
  calls.clear()
85
111
  s.state = 'closed'; s.fullyIdle = false; save()
@@ -112,25 +138,39 @@ export async function startManagedSession({ provider, executable = provider, pre
112
138
  delete s.pendingApproval; s.state = 'running'; save(); return { answered: true }
113
139
  }
114
140
  if (message.action !== 'submit') throw new Error('지원하지 않는 로컬 세션 요청입니다.')
115
- if (jobs.has(message.request?.id)) throw new Error('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.')
116
- if (closed || s.state !== 'idle' || pending) throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.')
141
+ // 🔴 여기부터 접수(pending) 전까지의 거절은 모델을 부르지 않은 확정 거절이다. refused 를 붙여 보낸 쪽이
142
+ // unknown 이 아니라 failed 로 기록하게 한다 — unknown 으로 남으면 그 세션의 다음 요청이 「이전 요청 미확정」으로
143
+ // 영영 막힌다(토큰 한도에 걸린 Claude 세션에 보낸 요청이 실제로 그렇게 막혔다).
144
+ const refuse = (text: string) => Object.assign(new Error(text), { refused: true })
145
+ if (jobs.has(message.request?.id)) throw refuse('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.')
146
+ const queuedRequest = message.request
147
+ if (!uuid(queuedRequest?.id) || queuedRequest.to !== id || typeof queuedRequest.prompt !== 'string' || !queuedRequest.prompt.trim() || Buffer.byteLength(queuedRequest.prompt) > 32768) throw refuse('요청 형식이 올바르지 않습니다.')
148
+ if (message.queueIfBusy && !closed && (pending || draining || queue.length || s.state === 'running' || s.state === 'awaiting_user')) {
149
+ if (queue.length >= 100) throw refuse('명령 큐가 가득 찼습니다.')
150
+ const job: JobRecord = { id: queuedRequest.id, to: id, state: 'queued' }
151
+ persistJob(job); jobs.set(job.id, job); queue.push(queuedRequest)
152
+ emit({ event: 'queued', request: job })
153
+ return { accepted: true, queued: true, session: id, position: queue.length }
154
+ }
155
+ if (closed || s.state !== 'idle' || pending) throw refuse('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.')
117
156
  if (existingThreadId) {
118
- if (checkingSubmission) throw new Error('다른 요청의 상태를 확인 중입니다.')
157
+ if (checkingSubmission) throw refuse('다른 요청의 상태를 확인 중입니다.')
119
158
  checkingSubmission = true
120
159
  try {
121
160
  const read = await rpc<{ thread: { id: string; status: { type: string } } }>('thread/read', { threadId: existingThreadId })
122
- if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle') throw new Error('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.')
161
+ if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle') throw refuse('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.')
123
162
  } finally { checkingSubmission = false }
124
163
  }
125
- if (!s.usageKnown) throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.')
126
- if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens) throw new Error('세션 토큰 한도에 도달했습니다.')
164
+ if (!s.usageKnown) throw refuse('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.')
165
+ if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens) throw refuse('세션 토큰 한도에 도달했습니다.')
127
166
  const request = message.request
128
- if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw new Error('요청 형식이 올바르지 않습니다.')
167
+ if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw refuse('요청 형식이 올바르지 않습니다.')
129
168
  pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown && (!existingThreadId || !!s.usage), result: '' }
130
- jobs.set(request.id, pending); s.state = 'running'; s.fullyIdle = false; save()
169
+ persistJob(pending); jobs.set(request.id, pending); s.state = 'running'; s.fullyIdle = false; save()
131
170
  const prompt = `[axMap request ${request.id}]\n요청자: ${request.from || 'axmap'}\n${role ? `역할: ${role}\n` : ''}${request.prompt}`
132
171
  turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs)
133
172
  if (provider === 'agy') write({ event: 'user', message: { content: prompt } })
173
+ else if (provider === 'claude') write({ type: 'user', message: { role: 'user', content: prompt } })
134
174
  else rpc<{ turn: { id: string } }>('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], ...(!existingThreadId ? { effort: 'low' } : {}) })
135
175
  .then(r => { if (pending?.id === request.id) pending.turnId = r.turn.id })
136
176
  .catch(e => shutdown(e.message))
@@ -146,7 +186,7 @@ export async function startManagedSession({ provider, executable = provider, pre
146
186
  if (Buffer.byteLength(input) > 65536) return socket.destroy()
147
187
  const end = input.indexOf('\n'); if (end < 0) return
148
188
  handled = true
149
- try { void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n')) }
189
+ try { void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e), ...((e as { refused?: boolean })?.refused ? { refused: true } : {}) }) + '\n')) }
150
190
  catch (e) { socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n') }
151
191
  })
152
192
  })
@@ -155,6 +195,8 @@ export async function startManagedSession({ provider, executable = provider, pre
155
195
  if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
156
196
  save()
157
197
  const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
198
+ : provider === 'claude' ? ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
199
+ '--permission-mode', permissionMode, ...(model ? ['--model', model] : [])]
158
200
  : ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])])
159
201
  child = existingThreadId ? connectCodexProxy({ executable, prefix, socketPath: socketPath!, cwd }) : spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
160
202
  env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } })
@@ -174,6 +216,23 @@ export async function startManagedSession({ provider, executable = provider, pre
174
216
  if (!line.trim()) continue
175
217
  try {
176
218
  const m = JSON.parse(line) as ProviderMessage
219
+ if (provider === 'claude') {
220
+ const c = m as unknown as ClaudeMessage
221
+ // init 은 실행마다 온다. 첫 init 의 대화 ID 를 붙잡고, 바뀌면 다른 대화에 답이 섞인 것이라 닫는다.
222
+ if (c.type === 'system' && c.subtype === 'init') {
223
+ if (!s.conversationId) { s.conversationId = c.session_id; save(); emit({ event: 'conversation', conversationId: s.conversationId }) }
224
+ else if (c.session_id !== s.conversationId) throw new Error('claude 대화 ID가 바뀌었습니다.')
225
+ }
226
+ if (c.type === 'result' && pending) {
227
+ if (c.session_id !== s.conversationId) throw new Error('claude 대화 ID가 바뀌었습니다.')
228
+ const turn: Record<string, unknown> | null = c.usage ? { ...c.usage, thinking_tokens: c.usage.output_tokens_details?.thinking_tokens ?? 0 } : null
229
+ const known = !!turn && CLAUDE_USAGE_KEYS.every(k => Number.isSafeInteger(turn[k]) && (turn[k] as number) >= 0)
230
+ if (known) claudeTotals = Object.fromEntries(CLAUDE_USAGE_KEYS.map(k => [k, (claudeTotals?.[k] || 0) + (turn![k] as number)]))
231
+ const ok = c.subtype === 'success' && !c.is_error
232
+ finish(ok ? 'completed' : 'failed', c.result, ok ? undefined : String(c.result || c.subtype), known ? claudeTotals : null)
233
+ }
234
+ continue
235
+ }
177
236
  if (provider === 'agy') {
178
237
  if (m.event === 'init') { s.conversationId = m.conversation_id; s.state = 'idle'; s.fullyIdle = true; save(); emit({ event: 'ready', conversationId: s.conversationId }) }
179
238
  if (m.event === 'result' && pending) {
@@ -199,7 +258,7 @@ export async function startManagedSession({ provider, executable = provider, pre
199
258
  if (m.method === 'thread/status/changed' && !pending) {
200
259
  const status = m.params?.status as { type?: string } | undefined
201
260
  s.state = status?.type === 'active' ? 'running' : status?.type === 'idle' ? 'idle' : 'unknown'
202
- s.fullyIdle = s.state === 'idle'; save()
261
+ s.fullyIdle = s.state === 'idle'; save(); setImmediate(drain)
203
262
  }
204
263
  if (m.method === 'thread/tokenUsage/updated') {
205
264
  if (pending) pending.rawUsage = m.params?.tokenUsage?.total
@@ -231,8 +290,14 @@ export async function startManagedSession({ provider, executable = provider, pre
231
290
  ? { threadId: existingThreadId, excludeTurns: true }
232
291
  : { cwd: path.resolve(cwd), ...(model ? { model } : {}), sandbox: 'read-only', approvalPolicy: 'on-request' })
233
292
  if (existingThreadId && r.thread.id !== existingThreadId) throw new Error('기존 thread ID가 바뀌었습니다.')
234
- s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = initialThreadState === 'active' ? 'running' : 'idle'; s.fullyIdle = s.state === 'idle'; save()
293
+ s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = initialThreadState === 'active' ? 'running' : 'idle'; s.fullyIdle = s.state === 'idle'; save(); setImmediate(drain)
235
294
  emit({ event: 'ready', conversationId: s.conversationId })
295
+ } else if (provider === 'claude') {
296
+ // Claude Code 는 첫 입력이 올 때까지 아무것도 내보내지 않는다. 프로세스가 떴으면 대기로 둔다 —
297
+ // 대화 ID 는 첫 실행의 init 에서 정해진다. 기다리는 동안 모델은 부르지 않는다.
298
+ await new Promise<void>((resolve, reject) => { child!.once('spawn', () => resolve()); child!.once('error', reject) })
299
+ if (closed) throw new Error(`claude 시작 실패: ${stderr.slice(-1000)}`)
300
+ s.state = 'idle'; s.fullyIdle = true; save(); emit({ event: 'ready', conversationId: null })
236
301
  } else {
237
302
  const deadline = Date.now() + 30000
238
303
  while (s.state === 'starting' && !closed && Date.now() < deadline) await new Promise(r => setTimeout(r, 25))
package/src/monitor.mjs CHANGED
@@ -6,8 +6,8 @@ import { bridgeHome, repoKey, requestResult } from './bridge.mjs';
6
6
  import { sessionStatus } from './session-status.mjs';
7
7
  import { errorCode, errorMessage } from './session-types.mjs';
8
8
  const uuidFile = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/i;
9
- const pending = new Set(['sending', 'submitted', 'unknown']);
10
- const requestLabels = { sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' };
9
+ const pending = new Set(['queued', 'sending', 'submitted', 'unknown']);
10
+ const requestLabels = { queued: '작업 큐 대기', sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' };
11
11
  const sessionLabels = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '사용자 승인 필요', settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' };
12
12
  const dialogueLabels = { queued: '접수', running: '중계 중', stopping: '중단 대기', completed: '완료', failed: '실패', cancelled: '중단', token_limit: '토큰 한도', unknown: '확인 필요', usage_unavailable: '사용량 미확인', message_limit: '메시지 한도', invalid_response: '응답 형식 확인 필요', turn_limit: '횟수 한도' };
13
13
  function records(directory, issues, label, uuidOnly = true) {
@@ -74,7 +74,7 @@ export function createMonitor(cwd, options = {}) {
74
74
  const refreshed = new Set();
75
75
  for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
76
76
  const request = requests[index];
77
- if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to))
77
+ if (!['queued', 'submitted', 'unknown'].includes(request.state) || refreshed.has(request.to))
78
78
  continue;
79
79
  refreshed.add(request.to);
80
80
  try {
package/src/monitor.mts CHANGED
@@ -13,8 +13,8 @@ export interface MonitorSnapshot {
13
13
  dialogues: DialogueRecord[]; claims: MonitorClaim[]; events: MonitorEvent[]; issues: string[]
14
14
  }
15
15
  const uuidFile = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/i
16
- const pending = new Set(['sending', 'submitted', 'unknown'])
17
- const requestLabels: Record<string, string> = { sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' }
16
+ const pending = new Set(['queued', 'sending', 'submitted', 'unknown'])
17
+ const requestLabels: Record<string, string> = { queued: '작업 큐 대기', sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' }
18
18
  const sessionLabels: Record<string, string> = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '사용자 승인 필요', settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' }
19
19
  const dialogueLabels: Record<string, string> = { queued: '접수', running: '중계 중', stopping: '중단 대기', completed: '완료', failed: '실패', cancelled: '중단', token_limit: '토큰 한도', unknown: '확인 필요', usage_unavailable: '사용량 미확인', message_limit: '메시지 한도', invalid_response: '응답 형식 확인 필요', turn_limit: '횟수 한도' }
20
20
 
@@ -64,7 +64,7 @@ export function createMonitor(cwd: string, options: {
64
64
  const refreshed = new Set<string>()
65
65
  for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
66
66
  const request = requests[index]!
67
- if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to)) continue
67
+ if (!['queued', 'submitted', 'unknown'].includes(request.state) || refreshed.has(request.to)) continue
68
68
  refreshed.add(request.to)
69
69
  try { requests[index] = resolve(request.id, workspace) }
70
70
  catch (e) { issues.push(`요청 ${request.id.slice(0, 8)}: ${errorMessage(e)}`) }