axmap-cli 1.10.0 → 1.11.1

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,31 @@ axmap setup
31
31
 
32
32
  최신 변경부터 정리합니다.
33
33
 
34
+ ### 1.11.1
35
+
36
+ <sub>2026.11.05</sub>
37
+
38
+ **npm에서 CLI 분류로 찾을 수 있게 패키지 키워드에 `cli`를 더했습니다.** 기능 변경은 없습니다.
39
+
40
+ ---
41
+
42
+ ### 1.11.0
43
+
44
+ <sub>2026.11.05</sub>
45
+
46
+ **Claude 데스크톱 앱과 Codex 앱이 대화 모드로 서로 일을 주고받습니다.**
47
+
48
+ - 콘솔 창이 없는 앱 세션(Claude 데스크톱 앱의 Code 탭, Codex 앱, axMap 래퍼 없이 연 `codex`)을 공급자 자신의 훅이 대화 ID로 등록합니다.
49
+ - 각 앱에 대화 모드를 켜 달라고 하고(`ax_dialogue_mode`), `ax_dispatch(to, from_session)`로 보냅니다. 받는 쪽의 응답이 끝나면 응답 끝(Stop) 훅이 그 요청으로 같은 대화를 이어 가고, 마지막 답이 결과가 되어 보낸 쪽의 다음 응답 끝에 돌아갑니다. 기다리는 동안 모델을 부르지 않습니다.
50
+ - 대화 모드는 직접 켤 때만 동작하며 시간(기본 30분), 이어 가기 횟수(기본 8회), 한 번의 기다림(최대 540초)으로 제한됩니다. 사람이 입력하면 진행 중이던 요청은 밀려나고, 모드를 끄면 아직 전달하지 않은 요청은 실패로 기록됩니다.
51
+ - 쉬고 있는 앱을 밖에서 깨우지 않습니다. 대화 모드를 켠 뒤 상대 앱에 한마디 걸어 기다림을 시작시키세요.
52
+ - `axmap setup`을 다시 실행해야 Stop 훅 제한 시간 600초가 적용됩니다. Codex가 바뀐 훅의 신뢰 확인을 요구할 수 있으며 axMap은 이를 우회하지 않습니다.
53
+
54
+ Windows에서 실제 Claude 데스크톱 앱과 Codex 앱 사이의 왕복과, 사람 입력 없는 2회차 왕복으로 검증했습니다. 앱 세션의 토큰 사용량은 측정하지 않으므로 `ax_chat`의 예산 한도는 적용되지 않습니다.
55
+ [연결 안내 →](docs/SESSION-BRIDGE.md)
56
+
57
+ ---
58
+
34
59
  ### 1.10.0
35
60
 
36
61
  <sub>2026.11.03</sub>
package/README.md CHANGED
@@ -31,6 +31,31 @@ 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.11.1
35
+
36
+ <sub>November 5, 2026</sub>
37
+
38
+ **Adds the `cli` package keyword so axMap appears under npm's CLI category.** No functional changes.
39
+
40
+ ---
41
+
42
+ ### 1.11.0
43
+
44
+ <sub>November 5, 2026</sub>
45
+
46
+ **Claude and Codex desktop apps can now hand work back and forth with dialogue mode.**
47
+
48
+ - App sessions without a console window (the Claude desktop app's Code tab, the Codex app, or `codex` without the axMap wrapper) are registered by the provider's own hooks under their conversation ID.
49
+ - Ask each app to turn on dialogue mode (`ax_dialogue_mode`), then send with `ax_dispatch(to, from_session)`. When the recipient finishes its turn, its Stop hook continues the same conversation with the request; the final answer becomes the result and returns to the sender's next Stop. Waiting makes no model calls.
50
+ - Dialogue mode is opt-in and bounded by time (default 30 minutes), turns (default 8) and a wait of at most 540 seconds per turn. A human prompt supersedes an in-flight request; turning the mode off fails undelivered requests.
51
+ - An idle app is never woken from outside. Say one thing to the other app after turning dialogue mode on so its wait begins.
52
+ - Run `axmap setup` again to install the 600-second Stop hook timeout. Codex may ask you to trust the changed hook; axMap does not bypass that.
53
+
54
+ Validated on Windows between the real Claude desktop app and Codex app, including a second exchange with no human input. Token usage is not measured for app sessions, so `ax_chat` budgets do not apply to them.
55
+ [Connection guide →](docs/SESSION-BRIDGE.md)
56
+
57
+ ---
58
+
34
59
  ### 1.10.0
35
60
 
36
61
  <sub>November 3, 2026</sub>
package/mcp/server.mjs CHANGED
@@ -227,7 +227,7 @@ const TTL = process.env.AXMAP_TTL ?? '45m'
227
227
  */
228
228
  const SESSION = process.env.AXMAP_SESSION || process.env.CLAUDE_CODE_SESSION_ID || `mcp-${process.pid}`
229
229
 
230
- const BRIDGE_TOOLS = new Set(['ax_sessions', 'ax_dispatch', 'ax_result', 'ax_chat', 'ax_dialogue', 'ax_chat_stop', 'ax_message', 'ax_mailbox', 'ax_mailbox_flush'])
230
+ const BRIDGE_TOOLS = new Set(['ax_sessions', 'ax_dialogue_mode', 'ax_dispatch', 'ax_result', 'ax_chat', 'ax_dialogue', 'ax_chat_stop', 'ax_message', 'ax_mailbox', 'ax_mailbox_flush'])
231
231
  const PIPELINE_TOOLS = new Set(['ax_pipeline', 'ax_pipeline_state', 'ax_pipeline_watch'])
232
232
  const PIPELINE_URI = 'ui://axmap/pipeline/v1.html'
233
233
  const PIPELINE_MIME = 'text/html;profile=mcp-app'
@@ -513,8 +513,19 @@ const TOOLS = [
513
513
  to: { type: 'string', description: 'ax_sessions에서 확인한 상대 세션 UUID' },
514
514
  prompt: { type: 'string', description: '상대가 실행할 작업 요청' },
515
515
  wait_ms: { type: 'integer', minimum: 0, maximum: 30000, description: '전달 후 회신을 로컬에서 기다릴 최대 시간(ms). 짧은 요청은 30000으로 한 번에 회수한다. 기본 0.' },
516
+ from_session: { type: 'string', description: '내 axMap 세션 UUID(대화 시작 때 안내된 값). 상대가 앱 세션이면 그 답이 내 대화 모드로 돌아온다.' },
516
517
  }, required: ['to', 'prompt'] },
517
518
  },
519
+ {
520
+ name: 'ax_dialogue_mode',
521
+ description: '앱 세션(Claude 앱·Codex 앱·codex, transport=hook)의 대화 모드를 켜거나 끈다. 사용자가 이 세션에서 다른 AI 세션과 사람 없이 주고받으며 작업을 이어 가라고 할 때만 켠다. 켜진 세션은 응답이 끝날 때마다 최대 540초 상대의 요청·회신을 모델 호출 없이 기다렸다가 같은 대화에서 이어 가며, 그동안 앱은 작업 중으로 보인다. 쉬고 있는 앱을 밖에서 깨우지는 못한다. 기본 30분·자동 이어 가기 8회.',
522
+ inputSchema: { type: 'object', properties: {
523
+ session: { type: 'string', description: '대화 모드를 바꿀 앱 세션 UUID. 보통 이 대화에 안내된 내 세션 ID' },
524
+ on: { type: 'boolean' },
525
+ minutes: { type: 'integer', minimum: 1, maximum: 240, description: '켜 둘 시간. 기본 30' },
526
+ max_turns: { type: 'integer', minimum: 1, maximum: 50, description: '자동으로 이어 갈 최대 횟수. 기본 8 (Claude 기본 연속 이어 가기 한도와 같다)' },
527
+ }, required: ['session', 'on'] },
528
+ },
518
529
  {
519
530
  name: 'ax_result',
520
531
  description: '작업 요청의 제출/완료/불명 상태, 결과와 측정 사용량을 확인한다. 관리형 세션은 구조화된 완료 이벤트, PTY는 종료 훅, 독립 Windows console은 요청 UUID가 있는 대화 기록과 현재 빈 입력창의 대기 상태로 판정한다.',
@@ -970,7 +981,10 @@ function dispatch(name, args = {}) {
970
981
  ...[['reason', args.reason], ['topic', args.topic], ['key', args.key], ['ttl-seconds', args.ttl_seconds]].flatMap(([k, v]) => v === undefined ? [] : [`--${k}`, String(v)])], args.text)
971
982
  case 'ax_mailbox': return bridge(['mailbox', '--to', args.to, ...(args.take ? ['--take'] : []), ...(args.all ? ['--all'] : []), ...(args.at ? ['--at', args.at] : [])])
972
983
  case 'ax_mailbox_flush': return bridge(['flush', '--to', args.to, '--at', args.at])
973
- case 'ax_dispatch': return bridge(['send', '--to', args.to, '--wait-ms', String(args.wait_ms ?? 0)], args.prompt)
984
+ case 'ax_dispatch': return bridge(['send', '--to', args.to, '--wait-ms', String(args.wait_ms ?? 0),
985
+ ...(args.from_session ? ['--from-session', String(args.from_session)] : [])], args.prompt)
986
+ case 'ax_dialogue_mode': return bridge(['mode', '--session', String(args.session), args.on ? '--on' : '--off',
987
+ ...[['minutes', args.minutes], ['max-turns', args.max_turns]].flatMap(([k, v]) => v === undefined ? [] : [`--${k}`, String(v)])])
974
988
  case 'ax_result': return bridge(['result', args.id, '--wait-ms', String(args.wait_ms ?? 0)])
