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.
package/README.ko.md CHANGED
@@ -31,6 +31,22 @@ axmap setup
31
31
 
32
32
  최신 변경부터 정리합니다.
33
33
 
34
+ ### 1.10.0
35
+
36
+ <sub>2026.11.03</sub>
37
+
38
+ **일반 Windows Claude Code 터미널을 연결하고, 상대가 준비될 때까지 실행 요청을 큐에 보관합니다.**
39
+
40
+ - `axmap setup` 후 Git 저장소에서 평소처럼 `claude`를 실행하면 원래 프로세스와 대화가 자동 등록됩니다. `ax_dispatch` / `ax_result`로 새 모델 프로세스 없이 요청·응답을 주고받습니다.
41
+ - 관리형 Claude Code·Codex·agy와 일반 Windows Claude 콘솔에서 실행 요청을 FIFO로 접수합니다. 입력 초안과 승인 화면을 유지하며, 큐 확인과 대기 중에는 모델을 호출하지 않습니다. AI 답변 생성에는 기존처럼 토큰이 사용됩니다.
42
+ - `bridge serve claude`의 지속 연결을 지원합니다. 자동 대화에서 코드 블록에 감싼 JSON 응답, 실행 시간 제한과 `--done-by` 종료 주체 지정을 제공합니다.
43
+ - 확정 미전달과 전달 불명을 구별하고 불명 요청은 자동 재전송하지 않습니다. 대기 조회가 큐에서 전송 중으로 넘어가는 순간에도 완료까지 기다립니다.
44
+
45
+ Windows의 실제 일반 Claude Code 터미널과 Codex 요청·응답으로 검증했습니다. 일반 콘솔 연결은 Windows 범위이며, 토큰 예산을 검사하는 `ax_chat`은 관리형 stdio 세션을 사용합니다. 앱의 도구 승인 절차는 유지합니다.
46
+ [연결 안내 →](docs/SESSION-BRIDGE.md)
47
+
48
+ ---
49
+
34
50
  ### 1.9.0
35
51
 
36
52
  <sub>2026.10.29</sub>
package/README.md CHANGED
@@ -31,6 +31,22 @@ View pipeline state inside compatible MCP Apps hosts, or add it to the existing
31
31
 
32
32
  Newest changes first.
33
33
 
34
+ ### 1.10.0
35
+
36
+ <sub>November 3, 2026</sub>
37
+
38
+ **Connect ordinary Windows Claude Code terminals and queue execution requests until the recipient is ready.**
39
+
40
+ - After `axmap setup`, launch `claude` normally in a Git repository. Hooks register the original process and conversation; `ax_dispatch` / `ax_result` deliver requests and collect replies without starting another model process.
41
+ - Managed Claude Code, Codex and agy sessions, plus ordinary Windows Claude consoles, accept FIFO execution queues. Input drafts and approval screens are preserved. Waiting and queue handling make no model calls; generating a response still uses provider tokens.
42
+ - `bridge serve claude` supports persistent stream-json conversations. Automatic dialogue accepts fenced JSON responses and provides execution timeouts and `--done-by` termination control.
43
+ - Requests that were not delivered remain distinct from uncertain delivery. Unknown requests are never automatically replayed. Wait operations now follow the queued-to-sending handoff through completion.
44
+
45
+ Validated with real Claude Code terminals and Codex request/reply exchanges on Windows. Ordinary console attachment is Windows-only; token-budgeted `ax_chat` continues to require managed stdio sessions. Tool approvals remain under the host's control.
46
+ [Connection guide →](docs/SESSION-BRIDGE.md)
47
+
48
+ ---
49
+
34
50
  ### 1.9.0
35
51
 
36
52
  <sub>October 29, 2026</sub>
package/mcp/server.mjs CHANGED
@@ -508,7 +508,7 @@ const TOOLS = [
508
508
  },
