axmap-cli 1.8.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.
package/src/bridge.mts CHANGED
@@ -4,11 +4,13 @@ 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 { errorCode, errorMessage } from './session-types.mjs'
11
+ import { errorCode, errorMessage, RPC_NOT_DELIVERED_EXIT } from './session-types.mjs'
12
+ import { commonKey, resolveTarget } from './workspace.mjs'
13
+ import type { HostLiveness } from './session-types.mjs'
12
14
  import type { SessionRecord, PublicSession, RequestRecord, DispatchOptions, HookInput, TranscriptEntry } from './session-types.mjs'
13
15
 
14
16
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -37,13 +39,21 @@ function writeJSON(p: string, data: unknown): void {
37
39
  }
38
40
  } finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp) }
39
41
  }
40
- function repoKey(cwd: string): string {
42
+ function commonDirKey(cwd: string): string | null {
41
43
  const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
42
44
  cwd, encoding: 'utf8', windowsHide: true,
43
45
  })
44
- if (r.status !== 0) throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`)
45
- const key = fs.realpathSync(r.stdout.trim())
46
- return process.platform === 'win32' ? key.toLowerCase() : key
46
+ return r.status === 0 ? commonKey(fs.realpathSync(r.stdout.trim())) : null
47
+ }
48
+ function repoKey(cwd: string): string {
49
+ // Git 안이면 지금까지와 같은 한 번의 git 호출로 끝낸다. 실패했을 때만 작업 폴더
50
+ // 규칙(수동 설정 → 자식 저장소 자동 탐색 → 모호하면 오류)으로 대상 저장소를 정한다.
51
+ const direct = commonDirKey(cwd)
52
+ if (direct) return direct
53
+ const repo = resolveTarget(cwd)
54
+ const key = repo && commonDirKey(repo)
55
+ if (!key) throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`)
56
+ return key
47
57
  }
