axmap-cli 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +33 -0
- package/README.md +33 -0
- package/mcp/server.mjs +17 -3
- package/package.json +1 -1
- package/src/app-session.mjs +324 -0
- package/src/app-session.mts +294 -0
- package/src/bridge.mjs +167 -12
- package/src/bridge.mts +117 -11
- package/src/external-console.mjs +45 -12
- package/src/external-console.mts +44 -14
- package/src/managed-session.mjs +101 -12
- package/src/managed-session.mts +80 -15
- package/src/monitor.mjs +3 -3
- package/src/monitor.mts +3 -3
- package/src/pipeline-mcp.mjs +7 -4
- package/src/pipeline-mcp.mts +6 -4
- package/src/session-dialogue.mjs +21 -5
- package/src/session-dialogue.mts +22 -5
- package/src/session-status.mjs +10 -1
- package/src/session-status.mts +10 -1
- package/src/session-types.mts +20 -7
- package/src/session-usage.mjs +16 -0
- package/src/session-usage.mts +17 -1
- package/src/terminal-bridge.mjs +19 -9
- package/src/terminal-bridge.mts +19 -10
- package/tools/bridge.mjs +33 -9
- package/tools/console-queue.mjs +3 -0
- package/tools/run.mjs +17 -5
- package/tools/setup.mjs +1 -1
- package/tools/windows-console.cs +24 -8
- package/tools/windows-console.ps1 +14 -5
package/README.ko.md
CHANGED
|
@@ -31,6 +31,39 @@ 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
|
+
|
|
51
|
+
### 1.10.0
|
|
52
|
+
|
|
53
|
+
<sub>2026.11.03</sub>
|
|
54
|
+
|
|
55
|
+
**일반 Windows Claude Code 터미널을 연결하고, 상대가 준비될 때까지 실행 요청을 큐에 보관합니다.**
|
|
56
|
+
|
|
57
|
+
- `axmap setup` 후 Git 저장소에서 평소처럼 `claude`를 실행하면 원래 프로세스와 대화가 자동 등록됩니다. `ax_dispatch` / `ax_result`로 새 모델 프로세스 없이 요청·응답을 주고받습니다.
|
|
58
|
+
- 관리형 Claude Code·Codex·agy와 일반 Windows Claude 콘솔에서 실행 요청을 FIFO로 접수합니다. 입력 초안과 승인 화면을 유지하며, 큐 확인과 대기 중에는 모델을 호출하지 않습니다. AI 답변 생성에는 기존처럼 토큰이 사용됩니다.
|
|
59
|
+
- `bridge serve claude`의 지속 연결을 지원합니다. 자동 대화에서 코드 블록에 감싼 JSON 응답, 실행 시간 제한과 `--done-by` 종료 주체 지정을 제공합니다.
|
|
60
|
+
- 확정 미전달과 전달 불명을 구별하고 불명 요청은 자동 재전송하지 않습니다. 대기 조회가 큐에서 전송 중으로 넘어가는 순간에도 완료까지 기다립니다.
|
|
61
|
+
|
|
62
|
+
Windows의 실제 일반 Claude Code 터미널과 Codex 요청·응답으로 검증했습니다. 일반 콘솔 연결은 Windows 범위이며, 토큰 예산을 검사하는 `ax_chat`은 관리형 stdio 세션을 사용합니다. 앱의 도구 승인 절차는 유지합니다.
|
|
63
|
+
[연결 안내 →](docs/SESSION-BRIDGE.md)
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
34
67
|
### 1.9.0
|
|
35
68
|
|
|
36
69
|
<sub>2026.10.29</sub>
|
package/README.md
CHANGED
|
@@ -31,6 +31,39 @@ 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
|
+
|
|
51
|
+
### 1.10.0
|
|
52
|
+
|
|
53
|
+
<sub>November 3, 2026</sub>
|
|
54
|
+
|
|
55
|
+
**Connect ordinary Windows Claude Code terminals and queue execution requests until the recipient is ready.**
|
|
56
|
+
|
|
57
|
+
- After `axmap setup`, launch `claude` normally in a Git repository. Hooks register the original process and conversation; `ax_dispatch` / `ax_result` deliver requests and collect replies without starting another model process.
|
|
58
|
+
- Managed Claude Code, Codex and agy sessions, plus ordinary Windows Claude consoles, accept FIFO execution queues. Input drafts and approval screens are preserved. Waiting and queue handling make no model calls; generating a response still uses provider tokens.
|
|
59
|
+
- `bridge serve claude` supports persistent stream-json conversations. Automatic dialogue accepts fenced JSON responses and provides execution timeouts and `--done-by` termination control.
|
|
60
|
+
- Requests that were not delivered remain distinct from uncertain delivery. Unknown requests are never automatically replayed. Wait operations now follow the queued-to-sending handoff through completion.
|
|
61
|
+
|
|
62
|
+
Validated with real Claude Code terminals and Codex request/reply exchanges on Windows. Ordinary console attachment is Windows-only; token-budgeted `ax_chat` continues to require managed stdio sessions. Tool approvals remain under the host's control.
|
|
63
|
+
[Connection guide →](docs/SESSION-BRIDGE.md)
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
34
67
|
### 1.9.0
|
|
35
68
|
|
|
36
69
|
<sub>October 29, 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'
|
|
@@ -508,13 +508,24 @@ const TOOLS = [
|
|
|
508
508
|
},
|
|
509
509
|
{
|
|
510
510
|
name: 'ax_dispatch',
|
|
511
|
-
description: '명시적으로 상대의 실행 턴이 필요한 작업용
|
|
511
|
+
description: '명시적으로 상대의 실행 턴이 필요한 작업용 전달이다. 관리형 Claude Code·Codex·agy와 Windows 일반 Claude Code(console)는 대기 중 실행하고 작업·입력·승인 대기 중이면 FIFO 큐에 접수해 idle에서 자동 실행한다. 기존 맥락과 프로세스를 유지한다. queued/submitted는 완료가 아니며 ax_result로 확인한다. unknown은 재전송하지 않는다. 일반 진행 알림에는 새 턴 없는 ax_message를 사용한다. 작업 중인 세션을 강제 중단하지 않는다.',
|
|
512
512
|
inputSchema: { type: 'object', properties: {
|
|
513
513
|
to: { type: 'string', description: 'ax_sessions에서 확인한 상대 세션 UUID' },
|
|
514
514
|
prompt: { type: 'string', description: '상대가 실행할 작업 요청' },
|
|
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
|
+
}
|