975
989
  case 'ax_chat': return bridge(['chat', '--background', '--left', args.left, '--right', args.right,
976
990
  ...['maxTurns', 'maxTokens', 'maxMessageChars'].flatMap(key => args[key] === undefined ? [] :
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.10.0",
4
+ "version": "1.11.1",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -20,6 +20,7 @@
20
20
  "lock",
21
21
  "git",
22
22
  "mcp",
23
+ "cli",
23
24
  "collaboration",
24
25
  "preemption"
25
26
  ],
@@ -0,0 +1,324 @@
1
+ // Generated from src/app-session.mts. Edit the .mts source and run npm run build.
2
+ /** 앱 세션(Claude 데스크톱 앱의 Code 탭, Codex 앱, axMap 래퍼 없는 codex TUI)의 대화 모드.
3
+ *
4
+ * 이 세션들에는 axMap 이 글자를 대신 입력할 콘솔 창이 없다. 그래서 공급자 자신의 훅만 쓴다.
5
+ * - 훅이 올 때마다 대화 ID 로 세션을 등록한다(transport: 'hook').
6
+ * - 대화 모드가 켜진 세션은 응답이 끝날 때(Stop) 상대의 요청이나 회신을 로컬에서 기다린다.
7
+ * 오면 공식 계약 {"decision":"block","reason":...} 으로 같은 대화를 이어 가게 한다.
8
+ * - 쉬고 있는 앱을 밖에서 깨울 방법은 없다. 기다림이 끝난 세션은 사용자가 다음에 말을 걸 때까지 쉰다.
9
+ * 기다리는 동안 모델을 부르지 않고, 새 모델 프로세스를 띄우지 않는다(D26). */
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { createHash, randomUUID } from 'node:crypto';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { withRepoLock } from './repolock.mjs';
16
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
17
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
18
+ /** 공급자 훅 제한 시간(600초, 두 공급자 문서의 기본값)보다 짧게 둔다. 훅이 강제 종료되면
19
+ * 이미 꺼낸 요청을 전달했는지 알 수 없게 되므로, 우리가 먼저 빈손으로 끝낸다. */
20
+ export const MAX_WAIT_SECONDS = 540;
21
+ /** Claude Code 는 사용자 입력 없이 Stop 훅으로 이어 가는 것을 기본 8회에서 끊는다
22
+ * (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP). 기본값을 그보다 크게 잡으면 끝이 우리 한도가 아니라 공급자 한도가 된다. */
23
+ const DEFAULT_MAX_TURNS = 8;
24
+ /** Claude 는 훅이 넣는 문자열을 1만 자에서 자른다. 머리말을 붙일 자리를 남긴다. */
25
+ const MAX_REPLY_CHARS = 8000;
26
+ const sessionsDir = () => path.join(bridgeHome(), 'sessions');
27
+ const requestsDir = () => path.join(bridgeHome(), 'requests');
28
+ const sessionPath = (id) => path.join(sessionsDir(), `${id}.json`);
29
+ const requestPath = (id) => path.join(requestsDir(), `${id}.json`);
30
+ /** dispatchRequest 가 같은 수신자에 쓰는 잠금과 같은 파일이다. 꺼내기와 넣기가 서로 엇갈리지 않는다. */
31
+ const queueLock = (id) => path.join(requestsDir(), `${id}.lock`);
32
+ export function appSessionId(provider, conversationId) {
33
+ const hex = createHash('sha256').update(`${provider}-hook:${conversationId}`).digest('hex');
34
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
35
+ }
36
+ function claudeHome() { return process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); }
37
+ /** 훅 입력은 공급자가 준 것이지만, 다른 대화의 기록 파일을 가리키지 않는지는 확인한다.
38
+ * null 은 "등록하지 않을 대화"다. 오류가 아니므로 조용히 넘긴다. */
39
+ function hookIdentity(input, provider) {
40
+ const conversationId = input.session_id;
41
+ if (typeof conversationId !== 'string' || !UUID.test(conversationId))
42
+ throw new Error('훅 입력에 대화 UUID(session_id)가 없습니다.');
43
+ if (typeof input.cwd !== 'string' || !path.isAbsolute(input.cwd))
44
+ throw new Error('훅 입력에 작업 폴더(cwd)가 없습니다.');
45
+ const log = input.transcript_path;
46
+ if (provider === 'claude') {
47
+ const relative = typeof log === 'string' ? path.relative(path.join(claudeHome(), 'projects'), path.resolve(log)) : '..';
48
+ if (!log || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(log) !== `${conversationId}.jsonl`) {
49
+ throw new Error('Claude 훅의 대화 기록 경로가 이 대화의 것이 아닙니다.');
50
+ }
51
+ return { conversationId, transcript: path.resolve(log), cwd: input.cwd };
52
+ }
53
+ // Codex 앱은 메시지마다 제목 생성 같은 보조 대화를 띄우고, 이 대화는 기록 파일 없이(transcript_path null)
54
+ // Stop·SessionEnd 도 없이 사라진다(2026-11-05 Codex 앱 실측). 등록하면 '작업 중' 세션으로 영영 남으므로,
55
+ // 이 대화의 rollout 기록 파일이 있는 대화만 받는다. 파일 이름에 대화 ID 가 들어간다(rollout-<시각>-<ID>.jsonl).
56
+ if (typeof log !== 'string' || !path.isAbsolute(log))
57
+ return null;
58
+ const name = path.basename(log);
59
+ if (!name.endsWith(`${conversationId}.jsonl`))
60
+ throw new Error('Codex 훅의 대화 기록 경로가 이 대화의 것이 아닙니다.');
61
+ return { conversationId, transcript: path.resolve(log), cwd: input.cwd };
62
+ }
63
+ /** Codex 는 사용자가 중단한 차례에 Stop 훅을 부르지 않는다. 기록 파일 끝에 turn_aborted 가
64
+ * 마지막 차례 이벤트로 남아 있으면 그 차례는 끝난 것이다. 못 읽으면 판정하지 않는다(null). */
65
+ export function codexTurnAborted(transcript) {
66
+ if (!transcript)
67
+ return null;
68
+ let fd;
69
+ try {
70
+ fd = fs.openSync(transcript, 'r');
71
+ const size = fs.fstatSync(fd).size;
72
+ const length = Math.min(size, 256 * 1024);
73
+ const buffer = Buffer.alloc(length);
74
+ fs.readSync(fd, buffer, 0, length, size - length);
75
+ const lines = buffer.toString('utf8').split('\n').reverse();
76
+ for (const line of lines) {
77
+ if (!line.includes('"event_msg"'))
78
+ continue;
79
+ let type;
80
+ try {
81
+ type = JSON.parse(line)?.payload?.type;
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ if (type === 'turn_aborted')
87
+ return true;
88
+ if (type === 'task_started' || type === 'task_complete')
89
+ return false;
90
+ }
91
+ return null;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ finally {
97
+ if (fd !== undefined)
98
+ fs.closeSync(fd);
99
+ }
100
+ }
101
+ /** 상태 표시용: 마지막 훅이 '작업 중'이라 적은 Codex 앱 세션이 실제로는 사용자 중단으로 끝났는지. */
102
+ export function appTurnAborted(id) {
103
+ try {
104
+ const s = readJSON(sessionPath(id));
105
+ return s.transport === 'hook' && s.provider === 'codex' && s.state === 'running' && codexTurnAborted(s.transcript) === true;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ /** 같은 대화가 작업 폴더를 옮기면 훅의 cwd 가 옛 폴더와 새 폴더 사이를 오간다(Claude 앱 실측).
112
+ * 마지막 하나로 덮으면 방금까지 주고받던 저장소에서 사라지므로, 본 적 있는 곳을 최근 순으로 쌓는다. */
113
+ const MAX_PLACES = 8;
114
+ function remember(previous, value) {
115
+ return [...(previous || []).filter(v => v !== value), value].slice(-MAX_PLACES);
116
+ }
117
+ function listRequests() {
118
+ if (!fs.existsSync(requestsDir()))
119
+ return [];
120
+ return fs.readdirSync(requestsDir()).filter(n => /^[0-9a-f-]{36}\.json$/i.test(n))
121
+ .flatMap(n => { try {
122
+ return [readJSON(path.join(requestsDir(), n))];
123
+ }
124
+ catch {
125
+ return [];
126
+ } });
127
+ }
128
+ export function modeActive(mode, now = Date.now()) {
129
+ return !!mode && Date.parse(mode.until) > now && mode.used < mode.maxTurns;
130
+ }
131
+ /** 이 저장소가 axMap 을 쓰는지. 안 쓰는 저장소의 대화에는 세션 ID 안내를 넣지 않는다(토큰 낭비). */
132
+ function usesAxmap(cwd) {
133
+ const r = spawnSync('git', ['for-each-ref', '--count=1', '--format=x', 'refs/heads/axmap/claims', 'refs/remotes/*/axmap/claims'], { cwd, encoding: 'utf8', windowsHide: true });
134
+ return r.status === 0 && r.stdout.trim() === 'x';
135
+ }
136
+ function announcement(id) {
137
+ return `axMap: 이 대화의 세션 ID는 ${id} 입니다. 다른 AI 세션과 대화 모드로 주고받을 때 ` +
138
+ `ax_dialogue_mode(session) 와 ax_dispatch(from_session) 에 이 값을 씁니다. 사용자가 요청하지 않으면 쓰지 않습니다.`;
139
+ }
140
+ /** 훅 하나를 처리하고 공급자에게 돌려줄 JSON 을 만든다. 오류는 호출한 쪽이 삼키고 {} 를 낸다 —
141
+ * 훅 실패가 사용자의 대화를 막으면 안 된다. */
142
+ export function handleAppHook(input, event, provider, options = {}) {
143
+ if (!['claude', 'codex'].includes(provider))
144
+ return {};
145
+ if (!['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd'].includes(event))
146
+ return {};
147
+ const now = options.now || Date.now;
148
+ const sleep = options.sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
149
+ const who = hookIdentity(input, provider);
150
+ if (!who)
151
+ return {};
152
+ const repo = repoKey(who.cwd);
153
+ const id = appSessionId(provider, who.conversationId);
154
+ fs.mkdirSync(requestsDir(), { recursive: true, mode: 0o700 });
155
+ const update = (change) => withRepoLock(queueLock(id), () => {
156
+ const previous = fs.existsSync(sessionPath(id)) ? readJSON(sessionPath(id)) : null;
157
+ const base = { ...(previous || {}), id, provider: provider, transport: 'hook',
158
+ conversationId: who.conversationId, ...(who.transcript ? { transcript: who.transcript } : {}),
159
+ workspaces: remember(previous?.workspaces, path.resolve(who.cwd)), repos: remember(previous?.repos, repo), registeredAt: new Date(now()).toISOString(), event,
160
+ ...(input.model && typeof input.model === 'string' ? { model: input.model } : {}) };
161
+ const next = change(base);
162
+ writeJSON(sessionPath(id), next);
163
+ return { previous, next };
164
+ });
165
+ if (event === 'SessionEnd') {
166
+ update(s => ({ ...s, state: 'closed', fullyIdle: false, dialogueMode: undefined }));
167
+ failQueued(id, '받는 세션이 종료돼 전달하지 않았습니다.');
168
+ return {};
169
+ }
170
+ if (event === 'SessionStart' || event === 'UserPromptSubmit') {
171
+ const { previous } = update(s => ({ ...s, state: event === 'SessionStart' ? 'idle' : 'running', fullyIdle: event === 'SessionStart' }));
172
+ if (event === 'UserPromptSubmit')
173
+ supersedeInFlight(id, typeof input.prompt === 'string' ? input.prompt : '');
174
+ if (previous?.announced || !usesAxmap(who.cwd))
175
+ return {};
176
+ update(s => ({ ...s, announced: true }));
177
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: announcement(id) } };
178
+ }
179
+ // Stop: 방금 끝난 차례가 우리가 넣은 요청이었으면 그 답을 결과로 적는다.
180
+ const { next: session } = update(s => ({ ...s, state: 'idle', fullyIdle: true }));
181
+ completeInFlight(id, input, who.transcript);
182
+ if (!modeActive(session.dialogueMode, now()))
183
+ return {};
184
+ const waitMs = Math.min(session.dialogueMode.waitSeconds, MAX_WAIT_SECONDS) * 1000;
185
+ const deadline = Math.min(now() + waitMs, Date.parse(session.dialogueMode.until));
186
+ for (;;) {
187
+ const delivery = takeNext(id, now);
188
+ if (delivery)
189
+ return { decision: 'block', reason: delivery };
190
+ if (now() >= deadline)
191
+ return {};
192
+ sleep(Math.max(0, Math.min(500, deadline - now())));
193
+ }
194
+ }
195
+ function failQueued(id, reason) {
196
+ withRepoLock(queueLock(id), () => {
197
+ for (const r of listRequests().filter(r => r.to === id && r.state === 'queued')) {
198
+ writeJSON(requestPath(r.id), { ...r, state: 'failed', error: reason });
199
+ }
200
+ });
201
+ }
202
+ /** 우리가 넣은 요청을 처리하던 중에 사람이 다른 말을 넣었다면, 다음 답은 그 요청의 답이 아니다. */
203
+ function supersedeInFlight(id, prompt) {
204
+ withRepoLock(queueLock(id), () => {
205
+ for (const r of listRequests().filter(r => r.to === id && r.state === 'submitted')) {
206
+ // Codex 는 이어 가기 문구를 새 프롬프트로 만들 수 있다. 그 문구에는 우리 표식이 있다.
207
+ if (prompt.includes(`[axMap request ${r.id}]`))
208
+ continue;
209
+ writeJSON(requestPath(r.id), { ...r, state: 'superseded', error: '사용자 입력이 끼어들어 이 요청의 답을 확정할 수 없습니다.' });
210
+ }
211
+ });
212
+ }
213
+ function lastClaudeAnswer(transcript) {
214
+ if (!transcript || !fs.existsSync(transcript))
215
+ return undefined;
216
+ const lines = fs.readFileSync(transcript, 'utf8').split('\n').filter(Boolean);
217
+ for (let i = lines.length - 1; i >= 0; i--) {
218
+ let entry;
219
+ try {
220
+ entry = JSON.parse(lines[i]);
221
+ }
222
+ catch {
223
+ continue;
224
+ }
225
+ if (entry.type === 'user')
226
+ return undefined;
227
+ if (entry.type !== 'assistant')
228
+ continue;
229
+ const content = entry.message?.content;
230
+ const text = typeof content === 'string' ? content : Array.isArray(content)
231
+ ? content.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n') : '';
232
+ if (text.trim())
233
+ return text;
234
+ }
235
+ return undefined;
236
+ }
237
+ function completeInFlight(id, input, transcript) {
238
+ withRepoLock(queueLock(id), () => {
239
+ for (const r of listRequests().filter(r => r.to === id && r.state === 'submitted')) {
240
+ const answer = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : lastClaudeAnswer(transcript);
241
+ writeJSON(requestPath(r.id), typeof answer === 'string'
242
+ ? { ...r, state: 'completed', result: answer, completedAt: new Date().toISOString() }
243
+ // 답을 못 읽은 것과 답이 없는 것은 다르다. 완료로 치지 않는다.
244
+ : { ...r, state: 'unknown', error: '응답이 끝났지만 마지막 답을 읽지 못했습니다.' });
245
+ }
246
+ });
247
+ }
248
+ /** 다음에 이어 갈 내용 하나를 꺼낸다: 내게 온 요청(먼저 온 순서), 없으면 내가 보낸 요청의 회신. */
249
+ function takeNext(id, now) {
250
+ return withRepoLock(queueLock(id), () => {
251
+ const s = readJSON(sessionPath(id));
252
+ if (!modeActive(s.dialogueMode, now()))
253
+ return null;
254
+ const all = listRequests().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
255
+ const remaining = s.dialogueMode.maxTurns - s.dialogueMode.used - 1;
256
+ const request = all.find(r => r.to === id && r.state === 'queued');
257
+ const reply = request ? undefined : all.find(r => r.fromSession === id && !r.returnedAt &&
258
+ ['completed', 'failed', 'superseded', 'unknown'].includes(r.state));
259
+ if (!request && !reply)
260
+ return null;
261
+ let text;
262
+ if (request) {
263
+ writeJSON(requestPath(request.id), { ...request, state: 'submitted', deliveredAt: new Date(now()).toISOString() });
264
+ text = `[axMap request ${request.id}]\n요청자: ${request.fromSession ? `세션 ${request.fromSession}` : request.from}\n${request.prompt}\n\n` +
265
+ `— axMap 대화 모드: 이 요청에 대한 마지막 답변이 그대로 요청자에게 전달됩니다.` +
266
+ (request.fromSession ? ` 답과 별개로 상대에게 새로 맡길 일이 있을 때만 ax_dispatch(to="${request.fromSession}", from_session="${id}")를 쓰세요.` : '') +
267
+ ` 남은 자동 이어 가기 ${remaining}회.`;
268
+ }
269
+ else {
270
+ const r = reply;
271
+ writeJSON(requestPath(r.id), { ...r, returnedAt: new Date(now()).toISOString() });
272
+ const body = typeof r.result === 'string' ? r.result : '';
273
+ const clipped = body.length > MAX_REPLY_CHARS ? `${body.slice(0, MAX_REPLY_CHARS)}\n…(잘림. 전체는 ax_result(id="${r.id}"))` : body;
274
+ text = `[axMap reply ${r.id}]\n세션 ${r.to}에 보낸 요청이 ${r.state === 'completed' ? '끝났습니다. 답:' : `완료되지 않았습니다(${r.state}): ${r.error || ''}`}\n${clipped}\n\n` +
275
+ `— axMap 대화 모드: 이어서 맡길 일이 있으면 ax_dispatch(to="${r.to}", from_session="${id}")로 보내고, 작업이 끝났으면 보내지 마세요. 남은 자동 이어 가기 ${remaining}회.`;
276
+ }
277
+ writeJSON(sessionPath(id), { ...s, state: 'running', fullyIdle: false,
278
+ dialogueMode: { ...s.dialogueMode, used: s.dialogueMode.used + 1 } });
279
+ return text;
280
+ });
281
+ }
282
+ /** 앱 세션으로 가는 요청은 언제나 대기열에 넣는다. 꺼내는 것은 받는 쪽의 Stop 훅뿐이다. */
283
+ export function enqueueAppRequest(s, request) {
284
+ if (s.state === 'closed')
285
+ throw new Error('받는 앱 세션이 종료됐습니다.');
286
+ if (!modeActive(s.dialogueMode))
287
+ throw new Error('받는 앱 세션의 대화 모드가 꺼져 있습니다. 그 세션에서 ax_dialogue_mode 로 켠 뒤 보내세요.');
288
+ return withRepoLock(queueLock(s.id), () => {
289
+ if (listRequests().filter(r => r.to === s.id && r.state === 'queued').length >= 100)
290
+ throw new Error('받는 앱 세션의 대기열이 가득 찼습니다.');
291
+ const record = { ...request, id: randomUUID(), createdAt: new Date().toISOString(), baseline: null, state: 'queued' };
292
+ writeJSON(requestPath(record.id), record);
293
+ return record;
294
+ });
295
+ }
296
+ export function setDialogueMode({ session, cwd, on, minutes = 30, maxTurns = DEFAULT_MAX_TURNS, waitSeconds = MAX_WAIT_SECONDS }) {
297
+ if (!UUID.test(session || ''))
298
+ throw new Error('세션 ID는 UUID여야 합니다.');
299
+ for (const [name, value, max] of [['minutes', minutes, 240], ['maxTurns', maxTurns, 50], ['waitSeconds', waitSeconds, MAX_WAIT_SECONDS]]) {
300
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
301
+ throw new Error(`${name} 는 1~${max} 정수여야 합니다.`);
302
+ }
303
+ if (!fs.existsSync(sessionPath(session)))
304
+ throw new Error('등록되지 않은 세션입니다.');
305
+ const repo = repoKey(cwd);
306
+ const result = withRepoLock(queueLock(session), () => {
307
+ const s = readJSON(sessionPath(session));
308
+ if (!s.repos.includes(repo))
309
+ throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.');
310
+ if (s.transport !== 'hook')
311
+ throw new Error('대화 모드는 앱 세션(transport: hook)에만 있습니다. 터미널·관리형 세션은 이미 바로 받습니다.');
312
+ if (on && s.state === 'closed')
313
+ throw new Error('종료된 세션입니다.');
314
+ const dialogueMode = on
315
+ ? { until: new Date(Date.now() + minutes * 60000).toISOString(), maxTurns, used: 0, waitSeconds } : undefined;
316
+ writeJSON(sessionPath(session), { ...s, dialogueMode });
317
+ return { session, on, ...(dialogueMode ? { dialogueMode } : {}) };
318
+ });
319
+ if (!on)
320
+ failQueued(session, '받는 세션의 대화 모드가 꺼져 전달하지 않았습니다.');
321
+ return { ...result, note: on
322
+ ? '이 세션은 응답이 끝날 때마다 최대 ' + waitSeconds + '초 동안 상대의 요청·회신을 기다렸다가 같은 대화에서 이어 갑니다. 기다리는 동안 앱은 작업 중으로 보입니다.'
323
+ : '대화 모드를 껐습니다. 아직 전달하지 않은 요청은 failed 로 적었습니다.' };
324
+ }
@@ -0,0 +1,294 @@
1
+ /** 앱 세션(Claude 데스크톱 앱의 Code 탭, Codex 앱, axMap 래퍼 없는 codex TUI)의 대화 모드.
2
+ *
3
+ * 이 세션들에는 axMap 이 글자를 대신 입력할 콘솔 창이 없다. 그래서 공급자 자신의 훅만 쓴다.
4
+ * - 훅이 올 때마다 대화 ID 로 세션을 등록한다(transport: 'hook').
5
+ * - 대화 모드가 켜진 세션은 응답이 끝날 때(Stop) 상대의 요청이나 회신을 로컬에서 기다린다.
6
+ * 오면 공식 계약 {"decision":"block","reason":...} 으로 같은 대화를 이어 가게 한다.
7
+ * - 쉬고 있는 앱을 밖에서 깨울 방법은 없다. 기다림이 끝난 세션은 사용자가 다음에 말을 걸 때까지 쉰다.
8
+ * 기다리는 동안 모델을 부르지 않고, 새 모델 프로세스를 띄우지 않는다(D26). */
9
+ import fs from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import { createHash, randomUUID } from 'node:crypto'
13
+ import { spawnSync } from 'node:child_process'
14
+ import { withRepoLock } from './repolock.mjs'
15
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
16
+ import type { HookInput, RequestRecord, SessionRecord, DialogueMode } from './session-types.mjs'
17
+
18
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
19
+ /** 공급자 훅 제한 시간(600초, 두 공급자 문서의 기본값)보다 짧게 둔다. 훅이 강제 종료되면
20
+ * 이미 꺼낸 요청을 전달했는지 알 수 없게 되므로, 우리가 먼저 빈손으로 끝낸다. */
21
+ export const MAX_WAIT_SECONDS = 540
22
+ /** Claude Code 는 사용자 입력 없이 Stop 훅으로 이어 가는 것을 기본 8회에서 끊는다
23
+ * (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP). 기본값을 그보다 크게 잡으면 끝이 우리 한도가 아니라 공급자 한도가 된다. */
24
+ const DEFAULT_MAX_TURNS = 8
25
+ /** Claude 는 훅이 넣는 문자열을 1만 자에서 자른다. 머리말을 붙일 자리를 남긴다. */
26
+ const MAX_REPLY_CHARS = 8000
27
+
28
+ const sessionsDir = () => path.join(bridgeHome(), 'sessions')
29
+ const requestsDir = () => path.join(bridgeHome(), 'requests')
30
+ const sessionPath = (id: string) => path.join(sessionsDir(), `${id}.json`)
31
+ const requestPath = (id: string) => path.join(requestsDir(), `${id}.json`)
32
+ /** dispatchRequest 가 같은 수신자에 쓰는 잠금과 같은 파일이다. 꺼내기와 넣기가 서로 엇갈리지 않는다. */
33
+ const queueLock = (id: string) => path.join(requestsDir(), `${id}.lock`)
34
+
35
+ export function appSessionId(provider: string, conversationId: string): string {
36
+ const hex = createHash('sha256').update(`${provider}-hook:${conversationId}`).digest('hex')
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
+ }
39
+
40
+ function claudeHome() { return process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') }
41
+
42
+ /** 훅 입력은 공급자가 준 것이지만, 다른 대화의 기록 파일을 가리키지 않는지는 확인한다.
43
+ * null 은 "등록하지 않을 대화"다. 오류가 아니므로 조용히 넘긴다. */
44
+ function hookIdentity(input: HookInput, provider: string): { conversationId: string; transcript?: string; cwd: string } | null {
45
+ const conversationId = input.session_id
46
+ if (typeof conversationId !== 'string' || !UUID.test(conversationId)) throw new Error('훅 입력에 대화 UUID(session_id)가 없습니다.')
47
+ if (typeof input.cwd !== 'string' || !path.isAbsolute(input.cwd)) throw new Error('훅 입력에 작업 폴더(cwd)가 없습니다.')
48
+ const log = input.transcript_path
49
+ if (provider === 'claude') {
50
+ const relative = typeof log === 'string' ? path.relative(path.join(claudeHome(), 'projects'), path.resolve(log)) : '..'
51
+ if (!log || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(log) !== `${conversationId}.jsonl`) {
52
+ throw new Error('Claude 훅의 대화 기록 경로가 이 대화의 것이 아닙니다.')
53
+ }
54
+ return { conversationId, transcript: path.resolve(log), cwd: input.cwd }
55
+ }
56
+ // Codex 앱은 메시지마다 제목 생성 같은 보조 대화를 띄우고, 이 대화는 기록 파일 없이(transcript_path null)
57
+ // Stop·SessionEnd 도 없이 사라진다(2026-11-05 Codex 앱 실측). 등록하면 '작업 중' 세션으로 영영 남으므로,
58
+ // 이 대화의 rollout 기록 파일이 있는 대화만 받는다. 파일 이름에 대화 ID 가 들어간다(rollout-<시각>-<ID>.jsonl).
59
+ if (typeof log !== 'string' || !path.isAbsolute(log)) return null
60
+ const name = path.basename(log)
61
+ if (!name.endsWith(`${conversationId}.jsonl`)) throw new Error('Codex 훅의 대화 기록 경로가 이 대화의 것이 아닙니다.')
62
+ return { conversationId, transcript: path.resolve(log), cwd: input.cwd }
63
+ }
64
+
65
+ /** Codex 는 사용자가 중단한 차례에 Stop 훅을 부르지 않는다. 기록 파일 끝에 turn_aborted 가
66
+ * 마지막 차례 이벤트로 남아 있으면 그 차례는 끝난 것이다. 못 읽으면 판정하지 않는다(null). */
67
+ export function codexTurnAborted(transcript: string | undefined): boolean | null {
68
+ if (!transcript) return null
69
+ let fd: number | undefined
70
+ try {
71
+ fd = fs.openSync(transcript, 'r')
72
+ const size = fs.fstatSync(fd).size
73
+ const length = Math.min(size, 256 * 1024)
74
+ const buffer = Buffer.alloc(length)
75
+ fs.readSync(fd, buffer, 0, length, size - length)
76
+ const lines = buffer.toString('utf8').split('\n').reverse()
77
+ for (const line of lines) {
78
+ if (!line.includes('"event_msg"')) continue
79
+ let type: unknown
80
+ try { type = JSON.parse(line)?.payload?.type } catch { continue }
81
+ if (type === 'turn_aborted') return true
82
+ if (type === 'task_started' || type === 'task_complete') return false
83
+ }
84
+ return null
85
+ } catch { return null } finally { if (fd !== undefined) fs.closeSync(fd) }
86
+ }
87
+
88
+ /** 상태 표시용: 마지막 훅이 '작업 중'이라 적은 Codex 앱 세션이 실제로는 사용자 중단으로 끝났는지. */
89
+ export function appTurnAborted(id: string): boolean {
90
+ try {
91
+ const s = readJSON<SessionRecord>(sessionPath(id))
92
+ return s.transport === 'hook' && s.provider === 'codex' && s.state === 'running' && codexTurnAborted(s.transcript) === true
93
+ } catch { return false }
94
+ }
95
+
96
+ /** 같은 대화가 작업 폴더를 옮기면 훅의 cwd 가 옛 폴더와 새 폴더 사이를 오간다(Claude 앱 실측).
97
+ * 마지막 하나로 덮으면 방금까지 주고받던 저장소에서 사라지므로, 본 적 있는 곳을 최근 순으로 쌓는다. */
98
+ const MAX_PLACES = 8
99
+ function remember(previous: string[] | undefined, value: string): string[] {
100
+ return [...(previous || []).filter(v => v !== value), value].slice(-MAX_PLACES)
101
+ }
102
+
103
+ function listRequests(): RequestRecord[] {
104
+ if (!fs.existsSync(requestsDir())) return []
105
+ return fs.readdirSync(requestsDir()).filter(n => /^[0-9a-f-]{36}\.json$/i.test(n))
106
+ .flatMap(n => { try { return [readJSON<RequestRecord>(path.join(requestsDir(), n))] } catch { return [] } })
107
+ }
108
+
109
+ export function modeActive(mode: DialogueMode | undefined, now = Date.now()): mode is DialogueMode {
110
+ return !!mode && Date.parse(mode.until) > now && mode.used < mode.maxTurns
111
+ }
112
+
113
+ /** 이 저장소가 axMap 을 쓰는지. 안 쓰는 저장소의 대화에는 세션 ID 안내를 넣지 않는다(토큰 낭비). */
114
+ function usesAxmap(cwd: string): boolean {
115
+ const r = spawnSync('git', ['for-each-ref', '--count=1', '--format=x', 'refs/heads/axmap/claims', 'refs/remotes/*/axmap/claims'],
116
+ { cwd, encoding: 'utf8', windowsHide: true })
117
+ return r.status === 0 && r.stdout.trim() === 'x'
118
+ }
119
+
120
+ function announcement(id: string): string {
121
+ return `axMap: 이 대화의 세션 ID는 ${id} 입니다. 다른 AI 세션과 대화 모드로 주고받을 때 ` +
122
+ `ax_dialogue_mode(session) 와 ax_dispatch(from_session) 에 이 값을 씁니다. 사용자가 요청하지 않으면 쓰지 않습니다.`
123
+ }
124
+
125
+ type HookOutput = Record<string, unknown>
126
+ export interface AppHookOptions { now?: () => number; sleep?: (ms: number) => void }
127
+
128
+ /** 훅 하나를 처리하고 공급자에게 돌려줄 JSON 을 만든다. 오류는 호출한 쪽이 삼키고 {} 를 낸다 —
129
+ * 훅 실패가 사용자의 대화를 막으면 안 된다. */
130
+ export function handleAppHook(input: HookInput, event: string, provider: string, options: AppHookOptions = {}): HookOutput {
131
+ if (!['claude', 'codex'].includes(provider)) return {}
132
+ if (!['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd'].includes(event)) return {}
133
+ const now = options.now || Date.now
134
+ const sleep = options.sleep || ((ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms))
135
+ const who = hookIdentity(input, provider)
136
+ if (!who) return {}
137
+ const repo = repoKey(who.cwd)
138
+ const id = appSessionId(provider, who.conversationId)
139
+ fs.mkdirSync(requestsDir(), { recursive: true, mode: 0o700 })
140
+
141
+ const update = (change: (s: SessionRecord) => SessionRecord) => withRepoLock(queueLock(id), () => {
142
+ const previous = fs.existsSync(sessionPath(id)) ? readJSON<SessionRecord>(sessionPath(id)) : null
143
+ const base: SessionRecord = { ...(previous || {}), id, provider: provider as SessionRecord['provider'], transport: 'hook',
144
+ conversationId: who.conversationId, ...(who.transcript ? { transcript: who.transcript } : {}),
145
+ workspaces: remember(previous?.workspaces, path.resolve(who.cwd)), repos: remember(previous?.repos, repo), registeredAt: new Date(now()).toISOString(), event,
146
+ ...(input.model && typeof input.model === 'string' ? { model: input.model } : {}) }
147
+ const next = change(base)
148
+ writeJSON(sessionPath(id), next)
149
+ return { previous, next }
150
+ })
151
+
152
+ if (event === 'SessionEnd') {
153
+ update(s => ({ ...s, state: 'closed', fullyIdle: false, dialogueMode: undefined }))
154
+ failQueued(id, '받는 세션이 종료돼 전달하지 않았습니다.')
155
+ return {}
156
+ }
157
+ if (event === 'SessionStart' || event === 'UserPromptSubmit') {
158
+ const { previous } = update(s => ({ ...s, state: event === 'SessionStart' ? 'idle' : 'running', fullyIdle: event === 'SessionStart' }))
159
+ if (event === 'UserPromptSubmit') supersedeInFlight(id, typeof input.prompt === 'string' ? input.prompt : '')
160
+ if (previous?.announced || !usesAxmap(who.cwd)) return {}
161
+ update(s => ({ ...s, announced: true }))
162
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: announcement(id) } }
163
+ }
164
+
165
+ // Stop: 방금 끝난 차례가 우리가 넣은 요청이었으면 그 답을 결과로 적는다.
166
+ const { next: session } = update(s => ({ ...s, state: 'idle', fullyIdle: true }))
167
+ completeInFlight(id, input, who.transcript)
168
+ if (!modeActive(session.dialogueMode, now())) return {}
169
+
170
+ const waitMs = Math.min(session.dialogueMode.waitSeconds, MAX_WAIT_SECONDS) * 1000
171
+ const deadline = Math.min(now() + waitMs, Date.parse(session.dialogueMode.until))
172
+ for (;;) {
173
+ const delivery = takeNext(id, now)
174
+ if (delivery) return { decision: 'block', reason: delivery }
175
+ if (now() >= deadline) return {}
176
+ sleep(Math.max(0, Math.min(500, deadline - now())))
177
+ }
178
+ }
179
+
180
+ function failQueued(id: string, reason: string) {
181
+ withRepoLock(queueLock(id), () => {
182
+ for (const r of listRequests().filter(r => r.to === id && r.state === 'queued')) {
183
+ writeJSON(requestPath(r.id), { ...r, state: 'failed', error: reason })
184
+ }
185
+ })
186
+ }
187
+
188
+ /** 우리가 넣은 요청을 처리하던 중에 사람이 다른 말을 넣었다면, 다음 답은 그 요청의 답이 아니다. */
189
+ function supersedeInFlight(id: string, prompt: string) {
190
+ withRepoLock(queueLock(id), () => {
191
+ for (const r of listRequests().filter(r => r.to === id && r.state === 'submitted')) {
192
+ // Codex 는 이어 가기 문구를 새 프롬프트로 만들 수 있다. 그 문구에는 우리 표식이 있다.
193
+ if (prompt.includes(`[axMap request ${r.id}]`)) continue
194
+ writeJSON(requestPath(r.id), { ...r, state: 'superseded', error: '사용자 입력이 끼어들어 이 요청의 답을 확정할 수 없습니다.' })
195
+ }
196
+ })
197
+ }
198
+
199
+ function lastClaudeAnswer(transcript: string | undefined): string | undefined {
200
+ if (!transcript || !fs.existsSync(transcript)) return undefined
201
+ const lines = fs.readFileSync(transcript, 'utf8').split('\n').filter(Boolean)
202
+ for (let i = lines.length - 1; i >= 0; i--) {
203
+ let entry: { type?: string; message?: { content?: unknown } }
204
+ try { entry = JSON.parse(lines[i]!) } catch { continue }
205
+ if (entry.type === 'user') return undefined
206
+ if (entry.type !== 'assistant') continue
207
+ const content = entry.message?.content
208
+ const text = typeof content === 'string' ? content : Array.isArray(content)
209
+ ? content.filter((b: { type?: string }) => b?.type === 'text').map((b: { text?: string }) => b.text || '').join('\n') : ''
210
+ if (text.trim()) return text
211
+ }
212
+ return undefined
213
+ }
214
+
215
+ function completeInFlight(id: string, input: HookInput, transcript: string | undefined) {
216
+ withRepoLock(queueLock(id), () => {
217
+ for (const r of listRequests().filter(r => r.to === id && r.state === 'submitted')) {
218
+ const answer = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : lastClaudeAnswer(transcript)
219
+ writeJSON(requestPath(r.id), typeof answer === 'string'
220
+ ? { ...r, state: 'completed', result: answer, completedAt: new Date().toISOString() }
221
+ // 답을 못 읽은 것과 답이 없는 것은 다르다. 완료로 치지 않는다.
222
+ : { ...r, state: 'unknown', error: '응답이 끝났지만 마지막 답을 읽지 못했습니다.' })
223
+ }
224
+ })
225
+ }
226
+
227
+ /** 다음에 이어 갈 내용 하나를 꺼낸다: 내게 온 요청(먼저 온 순서), 없으면 내가 보낸 요청의 회신. */
228
+ function takeNext(id: string, now: () => number): string | null {
229
+ return withRepoLock(queueLock(id), () => {
230
+ const s = readJSON<SessionRecord>(sessionPath(id))
231
+ if (!modeActive(s.dialogueMode, now())) return null
232
+ const all = listRequests().sort((a, b) => a.createdAt.localeCompare(b.createdAt))
233
+ const remaining = s.dialogueMode.maxTurns - s.dialogueMode.used - 1
234
+ const request = all.find(r => r.to === id && r.state === 'queued')
235
+ const reply = request ? undefined : all.find(r => r.fromSession === id && !r.returnedAt &&
236
+ ['completed', 'failed', 'superseded', 'unknown'].includes(r.state))
237
+ if (!request && !reply) return null
238
+ let text: string
239
+ if (request) {
240
+ writeJSON(requestPath(request.id), { ...request, state: 'submitted', deliveredAt: new Date(now()).toISOString() })
241
+ text = `[axMap request ${request.id}]\n요청자: ${request.fromSession ? `세션 ${request.fromSession}` : request.from}\n${request.prompt}\n\n` +
242
+ `— axMap 대화 모드: 이 요청에 대한 마지막 답변이 그대로 요청자에게 전달됩니다.` +
243
+ (request.fromSession ? ` 답과 별개로 상대에게 새로 맡길 일이 있을 때만 ax_dispatch(to="${request.fromSession}", from_session="${id}")를 쓰세요.` : '') +
244
+ ` 남은 자동 이어 가기 ${remaining}회.`
245
+ } else {
246
+ const r = reply!
247
+ writeJSON(requestPath(r.id), { ...r, returnedAt: new Date(now()).toISOString() })
248
+ const body = typeof r.result === 'string' ? r.result : ''
249
+ const clipped = body.length > MAX_REPLY_CHARS ? `${body.slice(0, MAX_REPLY_CHARS)}\n…(잘림. 전체는 ax_result(id="${r.id}"))` : body
250
+ text = `[axMap reply ${r.id}]\n세션 ${r.to}에 보낸 요청이 ${r.state === 'completed' ? '끝났습니다. 답:' : `완료되지 않았습니다(${r.state}): ${r.error || ''}`}\n${clipped}\n\n` +
251
+ `— axMap 대화 모드: 이어서 맡길 일이 있으면 ax_dispatch(to="${r.to}", from_session="${id}")로 보내고, 작업이 끝났으면 보내지 마세요. 남은 자동 이어 가기 ${remaining}회.`
252
+ }
253
+ writeJSON(sessionPath(id), { ...s, state: 'running', fullyIdle: false,
254
+ dialogueMode: { ...s.dialogueMode, used: s.dialogueMode.used + 1 } })
255
+ return text
256
+ })
257
+ }
258
+
259
+ /** 앱 세션으로 가는 요청은 언제나 대기열에 넣는다. 꺼내는 것은 받는 쪽의 Stop 훅뿐이다. */
260
+ export function enqueueAppRequest(s: SessionRecord, request: Omit<RequestRecord, 'id' | 'createdAt' | 'state' | 'baseline'>): RequestRecord {
261
+ if (s.state === 'closed') throw new Error('받는 앱 세션이 종료됐습니다.')
262
+ if (!modeActive(s.dialogueMode)) throw new Error('받는 앱 세션의 대화 모드가 꺼져 있습니다. 그 세션에서 ax_dialogue_mode 로 켠 뒤 보내세요.')
263
+ return withRepoLock(queueLock(s.id), () => {
264
+ if (listRequests().filter(r => r.to === s.id && r.state === 'queued').length >= 100) throw new Error('받는 앱 세션의 대기열이 가득 찼습니다.')
265
+ const record: RequestRecord = { ...request, id: randomUUID(), createdAt: new Date().toISOString(), baseline: null, state: 'queued' }
266
+ writeJSON(requestPath(record.id), record)
267
+ return record
268
+ })
269
+ }
270
+
271
+ export interface ModeOptions { session: string; cwd: string; on: boolean; minutes?: number; maxTurns?: number; waitSeconds?: number }
272
+ export function setDialogueMode({ session, cwd, on, minutes = 30, maxTurns = DEFAULT_MAX_TURNS, waitSeconds = MAX_WAIT_SECONDS }: ModeOptions) {
273
+ if (!UUID.test(session || '')) throw new Error('세션 ID는 UUID여야 합니다.')
274
+ for (const [name, value, max] of [['minutes', minutes, 240], ['maxTurns', maxTurns, 50], ['waitSeconds', waitSeconds, MAX_WAIT_SECONDS]] as const) {
275
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`${name} 는 1~${max} 정수여야 합니다.`)
276
+ }
277
+ if (!fs.existsSync(sessionPath(session))) throw new Error('등록되지 않은 세션입니다.')
278
+ const repo = repoKey(cwd)
279
+ const result = withRepoLock(queueLock(session), () => {
280
+ const s = readJSON<SessionRecord>(sessionPath(session))
281
+ if (!s.repos.includes(repo)) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
282
+ if (s.transport !== 'hook') throw new Error('대화 모드는 앱 세션(transport: hook)에만 있습니다. 터미널·관리형 세션은 이미 바로 받습니다.')
283
+ if (on && s.state === 'closed') throw new Error('종료된 세션입니다.')
284
+ const dialogueMode: DialogueMode | undefined = on
285
+ ? { until: new Date(Date.now() + minutes * 60000).toISOString(), maxTurns, used: 0, waitSeconds } : undefined
286
+ writeJSON(sessionPath(session), { ...s, dialogueMode })
287
+ return { session, on, ...(dialogueMode ? { dialogueMode } : {}) }
288
+ })
289
+ if (!on) failQueued(session, '받는 세션의 대화 모드가 꺼져 전달하지 않았습니다.')
290
+ return { ...result, note: on
291
+ ? '이 세션은 응답이 끝날 때마다 최대 ' + waitSeconds + '초 동안 상대의 요청·회신을 기다렸다가 같은 대화에서 이어 갑니다. 기다리는 동안 앱은 작업 중으로 보입니다.'
292
+ : '대화 모드를 껐습니다. 아직 전달하지 않은 요청은 failed 로 적었습니다.' }
293
+ }
294
+
package/src/bridge.mjs CHANGED
@@ -9,6 +9,7 @@ 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';
12
+ import { enqueueAppRequest } from './app-session.mjs';
12
13
  import { errorCode, errorMessage, RPC_NOT_DELIVERED_EXIT } from './session-types.mjs';
13
14
  import { commonKey, resolveTarget } from './workspace.mjs';
14
15
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -134,6 +135,7 @@ function publicSession(s, liveness) {
134
135
  // 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
135
136
  fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
136
137
  role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
138
+ ...(s.dialogueMode ? { dialogueMode: s.dialogueMode } : {}),
137
139
  ...(liveness ? { liveness } : {}) };
138
140
  }
