axmap-cli 1.9.0 → 1.11.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.
package/src/bridge.mts CHANGED
@@ -4,10 +4,11 @@ import fs from 'node:fs'
4
4
  import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { randomUUID } from 'node:crypto'
7
- import { spawnSync } from 'node:child_process'
7
+ import { spawn, spawnSync } from 'node:child_process'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { withRepoLock } from './repolock.mjs'
10
10
  import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs'
11
+ import { enqueueAppRequest } from './app-session.mjs'
11
12
  import { errorCode, errorMessage, RPC_NOT_DELIVERED_EXIT } from './session-types.mjs'
12
13
  import { commonKey, resolveTarget } from './workspace.mjs'
13
14
  import type { HostLiveness } from './session-types.mjs'
@@ -82,6 +83,20 @@ function runAPI(session: SessionRecord, args: string[]): unknown {
82
83
  return null
83
84
  }
84
85
  function transcript(session: SessionRecord): TranscriptEntry[] {
86
+ if (session.provider === 'claude') {
87
+ const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
88
+ const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..'
89
+ if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`) throw new Error('Claude transcript identity mismatch')
90
+ if (!fs.existsSync(session.transcript)) return []
91
+ return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
92
+ const entry = JSON.parse(line) as { type?: string; sessionId?: string; message?: { content?: string | Array<{ type: string; text?: string }> } }
93
+ if (entry.sessionId && entry.sessionId !== session.conversationId) return []
94
+ const content = entry.message?.content
95
+ const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : ''
96
+ if (!text || !['user', 'assistant'].includes(entry.type || '')) return []
97
+ return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }]
98
+ })
99
+ }
85
100
  const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
86
101
  if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
87
102
  const lines = fs.readFileSync(expected, 'utf8').split('\n')
@@ -97,6 +112,7 @@ function publicSession(s: SessionRecord, liveness?: HostLiveness): PublicSession
97
112
  // 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
98
113
  fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
99
114
  role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
115
+ ...(s.dialogueMode ? { dialogueMode: s.dialogueMode } : {}),
100
116
  ...(liveness ? { liveness } : {}) }
101
117
  }
102
118
 
@@ -164,7 +180,7 @@ export function sessions(cwd: string): PublicSession[] {
164
180
  function sessionFor(id: string, cwd: string): SessionRecord {
165
181
  const s = readJSON<SessionRecord>(file('sessions', id))
166
182
  if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
167
- if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
183
+ if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio', 'hook'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
168
184
  return s
169
185
  }
170
186
 
@@ -193,11 +209,23 @@ export function requestResult(id: string, cwd: string): RequestRecord {
193
209
  const target = file('requests', id)
194
210
  const request = readJSON<RequestRecord>(target)
195
211
  if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
196
- if (!['submitted', 'unknown'].includes(request.state)) return request
212
+ if (!['queued', 'submitted', 'unknown'].includes(request.state)) return request
197
213
  let s = sessionFor(request.to, cwd)
214
+ // 앱 세션의 요청 상태는 받는 쪽 훅이 직접 적는다. 여기서 추측하지 않는다.
215
+ if (s.transport === 'hook') return request
216
+ if (request.state === 'queued' && s.transport === 'console') return request
198
217
  if (['pty', 'stdio'].includes(s.transport || '')) {
199
218
  try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
200
- catch (e) { request.state = 'unknown'; request.error = errorMessage(e) }
219
+ catch (e) {
220
+ const saved = path.join(bridgeHome(), 'jobs', `${id}.json`)
221
+ // A normal host exit persists outcomes before closing the pipe. Never replay
222
+ // a queued job after a crash or mistake an unexecuted job for completion.
223
+ if (s.transport === 'stdio' && fs.existsSync(saved)) {
224
+ const job = readJSON<Pick<RequestRecord, 'id' | 'to' | 'state'>>(saved)
225
+ if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state)) Object.assign(request, job)
226
+ else { request.state = 'unknown'; request.error = errorMessage(e) }
227
+ } else { request.state = 'unknown'; request.error = errorMessage(e) }
228
+ }
201
229
  writeJSON(target, request)
202
230
  return request
203
231
  }
@@ -237,7 +265,7 @@ export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000)
237
265
  const deadline = performance.now() + timeoutMs
238
266
  for (;;) {
239
267
  const result = requestResult(id, cwd)
240
- if (result.state !== 'submitted' || performance.now() >= deadline) return result
268
+ if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline) return result
241
269
  await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
242
270
  }
243
271
  }
@@ -247,7 +275,7 @@ export function sessionLeasePath(id: string): string {
247
275
  return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
248
276
  }
249
277
 
250
- export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
278
+ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', fromSession, dialogueId = null, queueIfBusy = false }: DispatchOptions): RequestRecord {
251
279
  // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
252
280
  // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
253
281
  let sending = false
@@ -258,13 +286,37 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
258
286
  if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
259
287
  const dir = path.join(bridgeHome(), 'requests')
260
288
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
289
+ if (fromSession !== undefined) {
290
+ if (fromSession === to) throw new Error('자기 자신에게는 보낼 수 없습니다.')
291
+ sessionFor(fromSession, cwd)
292
+ }
293
+ if (s.transport === 'hook') {
294
+ if (dialogueId) throw new Error('앱 세션은 자동 대화 중계(ax_chat)를 지원하지 않습니다. 사용량을 측정하지 못합니다.')
295
+ return enqueueAppRequest(s, { to, from, repo: repoKey(cwd), prompt, ...(fromSession ? { fromSession } : {}) })
296
+ }
297
+ if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
298
+ validateConsolePrompt(prompt, from)
299
+ if (s.state === 'closed') throw new Error('Claude terminal is closed')
300
+ const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
301
+ const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
302
+ .filter(r => r.to === to && r.state === 'queued')
303
+ if (pending.length >= 100) throw new Error('Claude command queue is full')
304
+ const request: RequestRecord = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' }
305
+ writeJSON(file('requests', request.id), request)
306
+ return request
307
+ })
308
+ const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' })
309
+ worker.on('error', () => {}); worker.unref()
310
+ return request
311
+ }
261
312
  return withRepoLock(path.join(dir, `${to}.lock`), () => {
262
313
  if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
263
314
  for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
264
315
  const previous = readJSON<RequestRecord>(path.join(dir, n))
265
- if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
316
+ if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state)) continue
266
317
  const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
267
- if (['sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
318
+ if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state)) continue
319
+ if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
268
320
  }
269
321
  // No fallback to `agy -p` or --conversation: both would start a separate process.
270
322
  let consolePreflight
@@ -273,16 +325,16 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
273
325
  runAPI(s, ['get-conversation-metadata', to])
274
326
  if (!s.fullyIdle || s.event !== 'Stop') throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.')
275
327
  }
276
- const request: RequestRecord = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
328
+ const request: RequestRecord = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt,
277
329
  createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
278
330
  const target = file('requests', request.id)
279
331
  writeJSON(target, request)
280
332
  sending = true
281
333
  try {
282
334
  request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
283
- ? terminalRPC(to, { action: 'submit', request })
335
+ ? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
284
336
  : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
285
- request.state = 'submitted'
337
+ request.state = (request.receipt as { queued?: boolean })?.queued ? 'queued' : 'submitted'
286
338
  } catch (e) {
287
339
  if (notDelivered(e)) {
288
340
  // 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
@@ -372,3 +424,57 @@ export function installHooks({ executable, home = os.homedir() }: { executable:
372
424
  return { hooks: target, backup: original === null ? null : backup,
373
425
  note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
374
426
  }
427
+
428
+ /** One local worker per ordinary Claude terminal; no provider is launched. */
429
+ export function runConsoleQueue(to: string, cwd: string) {
430
+ const dir = path.join(bridgeHome(), 'requests')
431
+ const runnerLock = path.join(dir, `${to}.runner.lock`)
432
+ if (fs.existsSync(runnerLock)) {
433
+ const owner = readJSON<{ pid: number }>(runnerLock)
434
+ try { process.kill(owner.pid, 0) } catch (e) {
435
+ if (errorCode(e) === 'ESRCH') fs.unlinkSync(runnerLock)
436
+ }
437
+ }
438
+ try { withRepoLock(runnerLock, () => {
439
+ for (;;) {
440
+ const records: RequestRecord[] = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
441
+ .filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
442
+ .filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
443
+ const queued = records.filter(r => r.state === 'queued')
444
+ if (!queued.length) return
445
+ let waiting = false
446
+ for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
447
+ const latest = r.state === 'sending' ? r : requestResult(r.id, cwd)
448
+ if (['unknown', 'sending'].includes(latest.state)) return
449
+ if (latest.state === 'submitted') waiting = true
450
+ }
451
+ const s = sessionFor(to, cwd)
452
+ if (s.pid) {
453
+ try { process.kill(s.pid, 0) } catch (e) {
454
+ if (errorCode(e) === 'ESRCH') {
455
+ for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' })
456
+ return
457
+ }
458
+ }
459
+ }
460
+ if (s.state === 'closed') {
461
+ for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' })
462
+ return
463
+ }
464
+ let live
465
+ try { live = checkConsole(s) } catch { waiting = true }
466
+ if (waiting || !live) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); continue }
467
+ withRepoLock(path.join(dir, `${to}.lock`), () => {
468
+ const r = readJSON<RequestRecord>(file('requests', queued[0]!.id))
469
+ if (r.state !== 'queued') return
470
+ r.baseline = transcript(s).length; r.state = 'sending'; writeJSON(file('requests', r.id), r)
471
+ try { r.receipt = submitConsole(s, r, live); r.state = 'submitted' }
472
+ catch (e) { r.state = notDelivered(e) ? 'queued' : 'unknown'; r.error = errorMessage(e) }
473
+ writeJSON(file('requests', r.id), r)
474
+ })
475
+ }
476
+ }, 1000) } catch (e) {
477
+ // Another worker won the runner lease. It owns draining; never replay here.
478
+ if (!fs.existsSync(runnerLock)) throw e
479
+ }
480
+ }
@@ -25,19 +25,19 @@ export function consoleCall(pid, action = 'snapshot', payload = {}) {
25
25
  }
26
26
  catch { /* report the underlying failure */ }
27
27
  if (r.error || r.status !== 0 || data?.error || !data)
28
- throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패');
28
+ throw Object.assign(new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패'), { notDelivered: !r.error && data?.notDelivered === true });
29
29
  if (action === 'ancestor' && data.ignored)
30
30
  return { ignored: true };
31
31
  if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string'))
32
32
  throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.');
33
33
  return data;
34
34
  }
35
- function identityID({ pid, startedAt }) {
36
- const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).digest('hex');
35
+ function identityID({ pid, startedAt, provider = 'agy' }) {
36
+ const hex = createHash('sha256').update(`${provider}-console:${pid}:${startedAt}`).digest('hex');
37
37
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
38
38
  }
39
39
  const target = (id) => path.join(bridgeHome(), 'sessions', `${id}.json`);
40
- export function consoleIdle(screen) {
40
+ export function consoleIdle(screen, live) {
41
41
  // Use the last input box only. Old answers may contain quoted prompt boxes.
42
42
  const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)];
43
43
  if (borders.length < 2)
@@ -47,7 +47,15 @@ export function consoleIdle(screen) {
47
47
  return false;
48
48
  const input = screen.slice(top.index + top[0].length, bottom.index).trim();
49
49
  const footer = screen.slice(bottom.index + bottom[0].length);
50
- return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer);
50
+ const empty = /^[>❯]$/.test(input);
51
+ // Claude paints a suggestion after the cursor in an otherwise empty editor.
52
+ // Require the cursor to remain at that editor's start, never just match text.
53
+ const placeholder = live?.provider === 'claude' && live.cursorX === 2 &&
54
+ /^[>❯]\s+Try "[^"\r\n]+"$/.test(input) &&
55
+ live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length;
56
+ const claudeEditor = live?.provider === 'claude' && live.cursorX === 2 &&
57
+ live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length;
58
+ return (empty || placeholder) && (live?.provider === 'claude' ? claudeEditor : /\? for shortcuts/.test(footer)) && !/esc to (cancel|interrupt)/i.test(footer);
51
59
  }
52
60
  export function attachConsole({ pid, cwd }) {
53
61
  const live = consoleCall(pid);
@@ -56,19 +64,38 @@ export function attachConsole({ pid, cwd }) {
56
64
  const repo = repoKey(cwd);
57
65
  if (previous.repos && !previous.repos.includes(repo))
58
66
  throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.');
59
- const idle = consoleIdle(live.screen);
60
- const s = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
67
+ const idle = consoleIdle(live.screen, live);
68
+ const s = { ...previous, id, provider: live.provider || 'agy', transport: 'console', pid, startedAt: live.startedAt,
61
69
  workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
62
70
  state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' };
63
71
  writeJSON(target(id), s);
64
72
  return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces };
65
73
  }
66
- export function registerExternalHook(input, event) {
67
- if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event))
74
+ export function registerExternalHook(input, event, provider = 'agy') {
75
+ if (!['SessionStart', 'PreInvocation', 'UserPromptSubmit', 'Stop', 'SessionEnd'].includes(event))
68
76
  throw new Error('지원하지 않는 훅 이벤트입니다.');
69
77
  const identity = consoleCall(process.pid, 'ancestor');
70
78
  if (identity.ignored)
71
79
  return {};
80
+ if (identity.provider && identity.provider !== provider)
81
+ return {};
82
+ if (provider === 'claude') {
83
+ const conversationId = input.session_id;
84
+ const log = input.transcript_path;
85
+ const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
86
+ const relative = typeof log === 'string' ? path.relative(path.join(home, 'projects'), path.resolve(log)) : '..';
87
+ if (!conversationId || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId) ||
88
+ !log || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(log) !== `${conversationId}.jsonl` ||
89
+ !input.cwd || !path.isAbsolute(input.cwd))
90
+ throw new Error('Claude 훅의 대화 기록·작업 폴더 신원이 올바르지 않습니다.');
91
+ const id = identityID(identity);
92
+ const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
93
+ const state = event === 'SessionEnd' ? 'closed' : event === 'Stop' || event === 'SessionStart' ? 'settling' : 'running';
94
+ writeJSON(target(id), { ...previous, ...identity, id, provider: 'claude', transport: 'console', conversationId,
95
+ transcript: path.resolve(log), workspaces: [path.resolve(input.cwd)], repos: [repoKey(input.cwd)],
96
+ state, fullyIdle: false, event, registeredAt: new Date().toISOString() });
97
+ return { id };
98
+ }
72
99
  const id = identityID(identity);
73
100
  const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
74
101
  const workspaces = input.workspacePaths || previous.workspaces;
@@ -89,7 +116,8 @@ export function registerExternalHook(input, event) {
89
116
  export function externalStatus(session) {
90
117
  const s = readJSON(target(session.id));
91
118
  const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
92
- const state = consoleIdle(live.screen) ? 'idle' : 'unknown';
119
+ const eligible = s.provider !== 'claude' || ['SessionStart', 'Stop'].includes(s.event || '');
120
+ const state = s.state === 'closed' ? 'closed' : eligible && consoleIdle(live.screen, live) ? 'idle' : 'unknown';
93
121
  writeJSON(target(s.id), { ...readJSON(target(s.id)), state, fullyIdle: state === 'idle' });
94
122
  return { id: s.id, state };
95
123
  }
@@ -100,7 +128,9 @@ function consolePID(s) {
100
128
  }
101
129
  export function checkConsole(s) {
102
130
  const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
103
- if (!consoleIdle(live.screen))
131
+ if (s.provider === 'claude' && !['SessionStart', 'Stop'].includes(s.event || ''))
132
+ throw new Error('Claude가 실행·승인 대기 중입니다.');
133
+ if (!consoleIdle(live.screen, live))
104
134
  throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.');
105
135
  return live;
106
136
  }
@@ -111,6 +141,8 @@ export function validateConsolePrompt(prompt, from) {
111
141
  export function findConsoleTranscript(s, request) {
112
142
  if (s.transcript)
113
143
  return s;
144
+ if (s.provider === 'claude')
145
+ return s;
114
146
  const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain');
115
147
  if (!fs.existsSync(brain))
116
148
  return s;
@@ -153,7 +185,8 @@ export function submitConsole(s, request, preflight) {
153
185
  // Bracketed paste preserves multiline prompts and prevents embedded Enter keys
154
186
  // from becoming separate submissions. Only the final CR submits the message.
155
187
  const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`;
156
- consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen, text });
188
+ consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
189
+ expectedCursorX: live.cursorX, expectedCursorRow: live.cursorRow, text });
157
190
  writeJSON(target(s.id), { ...readJSON(target(s.id)), state: 'running', fullyIdle: false });
158
191
  return { accepted: true, pid: s.pid };
159
192
  }
@@ -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
  }