509
509
  {
510
510
  name: 'ax_dispatch',
511
- description: '명시적으로 상대의 실행 턴이 필요한 작업용 저수준 전달이다. 일반 진행·완료·계약 확인 알림에는 새 턴 없는 ax_message를 사용하고 수신자가 작업 경계에서 ax_mailbox로 묶음 회수한다. 기존 맥락을 유지하며 새 모델 프로세스를 만들지 않는다. submitted는 완료가 아니다. unknown이면 자동 재시도하지 말고 ax_result로 확인한다. 작업 중인 세션을 강제 중단하지 않는다.',
511
+ description: '명시적으로 상대의 실행 턴이 필요한 작업용 전달이다. 관리형 Claude Code·Codex·agy와 Windows 일반 Claude Code(console)는 대기 중 실행하고 작업·입력·승인 대기 중이면 FIFO 큐에 접수해 idle에서 자동 실행한다. 기존 맥락과 프로세스를 유지한다. queued/submitted는 완료가 아니며 ax_result로 확인한다. unknown은 재전송하지 않는다. 일반 진행 알림에는 새 턴 없는 ax_message를 사용한다. 작업 중인 세션을 강제 중단하지 않는다.',
512
512
  inputSchema: { type: 'object', properties: {
513
513
  to: { type: 'string', description: 'ax_sessions에서 확인한 상대 세션 UUID' },
514
514
  prompt: { type: 'string', description: '상대가 실행할 작업 요청' },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
3
3
  "name": "axmap-cli",
4
- "version": "1.9.0",
4
+ "version": "1.10.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
package/src/bridge.mjs CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { randomUUID } from 'node:crypto';
8
- import { spawnSync } from 'node:child_process';
8
+ import { spawn, spawnSync } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { withRepoLock } from './repolock.mjs';
11
11
  import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs';
@@ -100,6 +100,24 @@ function runAPI(session, args) {
100
100
  return null;
101
101
  }
102
102
  function transcript(session) {
103
+ if (session.provider === 'claude') {
104
+ const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
105
+ const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..';
106
+ if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`)
107
+ throw new Error('Claude transcript identity mismatch');
108
+ if (!fs.existsSync(session.transcript))
109
+ return [];
110
+ return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
111
+ const entry = JSON.parse(line);
112
+ if (entry.sessionId && entry.sessionId !== session.conversationId)
113
+ return [];
114
+ const content = entry.message?.content;
115
+ const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : '';
116
+ if (!text || !['user', 'assistant'].includes(entry.type || ''))
117
+ return [];
118
+ return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }];
119
+ });
120
+ }
103
121
  const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl');
104
122
  if (session.transcript !== expected)
105
123
  throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.');
@@ -203,7 +221,7 @@ function sessionFor(id, cwd) {
203
221
  const s = readJSON(file('sessions', id));
204
222
  if (!s.repos.includes(repoKey(cwd)))
205
223
  throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.');
206
- if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || ''))
224
+ if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio'].includes(s.transport || ''))
207
225
  throw new Error('아직 지원하지 않는 수신 세션입니다.');
208
226
  return s;
209
227
  }
@@ -235,16 +253,32 @@ export function requestResult(id, cwd) {
235
253
  const request = readJSON(target);
236
254
  if (request.repo !== repoKey(cwd))
237
255
  throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.');
238
- if (!['submitted', 'unknown'].includes(request.state))
256
+ if (!['queued', 'submitted', 'unknown'].includes(request.state))
239
257
  return request;
240
258
  let s = sessionFor(request.to, cwd);
259
+ if (request.state === 'queued' && s.transport === 'console')
260
+ return request;
241
261
  if (['pty', 'stdio'].includes(s.transport || '')) {
242
262
  try {
243
263
  Object.assign(request, terminalRPC(s.id, { action: 'result', id }));
244
264
  }
245
265
  catch (e) {
246
- request.state = 'unknown';
247
- request.error = errorMessage(e);
266
+ const saved = path.join(bridgeHome(), 'jobs', `${id}.json`);
267
+ // A normal host exit persists outcomes before closing the pipe. Never replay
268
+ // a queued job after a crash or mistake an unexecuted job for completion.
269
+ if (s.transport === 'stdio' && fs.existsSync(saved)) {
270
+ const job = readJSON(saved);
271
+ if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state))
272
+ Object.assign(request, job);
273
+ else {
274
+ request.state = 'unknown';
275
+ request.error = errorMessage(e);
276
+ }
277
+ }
278
+ else {
279
+ request.state = 'unknown';
280
+ request.error = errorMessage(e);
281
+ }
248
282
  }
249
283
  writeJSON(target, request);
250
284
  return request;
@@ -292,7 +326,7 @@ export async function waitForRequest(id, cwd, timeoutMs = 30000) {
292
326
  const deadline = performance.now() + timeoutMs;
293
327
  for (;;) {
294
328
  const result = requestResult(id, cwd);
295
- if (result.state !== 'submitted' || performance.now() >= deadline)
329
+ if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline)
296
330
  return result;
297
331
  await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))));
298
332
  }
@@ -302,7 +336,7 @@ export function sessionLeasePath(id) {
302
336
  throw new Error('세션 ID는 UUID여야 합니다.');
303
337
  return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`);
304
338
  }
305
- export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }) {
339
+ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null, queueIfBusy = false }) {
306
340
  // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
307
341
  // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
308
342
  let sending = false;
@@ -315,15 +349,35 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
315
349
  throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.');
316
350
  const dir = path.join(bridgeHome(), 'requests');
317
351
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
352
+ if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
353
+ validateConsolePrompt(prompt, from);
354
+ if (s.state === 'closed')
355
+ throw new Error('Claude terminal is closed');
356
+ const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
357
+ const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON(path.join(dir, n)))
358
+ .filter(r => r.to === to && r.state === 'queued');
359
+ if (pending.length >= 100)
360
+ throw new Error('Claude command queue is full');
361
+ const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' };
362
+ writeJSON(file('requests', request.id), request);
363
+ return request;
364
+ });
365
+ const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' });
366
+ worker.on('error', () => { });
367
+ worker.unref();
368
+ return request;
369
+ }
318
370
  return withRepoLock(path.join(dir, `${to}.lock`), () => {
319
371
  if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
320
372
  throw new Error('이 세션은 자동 대화가 사용 중입니다.');
321
373
  for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
322
374
  const previous = readJSON(path.join(dir, n));
323
- if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state))
375
+ if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state))
324
376
  continue;