139
141
  /** 관리형 세션(pty·stdio)의 로컬 제어 파이프. 세션 호스트가 살아 있는 동안만 존재한다. */
@@ -221,7 +223,7 @@ function sessionFor(id, cwd) {
221
223
  const s = readJSON(file('sessions', id));
222
224
  if (!s.repos.includes(repoKey(cwd)))
223
225
  throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.');
224
- if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio'].includes(s.transport || ''))
226
+ if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio', 'hook'].includes(s.transport || ''))
225
227
  throw new Error('아직 지원하지 않는 수신 세션입니다.');
226
228
  return s;
227
229
  }
@@ -256,6 +258,9 @@ export function requestResult(id, cwd) {
256
258
  if (!['queued', 'submitted', 'unknown'].includes(request.state))
257
259
  return request;
258
260
  let s = sessionFor(request.to, cwd);
261
+ // 앱 세션의 요청 상태는 받는 쪽 훅이 직접 적는다. 여기서 추측하지 않는다.
262
+ if (s.transport === 'hook')
263
+ return request;
259
264
  if (request.state === 'queued' && s.transport === 'console')
260
265
  return request;
261
266
  if (['pty', 'stdio'].includes(s.transport || '')) {
@@ -336,7 +341,7 @@ export function sessionLeasePath(id) {
336
341
  throw new Error('세션 ID는 UUID여야 합니다.');
337
342
  return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`);
338
343
  }
339
- export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null, queueIfBusy = false }) {
344
+ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', fromSession, dialogueId = null, queueIfBusy = false }) {
340
345
  // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
341
346
  // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
342
347
  let sending = false;
@@ -349,6 +354,16 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
349
354
  throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.');
350
355
  const dir = path.join(bridgeHome(), 'requests');
351
356
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
357
+ if (fromSession !== undefined) {
358
+ if (fromSession === to)
359
+ throw new Error('자기 자신에게는 보낼 수 없습니다.');
360
+ sessionFor(fromSession, cwd);
361
+ }
362
+ if (s.transport === 'hook') {
363
+ if (dialogueId)
364
+ throw new Error('앱 세션은 자동 대화 중계(ax_chat)를 지원하지 않습니다. 사용량을 측정하지 못합니다.');
365
+ return enqueueAppRequest(s, { to, from, repo: repoKey(cwd), prompt, ...(fromSession ? { fromSession } : {}) });
366
+ }
352
367
  if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
353
368
  validateConsolePrompt(prompt, from);
354
369
  if (s.state === 'closed')
@@ -358,7 +373,7 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
358
373
  .filter(r => r.to === to && r.state === 'queued');
359
374
  if (pending.length >= 100)
360
375
  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' };
376
+ const request = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' };
362
377
  writeJSON(file('requests', request.id), request);
363
378
  return request;
364
379
  });
@@ -391,7 +406,7 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
391
406
  if (!s.fullyIdle || s.event !== 'Stop')
392
407
  throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.');
393
408
  }
394
- const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
409
+ const request = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt,
395
410
  createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' };
396
411
  const target = file('requests', request.id);
397
412
  writeJSON(target, request);
package/src/bridge.mts CHANGED
@@ -8,6 +8,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'
@@ -111,6 +112,7 @@ function publicSession(s: SessionRecord, liveness?: HostLiveness): PublicSession
111
112
  // 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
112
113
  fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
113
114
  role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
115
+ ...(s.dialogueMode ? { dialogueMode: s.dialogueMode } : {}),
114
116
  ...(liveness ? { liveness } : {}) }
115
117
  }
116
118
 
@@ -178,7 +180,7 @@ export function sessions(cwd: string): PublicSession[] {
178
180
  function sessionFor(id: string, cwd: string): SessionRecord {
179
181
  const s = readJSON<SessionRecord>(file('sessions', id))
180
182
  if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
181
- if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['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('아직 지원하지 않는 수신 세션입니다.')
182
184
  return s
183
185
  }
184
186
 
@@ -209,6 +211,8 @@ export function requestResult(id: string, cwd: string): RequestRecord {
209
211
  if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
210
212
  if (!['queued', 'submitted', 'unknown'].includes(request.state)) return request
211
213
  let s = sessionFor(request.to, cwd)
214
+ // 앱 세션의 요청 상태는 받는 쪽 훅이 직접 적는다. 여기서 추측하지 않는다.
215
+ if (s.transport === 'hook') return request
212
216
  if (request.state === 'queued' && s.transport === 'console') return request
213
217
  if (['pty', 'stdio'].includes(s.transport || '')) {
214
218
  try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
@@ -271,7 +275,7 @@ export function sessionLeasePath(id: string): string {
271
275
  return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
272
276
  }
273
277
 
274
- export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null, queueIfBusy = false }: DispatchOptions): RequestRecord {
278
+ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', fromSession, dialogueId = null, queueIfBusy = false }: DispatchOptions): RequestRecord {
275
279
  // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
276
280
  // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
277
281
  let sending = false
@@ -282,6 +286,14 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
282
286
  if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
283
287
  const dir = path.join(bridgeHome(), 'requests')
284
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
+ }
285
297
  if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
286
298
  validateConsolePrompt(prompt, from)
287
299
  if (s.state === 'closed') throw new Error('Claude terminal is closed')
@@ -289,7 +301,7 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
289
301
  const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
290
302
  .filter(r => r.to === to && r.state === 'queued')
291
303
  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' }
304
+ const request: RequestRecord = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' }
293
305
  writeJSON(file('requests', request.id), request)
294
306
  return request
295
307
  })
@@ -313,7 +325,7 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
313
325
  runAPI(s, ['get-conversation-metadata', to])
314
326
  if (!s.fullyIdle || s.event !== 'Stop') throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.')
315
327
  }
316
- 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,
317
329
  createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
318
330
  const target = file('requests', request.id)
319
331
  writeJSON(target, request)
@@ -81,7 +81,10 @@ export async function runPipelineOperation(action, args, emit = () => { }, cance
81
81
  throw new Error('to와 prompt 문자열이 필요합니다.');
82
82
  if (progress)
83
83
  emit('axMap · 대상 세션과 입력 상태 확인');
84
- request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap', queueIfBusy: true });
84
+ if (args.from_session !== undefined && typeof args.from_session !== 'string')
85
+ throw new Error('from_session 은 세션 UUID 문자열이어야 합니다.');
86
+ request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap',
87
+ ...(args.from_session ? { fromSession: args.from_session } : {}), queueIfBusy: true });
85
88
  }
86
89
  else {
87
90
  if (typeof args.id !== 'string')
@@ -83,7 +83,9 @@ export async function runPipelineOperation(action: string, args: Record<string,
83
83
  if (action === 'dispatch') {
84
84
  if (typeof args.to !== 'string' || typeof args.prompt !== 'string') throw new Error('to와 prompt 문자열이 필요합니다.')
85
85
  if (progress) emit('axMap · 대상 세션과 입력 상태 확인')
86
- request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap', queueIfBusy: true })
86
+ if (args.from_session !== undefined && typeof args.from_session !== 'string') throw new Error('from_session 은 세션 UUID 문자열이어야 합니다.')
87
+ request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap',
88
+ ...(args.from_session ? { fromSession: args.from_session } : {}), queueIfBusy: true })
87
89
  } else {
88
90
  if (typeof args.id !== 'string') throw new Error('요청 UUID가 필요합니다.')
89
91
  request = requestResult(args.id, cwd)
@@ -2,6 +2,7 @@
2
2
  import { sessions } from './bridge.mjs';
3
3
  import { sendTerminal } from './terminal-bridge.mjs';
4
4
  import { externalStatus } from './external-console.mjs';
5
+ import { appTurnAborted } from './app-session.mjs';
5
6
  /** Probe only the authenticated local control pipe. A saved idle/running record
6
7
  * cannot establish that its host still exists; no model request is submitted. */
7
8
  export async function sessionStatus(cwd = process.cwd()) {
@@ -21,6 +22,11 @@ export async function sessionStatus(cwd = process.cwd()) {
21
22
  return snapshot('unknown', 'unreachable');
22
23
  }
23
24
  }
25
+ // 앱 세션은 물어볼 연결이 없다. 마지막 훅이 적은 상태만 보여 주고, 대기로 확정하지 않는다.
26
+ // 단 Codex 는 사용자가 중단한 차례에 Stop 훅이 없으므로, 기록 파일의 turn_aborted 로 '작업 중'을 바로잡는다.
27
+ if (session.transport === 'hook') {
28
+ return snapshot(appTurnAborted(session.id) ? 'idle' : session.state || 'unknown', 'unverified');
29
+ }
24
30
  if (!['pty', 'stdio'].includes(session.transport || ''))
25
31
  return snapshot('unknown', 'unverified');
26
32
  try {
@@ -49,6 +55,9 @@ export function formatSessionStatus(listed) {
49
55
  return '이 저장소에 등록된 로컬 세션이 없습니다.';
50
56
  return ['axMap 세션 상태', ...listed.map(s => {
51
57
  const usage = s.usage?.totalTokens == null ? '미확인' : `${s.usage.totalTokens}${s.maxTokens ? ` / ${s.maxTokens}` : ''}`;
52
- return `${safe(s.provider)} ${safe(s.id)}\n ${connections[s.connection] || '상태 미확인'} · ${labels[s.state] || '상태 미확인'}${s.role ? ` · ${safe(s.role)}` : ''}\n 확인된 누적 처리 토큰: ${usage}`;
58
+ const m = s.dialogueMode;
59
+ const mode = s.transport !== 'hook' ? '' : `\n 앱 세션 · 대화 모드 ${m && Date.parse(m.until) > Date.now() && m.used < m.maxTurns
60
+ ? `켜짐 (이어 가기 ${m.used}/${m.maxTurns}, ${m.until}까지)` : '꺼짐'}`;
61
+ return `${safe(s.provider)} ${safe(s.id)}\n ${connections[s.connection] || '상태 미확인'} · ${labels[s.state] || '상태 미확인'}${s.role ? ` · ${safe(s.role)}` : ''}${mode}\n 확인된 누적 처리 토큰: ${usage}`;
53
62
  })].join('\n');
54
63
  }
@@ -1,6 +1,7 @@
1
1
  import { sessions } from './bridge.mjs'
2
2
  import { sendTerminal } from './terminal-bridge.mjs'
3
3
  import { externalStatus } from './external-console.mjs'
4
+ import { appTurnAborted } from './app-session.mjs'
4
5
  import type { PublicSession, SessionSnapshot, SessionState, ConnectionState } from './session-types.mjs'
5
6
 
6
7
  /** Probe only the authenticated local control pipe. A saved idle/running record
@@ -18,6 +19,11 @@ export async function sessionStatus(cwd = process.cwd()): Promise<SessionSnapsho
18
19
  return snapshot(live.state, 'connected')
19
20
  } catch { return snapshot('unknown', 'unreachable') }
20
21
  }
22
+ // 앱 세션은 물어볼 연결이 없다. 마지막 훅이 적은 상태만 보여 주고, 대기로 확정하지 않는다.
23
+ // 단 Codex 는 사용자가 중단한 차례에 Stop 훅이 없으므로, 기록 파일의 turn_aborted 로 '작업 중'을 바로잡는다.
24
+ if (session.transport === 'hook') {
25
+ return snapshot(appTurnAborted(session.id) ? 'idle' : session.state || 'unknown', 'unverified')
26
+ }
21
27
  if (!['pty', 'stdio'].includes(session.transport || '')) return snapshot('unknown', 'unverified')
22
28
  try {
23
29
  const raw: unknown = await sendTerminal(session.id, { action: 'status' }, null, { timeoutMs: 1000 })
@@ -45,6 +51,9 @@ export function formatSessionStatus(listed: readonly SessionSnapshot[]): string
45
51
  if (!listed.length) return '이 저장소에 등록된 로컬 세션이 없습니다.'
46
52
  return ['axMap 세션 상태', ...listed.map(s => {
47
53
  const usage = s.usage?.totalTokens == null ? '미확인' : `${s.usage.totalTokens}${s.maxTokens ? ` / ${s.maxTokens}` : ''}`
48
- return `${safe(s.provider)} ${safe(s.id)}\n ${connections[s.connection] || '상태 미확인'} · ${labels[s.state] || '상태 미확인'}${s.role ? ` · ${safe(s.role)}` : ''}\n 확인된 누적 처리 토큰: ${usage}`
54
+ const m = s.dialogueMode
55
+ const mode = s.transport !== 'hook' ? '' : `\n 앱 세션 · 대화 모드 ${m && Date.parse(m.until) > Date.now() && m.used < m.maxTurns
56
+ ? `켜짐 (이어 가기 ${m.used}/${m.maxTurns}, ${m.until}까지)` : '꺼짐'}`
57
+ return `${safe(s.provider)} ${safe(s.id)}\n ${connections[s.connection] || '상태 미확인'} · ${labels[s.state] || '상태 미확인'}${s.role ? ` · ${safe(s.role)}` : ''}${mode}\n 확인된 누적 처리 토큰: ${usage}`
49
58
  })].join('\n')
50
59
  }
@@ -1,6 +1,6 @@
1
1
  /** Shared contracts for the local bridge. Runtime input checks still guard external data. */
2
2
  export type Provider = 'codex' | 'agy' | 'claude'
3
- export type Transport = 'console' | 'stdio' | 'pty' | 'agentapi'
3
+ export type Transport = 'console' | 'stdio' | 'pty' | 'agentapi' | 'hook'
4
4
  export type SessionState = 'starting' | 'running' | 'idle' | 'awaiting_user' | 'settling' | 'closed' | 'unknown'
5
5
  export type RequestState = 'queued' | 'sending' | 'submitted' | 'unknown' | 'completed' | 'failed' | 'superseded'
6
6
  export type ConnectionState = 'connected' | 'closed' | 'unreachable' | 'unverified'
@@ -19,6 +19,8 @@ export interface Usage {
19
19
  totalTokens: number
20
20
  providerTotalTokens: number
21
21
  }
22
+ /** 앱 세션의 대화 모드. 기다리는 쪽은 Stop 훅이고, used 는 이 모드로 이어 간 횟수다. */
23
+ export interface DialogueMode { until: string; maxTurns: number; used: number; waitSeconds: number }
22
24
  export interface PendingApproval { id: string | number; method?: string; [key: string]: unknown }
23
25
  export interface SessionRecord {
24
26
  id: string
@@ -46,9 +48,12 @@ export interface SessionRecord {
46
48
  /** 로컬 파이프를 연 `bridge serve`·`run` 프로세스. 비 Windows 생존 확인에 쓴다. */
47
49
  hostPid?: number
48
50
  closedReason?: string
51
+ /** transport 'hook' 만: 대화 모드와 세션 ID 안내 여부 */
52
+ dialogueMode?: DialogueMode
53
+ announced?: boolean
49
54
  }
50
55
  export type PublicSession = Pick<SessionRecord, 'id' | 'provider' | 'workspaces' | 'model' | 'transport' |
51
- 'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId'> & { liveness?: HostLiveness }
56
+ 'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId' | 'dialogueMode'> & { liveness?: HostLiveness }
52
57
  export interface SessionSnapshot extends PublicSession {
53
58
  lastKnownState: SessionState | null
54
59
  state: SessionState
@@ -70,8 +75,12 @@ export interface RequestRecord {
70
75
  completedAt?: string
71
76
  conversationId?: string
72
77
  usage?: Usage | null
78
+ /** 보낸 쪽이 axMap 세션이면 그 ID. 앱 세션이면 회신이 이 세션의 Stop 훅으로 돌아간다. */
79
+ fromSession?: string
80
+ deliveredAt?: string
81
+ returnedAt?: string
73
82
  }
74
- export interface DispatchOptions { to: string; prompt: string; cwd: string; from?: string; dialogueId?: string | null; queueIfBusy?: boolean }
83
+ export interface DispatchOptions { to: string; prompt: string; cwd: string; from?: string; fromSession?: string; dialogueId?: string | null; queueIfBusy?: boolean }
75
84
  export interface HookInput {
76
85
  cwd?: string
77
86
  transcript_path?: string
@@ -311,7 +311,9 @@ export function installTerminalHooks(home = os.homedir(), provider = 'agy') {
311
311
  const hooks = group.hooks.filter(h => h.statusMessage !== 'axMap terminal bridge');
312
312
  return hooks.length ? [{ ...group, hooks }] : [];
313
313
  });
314
- config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: 10, statusMessage: 'axMap terminal bridge' }] });
314
+ // Stop 은 대화 모드에서 상대를 최대 540초 기다린다(src/app-session). 공급자 기본 한도 600초를 그대로 준다.
315
+ // 대화 모드가 꺼져 있으면 지금처럼 바로 끝난다.
316
+ config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: event === 'Stop' ? 600 : 10, statusMessage: 'axMap terminal bridge' }] });
315
317
  }
316
318
  }
317
319
  if (provider === 'agy')
@@ -237,7 +237,9 @@ export function installTerminalHooks(home = os.homedir(), provider = 'agy') {
237
237
  const hooks = group.hooks.filter(h => h.statusMessage !== 'axMap terminal bridge')
238
238
  return hooks.length ? [{ ...group, hooks }] : []
239
239
  })
240
- config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: 10, statusMessage: 'axMap terminal bridge' }] })
240
+ // Stop 은 대화 모드에서 상대를 최대 540초 기다린다(src/app-session). 공급자 기본 한도 600초를 그대로 준다.
241
+ // 대화 모드가 꺼져 있으면 지금처럼 바로 끝난다.
242
+ config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: event === 'Stop' ? 600 : 10, statusMessage: 'axMap terminal bridge' }] })
241
243
  }
242
244
  }
243
245
  if (provider === 'agy') config['axmap-terminal'] = definition
package/tools/bridge.mjs CHANGED
@@ -9,6 +9,11 @@ import { sendTerminal } from '../src/terminal-bridge.mjs'
9
9
  import { sessionStatus, formatSessionStatus } from '../src/session-status.mjs'
10
10
  import { attachConsole } from '../src/external-console.mjs'
11
11
  import { postMail, collectMail, flushMail } from '../src/session-mailbox.mjs'
12
+ import { setDialogueMode } from '../src/app-session.mjs'
13
+
14
+ // Windows PowerShell 5.1 은 파이프로 넘기는 글 앞에 BOM 을 붙이고 줄바꿈을 CRLF 로 보낸다(2026-11-05 실측).
15
+ // 그대로 두면 상대 AI 의 프롬프트 첫 글자에 보이지 않는 문자가 섞인다.
16
+ const stdinText = () => fs.readFileSync(0, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
12
17
 
13
18
  const args = process.argv.slice(2)
14
19
  const [command] = args
@@ -17,7 +22,7 @@ const output = (value) => console.log(JSON.stringify(value, null, 2))
17
22
  try {
18
23
  switch (command) {
19
24
  case 'message': {
20
- const r = postMail({ to: option('to'), cwd: process.cwd(), text: fs.readFileSync(0, 'utf8'),
25
+ const r = postMail({ to: option('to'), cwd: process.cwd(), text: stdinText(),
21
26
  priority: option('priority') || 'boundary', reason: option('reason'), topic: option('topic'), key: option('key'),
22
27
  ttlSeconds: Number(option('ttl-seconds') || 3600) })
23
28
  output(r)
@@ -51,7 +56,7 @@ try {
51
56
  break
52
57
  }
53
58
  case 'chat': {
54
- const options = { ...(option('id') ? { id: option('id') } : {}), left: option('left'), right: option('right'), prompt: fs.readFileSync(0, 'utf8'),
59
+ const options = { ...(option('id') ? { id: option('id') } : {}), left: option('left'), right: option('right'), prompt: stdinText(),
55
60
  maxTurns: Number(option('max-turns') || 4), maxTokens: Number(option('max-tokens') || 50000),
56
61
  maxMessageChars: Number(option('max-message-chars') || 2000),
57
62
  ...(option('timeout-ms') ? { timeoutMs: Number(option('timeout-ms')) } : {}),
@@ -63,7 +68,7 @@ try {
63
68
  break
64
69
  }
65
70
  case 'dialogue': output(dialogueResult(args[1])); break
66
- case 'notice': output(await sendTerminal(option('to'), { action: 'notice', text: fs.readFileSync(0, 'utf8') })); break
71
+ case 'notice': output(await sendTerminal(option('to'), { action: 'notice', text: stdinText() })); break
67
72
  case 'stop': output(stopDialogue(args[1])); break
68
73
  case 'answer': output(await sendTerminal(option('to'), { action: 'answer', id: option('request'), decision: option('decision') })); break
69
74
  case 'install':
@@ -80,6 +85,14 @@ try {
80
85
  break
81
86
  }
82
87
  case 'sessions': output(sessions(process.cwd())); break
88
+ case 'mode': {
89
+ const on = args.includes('--on'), off = args.includes('--off')
90
+ if (on === off) throw new Error('bridge mode --session <UUID> --on|--off 중 하나를 주세요.')
91
+ const num = name => option(name) === undefined ? undefined : Number(option(name))
92
+ output(setDialogueMode({ session: option('session'), cwd: process.cwd(), on,
93
+ ...[['minutes', num('minutes')], ['maxTurns', num('max-turns')], ['waitSeconds', num('wait-seconds')]].reduce((o, [k, v]) => v === undefined ? o : { ...o, [k]: v }, {}) }))
94
+ break
95
+ }
83
96
  case 'status': {
84
97
  const listed = await sessionStatus()
85
98
  if (args.includes('--json')) output(listed)
@@ -89,7 +102,7 @@ try {
89
102
  case 'send': {
90
103
  const waitMs = Number(option('wait-ms') || 0)
91
104
  if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 30000) throw new Error('wait-ms는 0~30000 정수여야 합니다.')
92
- const r = dispatchRequest({ to: option('to'), prompt: fs.readFileSync(0, 'utf8'), cwd: process.cwd(), from: process.env.AXMAP_AGENT || 'axmap', queueIfBusy: !args.includes('--no-queue') })
105
+ const r = dispatchRequest({ to: option('to'), prompt: stdinText(), cwd: process.cwd(), from: process.env.AXMAP_AGENT || 'axmap', ...(option('from-session') ? { fromSession: option('from-session') } : {}), queueIfBusy: !args.includes('--no-queue') })
93
106
  const result = waitMs && ['queued', 'submitted'].includes(r.state) ? await waitForRequest(r.id, process.cwd(), waitMs) : r
94
107
  output(result)
95
108
  if (result.state === 'unknown') process.exitCode = 2
@@ -131,6 +144,9 @@ axmap bridge mailbox --to <UUID> [--all]
131
144
  읽기만 합니다. --take --at checkpoint로 작업 경계에서 묶음 회수 (모델 호출 없음).
132
145
  axmap bridge flush --to <UUID> --at task-complete|test-complete|experiment-complete|checkpoint
133
146
  필요할 때만 idle 수신자에게 큐를 한 요청으로 전달. 자동 감시·중단·재전송 없음.
147
+ axmap bridge mode --session <앱 세션 UUID> --on|--off [--minutes 30] [--max-turns 8] [--wait-seconds 540]
148
+ 앱(Claude 앱·Codex 앱)·codex 세션의 대화 모드. 켜면 응답이 끝날 때 상대 요청·회신을 기다렸다가 같은 대화에서 이어 갑니다.
149
+ axmap bridge send ... --from-session <내 세션 UUID> 상대가 앱 세션이면 그 답이 내 세션으로 돌아옵니다(내가 대화 모드일 때)
134
150
  axmap bridge result <요청 UUID> [--wait-ms 30000] 제출/완료/불명 상태와 결과. 대기 중 모델 호출 없음
135
151
  관리형 stdio: Codex App Server / agy stream-json. PTY와 agentapi는 별도 실험 경로입니다.`)
136
152
  break
package/tools/run.mjs CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { startTerminal, sendTerminal, installTerminalHooks, installTerminalRuntime } from '../src/terminal-bridge.mjs'
6
6
  import { registerExternalHook } from '../src/external-console.mjs'
7
+ import { handleAppHook } from '../src/app-session.mjs'
7
8
  import { RPC_NOT_DELIVERED_EXIT } from '../src/session-types.mjs'
8
9
 
9
10
  const [command, ...args] = process.argv.slice(2)
@@ -22,13 +23,24 @@ try {
22
23
  }
23
24
  } else if (command === 'hook') {
24
25
  const input = JSON.parse(fs.readFileSync(0, 'utf8'))
26
+ let output = {}
25
27
  if (process.env.AXMAP_TERMINAL_SESSION && process.env.AXMAP_TERMINAL_TOKEN) {
26
28
  await sendTerminal(process.env.AXMAP_TERMINAL_SESSION, { action: 'hook', event: args[0], input }, process.env.AXMAP_TERMINAL_TOKEN)
27
- } else if (process.platform === 'win32' && (input.conversationId || input.session_id)) {
28
- try { registerExternalHook(input, args[0], args.includes('--provider') ? args[args.indexOf('--provider') + 1] : undefined) }
29
- catch (e) { console.error(`axMap 독립 콘솔: ${e.message}`) }
29
+ } else if (input.conversationId || input.session_id) {
30
+ const provider = args.includes('--provider') ? args[args.indexOf('--provider') + 1] : undefined
31
+ // 콘솔 창이 있는 Windows agy·Claude 는 지금처럼 창에 입력하는 경로를 쓴다.
32
+ // 창이 없으면(앱, codex) 훅만으로 받는 앱 세션 경로로 간다.
33
+ let viaConsole = false
34
+ if (process.platform === 'win32' && provider !== 'codex') {
35
+ try { viaConsole = !!registerExternalHook(input, args[0], provider).id }
36
+ catch (e) { console.error(`axMap 독립 콘솔: ${e.message}`) }
37
+ }
38
+ if (!viaConsole && ['claude', 'codex'].includes(provider)) {
39
+ try { output = handleAppHook(input, args[0], provider) }
40
+ catch (e) { console.error(`axMap 앱 세션: ${e.message}`) }
41
+ }
30
42
  }
31
- console.log('{}')
43
+ console.log(JSON.stringify(output))
32
44
  } else if (command === '--help' || !command) {
33
45
  console.log('axmap run agy|codex [기존 CLI 인수...] [--cwd 작업폴더]\n사용자가 여는 실제 CLI 터미널을 유지합니다. 별도 연결 URL은 필요 없습니다.')
34
46
  } else {