48
58
  function addressError(address: unknown): asserts address is string {
49
59
  // Only a loopback address exported by a local provider hook is accepted.
@@ -72,6 +82,20 @@ function runAPI(session: SessionRecord, args: string[]): unknown {
72
82
  return null
73
83
  }
74
84
  function transcript(session: SessionRecord): TranscriptEntry[] {
85
+ if (session.provider === 'claude') {
86
+ const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
87
+ const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..'
88
+ if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`) throw new Error('Claude transcript identity mismatch')
89
+ if (!fs.existsSync(session.transcript)) return []
90
+ return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
91
+ const entry = JSON.parse(line) as { type?: string; sessionId?: string; message?: { content?: string | Array<{ type: string; text?: string }> } }
92
+ if (entry.sessionId && entry.sessionId !== session.conversationId) return []
93
+ const content = entry.message?.content
94
+ const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : ''
95
+ if (!text || !['user', 'assistant'].includes(entry.type || '')) return []
96
+ return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }]
97
+ })
98
+ }
75
99
  const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
76
100
  if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
77
101
  const lines = fs.readFileSync(expected, 'utf8').split('\n')
@@ -79,24 +103,82 @@ function transcript(session: SessionRecord): TranscriptEntry[] {
79
103
  lines.pop()
80
104
  return lines.filter(s => s.trim()).map(s => JSON.parse(s) as TranscriptEntry)
81
105
  }
82
- function publicSession(s: SessionRecord): PublicSession {
106
+ function publicSession(s: SessionRecord, liveness?: HostLiveness): PublicSession {
83
107
  return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
84
108
  conversationId: s.conversationId,
85
109
  transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
86
- registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
87
- role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval }
110
+ registeredAt: s.registeredAt, event: s.event,
111
+ // 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
112
+ fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
113
+ role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
114
+ ...(liveness ? { liveness } : {}) }
115
+ }
116
+
117
+ /** 관리형 세션(pty·stdio)의 로컬 제어 파이프. 세션 호스트가 살아 있는 동안만 존재한다. */
118
+ export const hostPipe = (id: string) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
119
+
120
+ /** Windows 의 열린 파이프 이름 목록. 읽지 못하면 오류 자체를 돌려준다 —
121
+ * "목록에 없음"과 "목록을 못 읽음"을 같은 것으로 보면 산 세션을 닫힘으로 적는다. */
122
+ function openPipes(): Set<string> | Error {
123
+ try { return new Set(fs.readdirSync('\\\\.\\pipe\\').map(n => n.toLowerCase())) }
124
+ catch (e) { return e instanceof Error ? e : new Error(String(e)) }
125
+ }
126
+
127
+ /**
128
+ * 저장된 상태가 아니라 호스트가 실제로 살아 있는지. 연결 요청은 보내지 않는다.
129
+ *
130
+ * `bridge serve` 가 강제 종료되면 종료 처리(state: closed)를 쓰지 못해 기록이
131
+ * 영원히 `idle` 로 남는다 (실측: 세션 d3cde187, 파이프 없음). 파이프는 호스트가
132
+ * listen 한 뒤에만 기록을 쓰므로, 파이프가 없다는 것은 호스트가 끝났다는 뜻이다.
133
+ * Windows : 파이프 이름 목록에 없으면 dead, 목록을 못 읽으면 unverified
134
+ * 그 밖 : 소켓 파일이 없으면 dead. 있으면 기록된 호스트 pid 로 판단하고,
135
+ * pid 가 없으면(이전 버전 기록) unverified — 남은 소켓 파일만으로는 모른다.
136
+ */
137
+ function hostLiveness(s: SessionRecord, pipes: () => Set<string> | Error): HostLiveness {
138
+ if (process.platform === 'win32') {
139
+ const listed = pipes()
140
+ if (listed instanceof Error) return 'unverified'
141
+ return listed.has(`axmap-${s.id}`.toLowerCase()) ? 'alive' : 'dead'
142
+ }
143
+ try { fs.statSync(hostPipe(s.id)) } catch (e) { return errorCode(e) === 'ENOENT' ? 'dead' : 'unverified' }
144
+ if (!Number.isSafeInteger(s.hostPid) || (s.hostPid as number) <= 0) return 'unverified'
145
+ try { process.kill(s.hostPid as number, 0); return 'alive' } catch (e) {
146
+ return errorCode(e) === 'ESRCH' ? 'dead' : errorCode(e) === 'EPERM' ? 'alive' : 'unverified'
147
+ }
148
+ }
149
+
150
+ function markClosed(target: string, s: SessionRecord): SessionRecord {
151
+ // 호스트가 끝난 뒤 기록을 바꾸는 쪽은 여기뿐이지만, 막 종료 처리 중인 호스트가 더
152
+ // 최신 기록(사용량 등)을 썼을 수 있어 다시 읽고 바꾼다.
153
+ let latest = s
154
+ try { latest = readJSON<SessionRecord>(target) } catch { /* 아래에서 읽은 기록으로 표시만 한다 */ }
155
+ if (latest.state === 'closed') return latest
156
+ const closed: SessionRecord = { ...latest, state: 'closed', fullyIdle: false,
157
+ closedReason: '세션 호스트의 로컬 연결 파이프가 없습니다. 정상 종료 처리 없이 끝난 것으로 보입니다.' }
158
+ // 저장에 실패해도 이번 조회에는 닫힘으로 보고한다. 다음 조회가 다시 시도한다.
159
+ try { writeJSON(target, closed) } catch { /* 위와 같다 */ }
160
+ return closed
88
161
  }
162
+
89
163
  export function sessions(cwd: string): PublicSession[] {
90
164
  const dir = path.join(bridgeHome(), 'sessions')
91
165
  if (!fs.existsSync(dir)) return []
92
166
  const repo = repoKey(cwd)
167
+ let pipes: Set<string> | Error | undefined
168
+ const listPipes = () => (pipes ??= openPipes())
93
169
  return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
94
- .map(n => readJSON<SessionRecord>(path.join(dir, n))).filter(s => s.repos.includes(repo)).map(publicSession)
170
+ .map(n => [path.join(dir, n), readJSON<SessionRecord>(path.join(dir, n))] as const)
171
+ .filter(([, s]) => s.repos.includes(repo))
172
+ .map(([target, s]) => {
173
+ if (!['pty', 'stdio'].includes(s.transport || '') || s.state === 'closed') return publicSession(s)
174
+ const liveness = hostLiveness(s, listPipes)
175
+ return liveness === 'dead' ? publicSession(markClosed(target, s), 'dead') : publicSession(s, liveness)
176
+ })
95
177
  }
96
178
  function sessionFor(id: string, cwd: string): SessionRecord {
97
179
  const s = readJSON<SessionRecord>(file('sessions', id))
98
180
  if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
99
- if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
181
+ if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
100
182
  return s
101
183
  }
102
184
 
@@ -125,11 +207,21 @@ export function requestResult(id: string, cwd: string): RequestRecord {
125
207
  const target = file('requests', id)
126
208
  const request = readJSON<RequestRecord>(target)
127
209
  if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
128
- if (!['submitted', 'unknown'].includes(request.state)) return request
210
+ if (!['queued', 'submitted', 'unknown'].includes(request.state)) return request
129
211
  let s = sessionFor(request.to, cwd)
212
+ if (request.state === 'queued' && s.transport === 'console') return request
130
213
  if (['pty', 'stdio'].includes(s.transport || '')) {
131
214
  try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
132
- catch (e) { request.state = 'unknown'; request.error = errorMessage(e) }
215
+ catch (e) {
216
+ const saved = path.join(bridgeHome(), 'jobs', `${id}.json`)
217
+ // A normal host exit persists outcomes before closing the pipe. Never replay
218
+ // a queued job after a crash or mistake an unexecuted job for completion.
219
+ if (s.transport === 'stdio' && fs.existsSync(saved)) {
220
+ const job = readJSON<Pick<RequestRecord, 'id' | 'to' | 'state'>>(saved)
221
+ if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state)) Object.assign(request, job)
222
+ else { request.state = 'unknown'; request.error = errorMessage(e) }
223
+ } else { request.state = 'unknown'; request.error = errorMessage(e) }
224
+ }
133
225
  writeJSON(target, request)
134
226
  return request
135
227
  }
@@ -169,7 +261,7 @@ export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000)
169
261
  const deadline = performance.now() + timeoutMs
170
262
  for (;;) {
171
263
  const result = requestResult(id, cwd)
172
- if (result.state !== 'submitted' || performance.now() >= deadline) return result
264
+ if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline) return result
173
265
  await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
174
266
  }
175
267
  }
@@ -179,20 +271,40 @@ export function sessionLeasePath(id: string): string {
179
271
  return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
180
272
  }
181
273
 
182
- export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
274
+ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null, queueIfBusy = false }: DispatchOptions): RequestRecord {
275
+ // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
276
+ // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
277
+ let sending = false
278
+ try {
183
279
  if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768) throw new Error('요청 본문은 1~32768바이트여야 합니다.')
184
280
  const s = sessionFor(to, cwd)
185
281
  const lease = sessionLeasePath(to)
186
282
  if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
187
283
  const dir = path.join(bridgeHome(), 'requests')
188
284
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
285
+ if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
286
+ validateConsolePrompt(prompt, from)
287
+ if (s.state === 'closed') throw new Error('Claude terminal is closed')
288
+ const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
289
+ const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
290
+ .filter(r => r.to === to && r.state === 'queued')
291
+ if (pending.length >= 100) throw new Error('Claude command queue is full')
292
+ const request: RequestRecord = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' }
293
+ writeJSON(file('requests', request.id), request)
294
+ return request
295
+ })
296
+ const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' })
297
+ worker.on('error', () => {}); worker.unref()
298
+ return request
299
+ }
189
300
  return withRepoLock(path.join(dir, `${to}.lock`), () => {
190
301
  if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
191
302
  for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
192
303
  const previous = readJSON<RequestRecord>(path.join(dir, n))
193
- if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
304
+ if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state)) continue
194
305
  const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
195
- if (['sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
306
+ if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state)) continue
307
+ if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
196
308
  }
197
309
  // No fallback to `agy -p` or --conversation: both would start a separate process.
198
310
  let consolePreflight
@@ -205,25 +317,45 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
205
317
  createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
206
318
  const target = file('requests', request.id)
207
319
  writeJSON(target, request)
320
+ sending = true
208
321
  try {
209
322
  request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
210
- ? terminalRPC(to, { action: 'submit', request })
323
+ ? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
211
324
  : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
212
- request.state = 'submitted'
325
+ request.state = (request.receipt as { queued?: boolean })?.queued ? 'queued' : 'submitted'
213
326
  } catch (e) {
214
- // A timeout may occur after delivery. Never retry automatically.
215
- request.state = 'unknown'
216
- request.error = errorMessage(e)
327
+ if (notDelivered(e)) {
328
+ // 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
329
+ // 않았으므로 확실히 전달되지 않았다. unknown 으로 두면 이 세션의 다음 요청이
330
+ // 전부 막힌다. 자동 재전송은 여전히 하지 않는다.
331
+ request.state = 'failed'
332
+ request.error = `전달되지 않았습니다: ${errorMessage(e)}`
333
+ } else {
334
+ // A timeout may occur after delivery. Never retry automatically.
335
+ request.state = 'unknown'
336
+ request.error = errorMessage(e)
337
+ }
217
338
  }
218
339
  writeJSON(target, request)
219
340
  return request
220
341
  })
342
+ } catch (e) {
343
+ if (!sending && e instanceof Error) Object.assign(e, { notDelivered: true })
344
+ throw e
345
+ }
221
346
  }
222
347
 
348
+ /** 연결 전에 실패해 아무것도 보내지 않은 오류만 true. 문구가 아니라 종료 코드나 보내기 전 단계로 가른다. */
349
+ export const notDelivered =(e: unknown) => e instanceof Error && (e as Error & { notDelivered?: boolean }).notDelivered === true
350
+
223
351
  function terminalRPC(id: string, message: unknown): unknown {
224
352
  const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
225
353
  input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
226
354
  })
355
+ // 시간 초과(r.error)나 그 밖의 실패는 연결 뒤에 났을 수 있어 전달 여부를 모른다.
356
+ if (!r.error && r.status === RPC_NOT_DELIVERED_EXIT) {
357
+ throw Object.assign(new Error(r.stderr?.trim() || '로컬 세션 파이프에 연결하지 못했습니다.'), { notDelivered: true })
358
+ }
227
359
  if (r.error || r.status !== 0) throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.')
228
360
  return JSON.parse(r.stdout)
229
361
  }
@@ -280,3 +412,57 @@ export function installHooks({ executable, home = os.homedir() }: { executable:
280
412
  return { hooks: target, backup: original === null ? null : backup,
281
413
  note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
282
414
  }
415
+
416
+ /** One local worker per ordinary Claude terminal; no provider is launched. */
417
+ export function runConsoleQueue(to: string, cwd: string) {
418
+ const dir = path.join(bridgeHome(), 'requests')
419
+ const runnerLock = path.join(dir, `${to}.runner.lock`)
420
+ if (fs.existsSync(runnerLock)) {
421
+ const owner = readJSON<{ pid: number }>(runnerLock)
422
+ try { process.kill(owner.pid, 0) } catch (e) {
423
+ if (errorCode(e) === 'ESRCH') fs.unlinkSync(runnerLock)
424
+ }
425
+ }
426
+ try { withRepoLock(runnerLock, () => {
427
+ for (;;) {
428
+ const records: RequestRecord[] = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
429
+ .filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
430
+ .filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
431
+ const queued = records.filter(r => r.state === 'queued')
432
+ if (!queued.length) return
433
+ let waiting = false
434
+ for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
435
+ const latest = r.state === 'sending' ? r : requestResult(r.id, cwd)
436
+ if (['unknown', 'sending'].includes(latest.state)) return
437
+ if (latest.state === 'submitted') waiting = true
438
+ }
439
+ const s = sessionFor(to, cwd)
440
+ if (s.pid) {
441
+ try { process.kill(s.pid, 0) } catch (e) {
442
+ if (errorCode(e) === 'ESRCH') {
443
+ for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' })
444
+ return
445
+ }
446
+ }
447
+ }
448
+ if (s.state === 'closed') {
449
+ for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' })
450
+ return
451
+ }
452
+ let live
453
+ try { live = checkConsole(s) } catch { waiting = true }
454
+ if (waiting || !live) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); continue }
455
+ withRepoLock(path.join(dir, `${to}.lock`), () => {
456
+ const r = readJSON<RequestRecord>(file('requests', queued[0]!.id))
457
+ if (r.state !== 'queued') return
458
+ r.baseline = transcript(s).length; r.state = 'sending'; writeJSON(file('requests', r.id), r)
459
+ try { r.receipt = submitConsole(s, r, live); r.state = 'submitted' }
460
+ catch (e) { r.state = notDelivered(e) ? 'queued' : 'unknown'; r.error = errorMessage(e) }
461
+ writeJSON(file('requests', r.id), r)
462
+ })
463
+ }
464
+ }, 1000) } catch (e) {
465
+ // Another worker won the runner lease. It owns draining; never replay here.
466
+ if (!fs.existsSync(runnerLock)) throw e
467
+ }
468
+ }
@@ -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
  }