325
377
  const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd);
326
- if (['sending', 'submitted', 'unknown'].includes(latest.state))
378
+ if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state))
379
+ continue;
380
+ if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state))
327
381
  throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`);
328
382
  }
329
383
  // No fallback to `agy -p` or --conversation: both would start a separate process.
@@ -344,9 +398,9 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
344
398
  sending = true;
345
399
  try {
346
400
  request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
347
- ? terminalRPC(to, { action: 'submit', request })
401
+ ? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
348
402
  : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`]);
349
- request.state = 'submitted';
403
+ request.state = request.receipt?.queued ? 'queued' : 'submitted';
350
404
  }
351
405
  catch (e) {
352
406
  if (notDelivered(e)) {
@@ -445,3 +499,89 @@ export function installHooks({ executable, home = os.homedir() }) {
445
499
  return { hooks: target, backup: original === null ? null : backup,
446
500
  note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' };
447
501
  }
502
+ /** One local worker per ordinary Claude terminal; no provider is launched. */
503
+ export function runConsoleQueue(to, cwd) {
504
+ const dir = path.join(bridgeHome(), 'requests');
505
+ const runnerLock = path.join(dir, `${to}.runner.lock`);
506
+ if (fs.existsSync(runnerLock)) {
507
+ const owner = readJSON(runnerLock);
508
+ try {
509
+ process.kill(owner.pid, 0);
510
+ }
511
+ catch (e) {
512
+ if (errorCode(e) === 'ESRCH')
513
+ fs.unlinkSync(runnerLock);
514
+ }
515
+ }
516
+ try {
517
+ withRepoLock(runnerLock, () => {
518
+ for (;;) {
519
+ const records = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
520
+ .filter(n => n.endsWith('.json')).map(n => readJSON(path.join(dir, n)))
521
+ .filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
522
+ const queued = records.filter(r => r.state === 'queued');
523
+ if (!queued.length)
524
+ return;
525
+ let waiting = false;
526
+ for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
527
+ const latest = r.state === 'sending' ? r : requestResult(r.id, cwd);
528
+ if (['unknown', 'sending'].includes(latest.state))
529
+ return;
530
+ if (latest.state === 'submitted')
531
+ waiting = true;
532
+ }
533
+ const s = sessionFor(to, cwd);
534
+ if (s.pid) {
535
+ try {
536
+ process.kill(s.pid, 0);
537
+ }
538
+ catch (e) {
539
+ if (errorCode(e) === 'ESRCH') {
540
+ for (const r of queued)
541
+ writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' });
542
+ return;
543
+ }
544
+ }
545
+ }
546
+ if (s.state === 'closed') {
547
+ for (const r of queued)
548
+ writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' });
549
+ return;
550
+ }
551
+ let live;
552
+ try {
553
+ live = checkConsole(s);
554
+ }
555
+ catch {
556
+ waiting = true;
557
+ }
558
+ if (waiting || !live) {
559
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
560
+ continue;
561
+ }
562
+ withRepoLock(path.join(dir, `${to}.lock`), () => {
563
+ const r = readJSON(file('requests', queued[0].id));
564
+ if (r.state !== 'queued')
565
+ return;
566
+ r.baseline = transcript(s).length;
567
+ r.state = 'sending';
568
+ writeJSON(file('requests', r.id), r);
569
+ try {
570
+ r.receipt = submitConsole(s, r, live);
571
+ r.state = 'submitted';
572
+ }
573
+ catch (e) {
574
+ r.state = notDelivered(e) ? 'queued' : 'unknown';
575
+ r.error = errorMessage(e);
576
+ }
577
+ writeJSON(file('requests', r.id), r);
578
+ });
579
+ }
580
+ }, 1000);
581
+ }
582
+ catch (e) {
583
+ // Another worker won the runner lease. It owns draining; never replay here.
584
+ if (!fs.existsSync(runnerLock))
585
+ throw e;
586
+ }
587
+ }
package/src/bridge.mts CHANGED
@@ -4,7 +4,7 @@ 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'
@@ -82,6 +82,20 @@ function runAPI(session: SessionRecord, args: string[]): unknown {
82
82
  return null
83
83
  }
84
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
+ }
85
99
  const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
86
100
  if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
87
101
  const lines = fs.readFileSync(expected, 'utf8').split('\n')
@@ -164,7 +178,7 @@ export function sessions(cwd: string): PublicSession[] {
164
178
  function sessionFor(id: string, cwd: string): SessionRecord {
165
179
  const s = readJSON<SessionRecord>(file('sessions', id))
166
180
  if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
167
- 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('아직 지원하지 않는 수신 세션입니다.')
168
182
  return s
169
183
  }
170
184
 
@@ -193,11 +207,21 @@ export function requestResult(id: string, cwd: string): RequestRecord {
193
207
  const target = file('requests', id)
194
208
  const request = readJSON<RequestRecord>(target)
195
209
  if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
196
- if (!['submitted', 'unknown'].includes(request.state)) return request
210
+ if (!['queued', 'submitted', 'unknown'].includes(request.state)) return request
197
211
  let s = sessionFor(request.to, cwd)
212
+ if (request.state === 'queued' && s.transport === 'console') return request
198
213
  if (['pty', 'stdio'].includes(s.transport || '')) {
199
214
  try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
200
- 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
+ }
201
225
  writeJSON(target, request)
202
226
  return request
203
227
  }
@@ -237,7 +261,7 @@ export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000)
237
261
  const deadline = performance.now() + timeoutMs
238
262
  for (;;) {
239
263
  const result = requestResult(id, cwd)
240
- if (result.state !== 'submitted' || performance.now() >= deadline) return result
264
+ if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline) return result
241
265
  await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
242
266
  }
