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
|
@@ -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
|
@@ -5,10 +5,11 @@ import fs from 'node:fs';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { withRepoLock } from './repolock.mjs';
|
|
11
11
|
import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs';
|
|
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;
|
|
@@ -100,6 +101,24 @@ function runAPI(session, args) {
|
|
|
100
101
|
return null;
|
|
101
102
|
}
|
|
102
103
|
function transcript(session) {
|
|
104
|
+
if (session.provider === 'claude') {
|
|
105
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
106
|
+
const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..';
|
|
107
|
+
if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`)
|
|
108
|
+
throw new Error('Claude transcript identity mismatch');
|
|
109
|
+
if (!fs.existsSync(session.transcript))
|
|
110
|
+
return [];
|
|
111
|
+
return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
|
|
112
|
+
const entry = JSON.parse(line);
|
|
113
|
+
if (entry.sessionId && entry.sessionId !== session.conversationId)
|
|
114
|
+
return [];
|
|
115
|
+
const content = entry.message?.content;
|
|
116
|
+
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : '';
|
|
117
|
+
if (!text || !['user', 'assistant'].includes(entry.type || ''))
|
|
118
|
+
return [];
|
|
119
|
+
return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
103
122
|
const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl');
|
|
104
123
|
if (session.transcript !== expected)
|
|
105
124
|
throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.');
|
|
@@ -116,6 +135,7 @@ function publicSession(s, liveness) {
|
|
|
116
135
|
// 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
|
|
117
136
|
fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
|
|
118
137
|
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
|
|
138
|
+
...(s.dialogueMode ? { dialogueMode: s.dialogueMode } : {}),
|
|
119
139
|
...(liveness ? { liveness } : {}) };
|
|
120
140
|
}
|
|
121
141
|
/** 관리형 세션(pty·stdio)의 로컬 제어 파이프. 세션 호스트가 살아 있는 동안만 존재한다. */
|
|
@@ -203,7 +223,7 @@ function sessionFor(id, cwd) {
|
|
|
203
223
|
const s = readJSON(file('sessions', id));
|
|
204
224
|
if (!s.repos.includes(repoKey(cwd)))
|
|
205
225
|
throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.');
|
|
206
|
-
if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || ''))
|
|
226
|
+
if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio', 'hook'].includes(s.transport || ''))
|
|
207
227
|
throw new Error('아직 지원하지 않는 수신 세션입니다.');
|
|
208
228
|
return s;
|
|
209
229
|
}
|
|
@@ -235,16 +255,35 @@ export function requestResult(id, cwd) {
|
|
|
235
255
|
const request = readJSON(target);
|
|
236
256
|
if (request.repo !== repoKey(cwd))
|
|
237
257
|
throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.');
|
|
238
|
-
if (!['submitted', 'unknown'].includes(request.state))
|
|
258
|
+
if (!['queued', 'submitted', 'unknown'].includes(request.state))
|
|
239
259
|
return request;
|
|
240
260
|
let s = sessionFor(request.to, cwd);
|
|
261
|
+
// 앱 세션의 요청 상태는 받는 쪽 훅이 직접 적는다. 여기서 추측하지 않는다.
|
|
262
|
+
if (s.transport === 'hook')
|
|
263
|
+
return request;
|
|
264
|
+
if (request.state === 'queued' && s.transport === 'console')
|
|
265
|
+
return request;
|
|
241
266
|
if (['pty', 'stdio'].includes(s.transport || '')) {
|
|
242
267
|
try {
|
|
243
268
|
Object.assign(request, terminalRPC(s.id, { action: 'result', id }));
|
|
244
269
|
}
|
|
245
270
|
catch (e) {
|
|
246
|
-
|
|
247
|
-
|
|
271
|
+
const saved = path.join(bridgeHome(), 'jobs', `${id}.json`);
|
|
272
|
+
// A normal host exit persists outcomes before closing the pipe. Never replay
|
|
273
|
+
// a queued job after a crash or mistake an unexecuted job for completion.
|
|
274
|
+
if (s.transport === 'stdio' && fs.existsSync(saved)) {
|
|
275
|
+
const job = readJSON(saved);
|
|
276
|
+
if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state))
|
|
277
|
+
Object.assign(request, job);
|
|
278
|
+
else {
|
|
279
|
+
request.state = 'unknown';
|
|
280
|
+
request.error = errorMessage(e);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
request.state = 'unknown';
|
|
285
|
+
request.error = errorMessage(e);
|
|
286
|
+
}
|
|
248
287
|
}
|
|
249
288
|
writeJSON(target, request);
|
|
250
289
|
return request;
|
|
@@ -292,7 +331,7 @@ export async function waitForRequest(id, cwd, timeoutMs = 30000) {
|
|
|
292
331
|
const deadline = performance.now() + timeoutMs;
|
|
293
332
|
for (;;) {
|
|
294
333
|
const result = requestResult(id, cwd);
|
|
295
|
-
if (
|
|
334
|
+
if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline)
|
|
296
335
|
return result;
|
|
297
336
|
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))));
|
|
298
337
|
}
|
|
@@ -302,7 +341,7 @@ export function sessionLeasePath(id) {
|
|
|
302
341
|
throw new Error('세션 ID는 UUID여야 합니다.');
|
|
303
342
|
return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`);
|
|
304
343
|
}
|
|
305
|
-
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }) {
|
|
344
|
+
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', fromSession, dialogueId = null, queueIfBusy = false }) {
|
|
306
345
|
// 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
|
|
307
346
|
// 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
|
|
308
347
|
let sending = false;
|
|
@@ -315,15 +354,45 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
315
354
|
throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.');
|
|
316
355
|
const dir = path.join(bridgeHome(), 'requests');
|
|
317
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
|
+
}
|
|
367
|
+
if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
|
|
368
|
+
validateConsolePrompt(prompt, from);
|
|
369
|
+
if (s.state === 'closed')
|
|
370
|
+
throw new Error('Claude terminal is closed');
|
|
371
|
+
const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
372
|
+
const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON(path.join(dir, n)))
|
|
373
|
+
.filter(r => r.to === to && r.state === 'queued');
|
|
374
|
+
if (pending.length >= 100)
|
|
375
|
+
throw new Error('Claude command queue is full');
|
|
376
|
+
const request = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' };
|
|
377
|
+
writeJSON(file('requests', request.id), request);
|
|
378
|
+
return request;
|
|
379
|
+
});
|
|
380
|
+
const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' });
|
|
381
|
+
worker.on('error', () => { });
|
|
382
|
+
worker.unref();
|
|
383
|
+
return request;
|
|
384
|
+
}
|
|
318
385
|
return withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
319
386
|
if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
|
|
320
387
|
throw new Error('이 세션은 자동 대화가 사용 중입니다.');
|
|
321
388
|
for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
|
|
322
389
|
const previous = readJSON(path.join(dir, n));
|
|
323
|
-
if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state))
|
|
390
|
+
if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state))
|
|
324
391
|
continue;
|
|
325
392
|
const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd);
|
|
326
|
-
if (
|
|
393
|
+
if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state))
|
|
394
|
+
continue;
|
|
395
|
+
if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state))
|
|
327
396
|
throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`);
|
|
328
397
|
}
|
|
329
398
|
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
@@ -337,16 +406,16 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
337
406
|
if (!s.fullyIdle || s.event !== 'Stop')
|
|
338
407
|
throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.');
|
|
339
408
|
}
|
|
340
|
-
const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
|
|
409
|
+
const request = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt,
|
|
341
410
|
createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' };
|
|
342
411
|
const target = file('requests', request.id);
|
|
343
412
|
writeJSON(target, request);
|
|
344
413
|
sending = true;
|
|
345
414
|
try {
|
|
346
415
|
request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
|
|
347
|
-
? terminalRPC(to, { action: 'submit', request })
|
|
416
|
+
? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
|
|
348
417
|
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`]);
|
|
349
|
-
request.state = 'submitted';
|
|
418
|
+
request.state = request.receipt?.queued ? 'queued' : 'submitted';
|
|
350
419
|
}
|
|
351
420
|
catch (e) {
|
|
352
421
|
if (notDelivered(e)) {
|
|
@@ -445,3 +514,89 @@ export function installHooks({ executable, home = os.homedir() }) {
|
|
|
445
514
|
return { hooks: target, backup: original === null ? null : backup,
|
|
446
515
|
note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' };
|
|
447
516
|
}
|
|
517
|
+
/** One local worker per ordinary Claude terminal; no provider is launched. */
|
|
518
|
+
export function runConsoleQueue(to, cwd) {
|
|
519
|
+
const dir = path.join(bridgeHome(), 'requests');
|
|
520
|
+
const runnerLock = path.join(dir, `${to}.runner.lock`);
|
|
521
|
+
if (fs.existsSync(runnerLock)) {
|
|
522
|
+
const owner = readJSON(runnerLock);
|
|
523
|
+
try {
|
|
524
|
+
process.kill(owner.pid, 0);
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
if (errorCode(e) === 'ESRCH')
|
|
528
|
+
fs.unlinkSync(runnerLock);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
withRepoLock(runnerLock, () => {
|
|
533
|
+
for (;;) {
|
|
534
|
+
const records = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
|
|
535
|
+
.filter(n => n.endsWith('.json')).map(n => readJSON(path.join(dir, n)))
|
|
536
|
+
.filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
|
|
537
|
+
const queued = records.filter(r => r.state === 'queued');
|
|
538
|
+
if (!queued.length)
|
|
539
|
+
return;
|
|
540
|
+
let waiting = false;
|
|
541
|
+
for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
|
|
542
|
+
const latest = r.state === 'sending' ? r : requestResult(r.id, cwd);
|
|
543
|
+
if (['unknown', 'sending'].includes(latest.state))
|
|
544
|
+
return;
|
|
545
|
+
if (latest.state === 'submitted')
|
|
546
|
+
waiting = true;
|
|
547
|
+
}
|
|
548
|
+
const s = sessionFor(to, cwd);
|
|
549
|
+
if (s.pid) {
|
|
550
|
+
try {
|
|
551
|
+
process.kill(s.pid, 0);
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
if (errorCode(e) === 'ESRCH') {
|
|
555
|
+
for (const r of queued)
|
|
556
|
+
writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (s.state === 'closed') {
|
|
562
|
+
for (const r of queued)
|
|
563
|
+
writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
let live;
|
|
567
|
+
try {
|
|
568
|
+
live = checkConsole(s);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
waiting = true;
|
|
572
|
+
}
|
|
573
|
+
if (waiting || !live) {
|
|
574
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
578
|
+
const r = readJSON(file('requests', queued[0].id));
|
|
579
|
+
if (r.state !== 'queued')
|
|
580
|
+
return;
|
|
581
|
+
r.baseline = transcript(s).length;
|
|
582
|
+
r.state = 'sending';
|
|
583
|
+
writeJSON(file('requests', r.id), r);
|
|
584
|
+
try {
|
|
585
|
+
r.receipt = submitConsole(s, r, live);
|
|
586
|
+
r.state = 'submitted';
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
r.state = notDelivered(e) ? 'queued' : 'unknown';
|
|
590
|
+
r.error = errorMessage(e);
|
|
591
|
+
}
|
|
592
|
+
writeJSON(file('requests', r.id), r);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}, 1000);
|
|
596
|
+
}
|
|
597
|
+
catch (e) {
|
|
598
|
+
// Another worker won the runner lease. It owns draining; never replay here.
|
|
599
|
+
if (!fs.existsSync(runnerLock))
|
|
600
|
+
throw e;
|
|
601
|
+
}
|
|
602
|
+
}
|