axmap-cli 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +17 -0
- package/README.md +17 -0
- package/mcp/server.mjs +16 -2
- package/package.json +1 -1
- package/src/app-session.mjs +324 -0
- package/src/app-session.mts +294 -0
- package/src/bridge.mjs +19 -4
- package/src/bridge.mts +16 -4
- package/src/pipeline-mcp.mjs +4 -1
- package/src/pipeline-mcp.mts +3 -1
- package/src/session-status.mjs +10 -1
- package/src/session-status.mts +10 -1
- package/src/session-types.mts +12 -3
- package/src/terminal-bridge.mjs +3 -1
- package/src/terminal-bridge.mts +3 -1
- package/tools/bridge.mjs +20 -4
- package/tools/run.mjs +16 -4
package/README.ko.md
CHANGED
|
@@ -31,6 +31,23 @@ axmap setup
|
|
|
31
31
|
|
|
32
32
|
최신 변경부터 정리합니다.
|
|
33
33
|
|
|
34
|
+
### 1.11.0
|
|
35
|
+
|
|
36
|
+
<sub>2026.11.05</sub>
|
|
37
|
+
|
|
38
|
+
**Claude 데스크톱 앱과 Codex 앱이 대화 모드로 서로 일을 주고받습니다.**
|
|
39
|
+
|
|
40
|
+
- 콘솔 창이 없는 앱 세션(Claude 데스크톱 앱의 Code 탭, Codex 앱, axMap 래퍼 없이 연 `codex`)을 공급자 자신의 훅이 대화 ID로 등록합니다.
|
|
41
|
+
- 각 앱에 대화 모드를 켜 달라고 하고(`ax_dialogue_mode`), `ax_dispatch(to, from_session)`로 보냅니다. 받는 쪽의 응답이 끝나면 응답 끝(Stop) 훅이 그 요청으로 같은 대화를 이어 가고, 마지막 답이 결과가 되어 보낸 쪽의 다음 응답 끝에 돌아갑니다. 기다리는 동안 모델을 부르지 않습니다.
|
|
42
|
+
- 대화 모드는 직접 켤 때만 동작하며 시간(기본 30분), 이어 가기 횟수(기본 8회), 한 번의 기다림(최대 540초)으로 제한됩니다. 사람이 입력하면 진행 중이던 요청은 밀려나고, 모드를 끄면 아직 전달하지 않은 요청은 실패로 기록됩니다.
|
|
43
|
+
- 쉬고 있는 앱을 밖에서 깨우지 않습니다. 대화 모드를 켠 뒤 상대 앱에 한마디 걸어 기다림을 시작시키세요.
|
|
44
|
+
- `axmap setup`을 다시 실행해야 Stop 훅 제한 시간 600초가 적용됩니다. Codex가 바뀐 훅의 신뢰 확인을 요구할 수 있으며 axMap은 이를 우회하지 않습니다.
|
|
45
|
+
|
|
46
|
+
Windows에서 실제 Claude 데스크톱 앱과 Codex 앱 사이의 왕복과, 사람 입력 없는 2회차 왕복으로 검증했습니다. 앱 세션의 토큰 사용량은 측정하지 않으므로 `ax_chat`의 예산 한도는 적용되지 않습니다.
|
|
47
|
+
[연결 안내 →](docs/SESSION-BRIDGE.md)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
34
51
|
### 1.10.0
|
|
35
52
|
|
|
36
53
|
<sub>2026.11.03</sub>
|
package/README.md
CHANGED
|
@@ -31,6 +31,23 @@ 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.0
|
|
35
|
+
|
|
36
|
+
<sub>November 5, 2026</sub>
|
|
37
|
+
|
|
38
|
+
**Claude and Codex desktop apps can now hand work back and forth with dialogue mode.**
|
|
39
|
+
|
|
40
|
+
- 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.
|
|
41
|
+
- 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.
|
|
42
|
+
- 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.
|
|
43
|
+
- An idle app is never woken from outside. Say one thing to the other app after turning dialogue mode on so its wait begins.
|
|
44
|
+
- 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.
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
[Connection guide →](docs/SESSION-BRIDGE.md)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
34
51
|
### 1.10.0
|
|
35
52
|
|
|
36
53
|
<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)
|
|
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.
|
|
4
|
+
"version": "1.11.0",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
|
@@ -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)
|
package/src/pipeline-mcp.mjs
CHANGED
|
@@ -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
|
-
|
|
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')
|
package/src/pipeline-mcp.mts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/session-status.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/session-status.mts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/session-types.mts
CHANGED
|
@@ -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
|
package/src/terminal-bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
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')
|
package/src/terminal-bridge.mts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 (
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|