243
267
  }
@@ -247,7 +271,7 @@ export function sessionLeasePath(id: string): string {
247
271
  return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
248
272
  }
249
273
 
250
- 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 {
251
275
  // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
252
276
  // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
253
277
  let sending = false
@@ -258,13 +282,29 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
258
282
  if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
259
283
  const dir = path.join(bridgeHome(), 'requests')
260
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
+ }
261
300
  return withRepoLock(path.join(dir, `${to}.lock`), () => {
262
301
  if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
263
302
  for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
264
303
  const previous = readJSON<RequestRecord>(path.join(dir, n))
265
- if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
304
+ if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state)) continue
266
305
  const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
267
- 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}`)
268
308
  }
269
309
  // No fallback to `agy -p` or --conversation: both would start a separate process.
270
310
  let consolePreflight
@@ -280,9 +320,9 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
280
320
  sending = true
281
321
  try {
282
322
  request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
283
- ? terminalRPC(to, { action: 'submit', request })
323
+ ? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
284
324
  : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
285
- request.state = 'submitted'
325
+ request.state = (request.receipt as { queued?: boolean })?.queued ? 'queued' : 'submitted'
286
326
  } catch (e) {
287
327
  if (notDelivered(e)) {
288
328
  // 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
@@ -372,3 +412,57 @@ export function installHooks({ executable, home = os.homedir() }: { executable:
372
412
  return { hooks: target, backup: original === null ? null : backup,
373
413
  note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
374
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
  }