axmap-cli 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/ax-update.md +0 -3
- package/README.ko.md +31 -0
- package/README.md +31 -0
- package/bin/axmap.mjs +149 -88
- package/governance/vote.mjs +6 -69
- package/mcp/README.md +2 -0
- package/mcp/server.mjs +23 -8
- package/package.json +1 -1
- package/src/bridge.mjs +313 -61
- package/src/bridge.mts +208 -22
- package/src/external-console.mjs +45 -12
- package/src/external-console.mts +44 -14
- package/src/managed-session.mjs +105 -15
- package/src/managed-session.mts +84 -18
- package/src/monitor.mjs +3 -3
- package/src/monitor.mts +3 -3
- package/src/pipeline-mcp.mjs +4 -4
- package/src/pipeline-mcp.mts +4 -4
- package/src/session-dialogue.mjs +21 -5
- package/src/session-dialogue.mts +22 -5
- package/src/session-mailbox.mjs +21 -11
- package/src/session-mailbox.mts +13 -8
- package/src/session-types.mjs +3 -0
- package/src/session-types.mts +18 -6
- package/src/session-usage.mjs +16 -0
- package/src/session-usage.mts +17 -1
- package/src/terminal-bridge.mjs +29 -15
- package/src/terminal-bridge.mts +28 -16
- package/src/update.mjs +3 -20
- package/src/workspace.mjs +247 -0
- package/src/workspace.mts +220 -0
- package/tools/bridge.mjs +14 -6
- package/tools/build.mjs +1 -1
- package/tools/bus.mjs +1 -1
- package/tools/console-queue.mjs +3 -0
- package/tools/run.mjs +11 -4
- package/tools/setup.mjs +1 -1
- package/tools/windows-console.cs +24 -8
- package/tools/windows-console.ps1 +14 -5
- package/src/closure.mjs +0 -343
- package/tools/vendor.mjs +0 -382
package/src/bridge.mts
CHANGED
|
@@ -4,11 +4,13 @@ import fs from 'node:fs'
|
|
|
4
4
|
import os from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
|
-
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { withRepoLock } from './repolock.mjs'
|
|
10
10
|
import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs'
|
|
11
|
-
import { errorCode, errorMessage } from './session-types.mjs'
|
|
11
|
+
import { errorCode, errorMessage, RPC_NOT_DELIVERED_EXIT } from './session-types.mjs'
|
|
12
|
+
import { commonKey, resolveTarget } from './workspace.mjs'
|
|
13
|
+
import type { HostLiveness } from './session-types.mjs'
|
|
12
14
|
import type { SessionRecord, PublicSession, RequestRecord, DispatchOptions, HookInput, TranscriptEntry } from './session-types.mjs'
|
|
13
15
|
|
|
14
16
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
@@ -37,13 +39,21 @@ function writeJSON(p: string, data: unknown): void {
|
|
|
37
39
|
}
|
|
38
40
|
} finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp) }
|
|
39
41
|
}
|
|
40
|
-
function
|
|
42
|
+
function commonDirKey(cwd: string): string | null {
|
|
41
43
|
const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
42
44
|
cwd, encoding: 'utf8', windowsHide: true,
|
|
43
45
|
})
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
return r.status === 0 ? commonKey(fs.realpathSync(r.stdout.trim())) : null
|
|
47
|
+
}
|
|
48
|
+
function repoKey(cwd: string): string {
|
|
49
|
+
// Git 안이면 지금까지와 같은 한 번의 git 호출로 끝낸다. 실패했을 때만 작업 폴더
|
|
50
|
+
// 규칙(수동 설정 → 자식 저장소 자동 탐색 → 모호하면 오류)으로 대상 저장소를 정한다.
|
|
51
|
+
const direct = commonDirKey(cwd)
|
|
52
|
+
if (direct) return direct
|
|
53
|
+
const repo = resolveTarget(cwd)
|
|
54
|
+
const key = repo && commonDirKey(repo)
|
|
55
|
+
if (!key) throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`)
|
|
56
|
+
return key
|
|
47
57
|
}
|
|
48
58
|
function addressError(address: unknown): asserts address is string {
|
|
49
59
|
// Only a loopback address exported by a local provider hook is accepted.
|
|
@@ -72,6 +82,20 @@ function runAPI(session: SessionRecord, args: string[]): unknown {
|
|
|
72
82
|
return null
|
|
73
83
|
}
|
|
74
84
|
function transcript(session: SessionRecord): TranscriptEntry[] {
|
|
85
|
+
if (session.provider === 'claude') {
|
|
86
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
87
|
+
const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..'
|
|
88
|
+
if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`) throw new Error('Claude transcript identity mismatch')
|
|
89
|
+
if (!fs.existsSync(session.transcript)) return []
|
|
90
|
+
return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
|
|
91
|
+
const entry = JSON.parse(line) as { type?: string; sessionId?: string; message?: { content?: string | Array<{ type: string; text?: string }> } }
|
|
92
|
+
if (entry.sessionId && entry.sessionId !== session.conversationId) return []
|
|
93
|
+
const content = entry.message?.content
|
|
94
|
+
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : ''
|
|
95
|
+
if (!text || !['user', 'assistant'].includes(entry.type || '')) return []
|
|
96
|
+
return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }]
|
|
97
|
+
})
|
|
98
|
+
}
|
|
75
99
|
const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
76
100
|
if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
|
|
77
101
|
const lines = fs.readFileSync(expected, 'utf8').split('\n')
|
|
@@ -79,24 +103,82 @@ function transcript(session: SessionRecord): TranscriptEntry[] {
|
|
|
79
103
|
lines.pop()
|
|
80
104
|
return lines.filter(s => s.trim()).map(s => JSON.parse(s) as TranscriptEntry)
|
|
81
105
|
}
|
|
82
|
-
function publicSession(s: SessionRecord): PublicSession {
|
|
106
|
+
function publicSession(s: SessionRecord, liveness?: HostLiveness): PublicSession {
|
|
83
107
|
return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
|
|
84
108
|
conversationId: s.conversationId,
|
|
85
109
|
transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
|
|
86
|
-
registeredAt: s.registeredAt, event: s.event,
|
|
87
|
-
|
|
110
|
+
registeredAt: s.registeredAt, event: s.event,
|
|
111
|
+
// 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
|
|
112
|
+
fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
|
|
113
|
+
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
|
|
114
|
+
...(liveness ? { liveness } : {}) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 관리형 세션(pty·stdio)의 로컬 제어 파이프. 세션 호스트가 살아 있는 동안만 존재한다. */
|
|
118
|
+
export const hostPipe = (id: string) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
|
|
119
|
+
|
|
120
|
+
/** Windows 의 열린 파이프 이름 목록. 읽지 못하면 오류 자체를 돌려준다 —
|
|
121
|
+
* "목록에 없음"과 "목록을 못 읽음"을 같은 것으로 보면 산 세션을 닫힘으로 적는다. */
|
|
122
|
+
function openPipes(): Set<string> | Error {
|
|
123
|
+
try { return new Set(fs.readdirSync('\\\\.\\pipe\\').map(n => n.toLowerCase())) }
|
|
124
|
+
catch (e) { return e instanceof Error ? e : new Error(String(e)) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 저장된 상태가 아니라 호스트가 실제로 살아 있는지. 연결 요청은 보내지 않는다.
|
|
129
|
+
*
|
|
130
|
+
* `bridge serve` 가 강제 종료되면 종료 처리(state: closed)를 쓰지 못해 기록이
|
|
131
|
+
* 영원히 `idle` 로 남는다 (실측: 세션 d3cde187, 파이프 없음). 파이프는 호스트가
|
|
132
|
+
* listen 한 뒤에만 기록을 쓰므로, 파이프가 없다는 것은 호스트가 끝났다는 뜻이다.
|
|
133
|
+
* Windows : 파이프 이름 목록에 없으면 dead, 목록을 못 읽으면 unverified
|
|
134
|
+
* 그 밖 : 소켓 파일이 없으면 dead. 있으면 기록된 호스트 pid 로 판단하고,
|
|
135
|
+
* pid 가 없으면(이전 버전 기록) unverified — 남은 소켓 파일만으로는 모른다.
|
|
136
|
+
*/
|
|
137
|
+
function hostLiveness(s: SessionRecord, pipes: () => Set<string> | Error): HostLiveness {
|
|
138
|
+
if (process.platform === 'win32') {
|
|
139
|
+
const listed = pipes()
|
|
140
|
+
if (listed instanceof Error) return 'unverified'
|
|
141
|
+
return listed.has(`axmap-${s.id}`.toLowerCase()) ? 'alive' : 'dead'
|
|
142
|
+
}
|
|
143
|
+
try { fs.statSync(hostPipe(s.id)) } catch (e) { return errorCode(e) === 'ENOENT' ? 'dead' : 'unverified' }
|
|
144
|
+
if (!Number.isSafeInteger(s.hostPid) || (s.hostPid as number) <= 0) return 'unverified'
|
|
145
|
+
try { process.kill(s.hostPid as number, 0); return 'alive' } catch (e) {
|
|
146
|
+
return errorCode(e) === 'ESRCH' ? 'dead' : errorCode(e) === 'EPERM' ? 'alive' : 'unverified'
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function markClosed(target: string, s: SessionRecord): SessionRecord {
|
|
151
|
+
// 호스트가 끝난 뒤 기록을 바꾸는 쪽은 여기뿐이지만, 막 종료 처리 중인 호스트가 더
|
|
152
|
+
// 최신 기록(사용량 등)을 썼을 수 있어 다시 읽고 바꾼다.
|
|
153
|
+
let latest = s
|
|
154
|
+
try { latest = readJSON<SessionRecord>(target) } catch { /* 아래에서 읽은 기록으로 표시만 한다 */ }
|
|
155
|
+
if (latest.state === 'closed') return latest
|
|
156
|
+
const closed: SessionRecord = { ...latest, state: 'closed', fullyIdle: false,
|
|
157
|
+
closedReason: '세션 호스트의 로컬 연결 파이프가 없습니다. 정상 종료 처리 없이 끝난 것으로 보입니다.' }
|
|
158
|
+
// 저장에 실패해도 이번 조회에는 닫힘으로 보고한다. 다음 조회가 다시 시도한다.
|
|
159
|
+
try { writeJSON(target, closed) } catch { /* 위와 같다 */ }
|
|
160
|
+
return closed
|
|
88
161
|
}
|
|
162
|
+
|
|
89
163
|
export function sessions(cwd: string): PublicSession[] {
|
|
90
164
|
const dir = path.join(bridgeHome(), 'sessions')
|
|
91
165
|
if (!fs.existsSync(dir)) return []
|
|
92
166
|
const repo = repoKey(cwd)
|
|
167
|
+
let pipes: Set<string> | Error | undefined
|
|
168
|
+
const listPipes = () => (pipes ??= openPipes())
|
|
93
169
|
return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
|
|
94
|
-
.map(n => readJSON<SessionRecord>(path.join(dir, n))
|
|
170
|
+
.map(n => [path.join(dir, n), readJSON<SessionRecord>(path.join(dir, n))] as const)
|
|
171
|
+
.filter(([, s]) => s.repos.includes(repo))
|
|
172
|
+
.map(([target, s]) => {
|
|
173
|
+
if (!['pty', 'stdio'].includes(s.transport || '') || s.state === 'closed') return publicSession(s)
|
|
174
|
+
const liveness = hostLiveness(s, listPipes)
|
|
175
|
+
return liveness === 'dead' ? publicSession(markClosed(target, s), 'dead') : publicSession(s, liveness)
|
|
176
|
+
})
|
|
95
177
|
}
|
|
96
178
|
function sessionFor(id: string, cwd: string): SessionRecord {
|
|
97
179
|
const s = readJSON<SessionRecord>(file('sessions', id))
|
|
98
180
|
if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
|
|
99
|
-
if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
181
|
+
if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
100
182
|
return s
|
|
101
183
|
}
|
|
102
184
|
|
|
@@ -125,11 +207,21 @@ export function requestResult(id: string, cwd: string): RequestRecord {
|
|
|
125
207
|
const target = file('requests', id)
|
|
126
208
|
const request = readJSON<RequestRecord>(target)
|
|
127
209
|
if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
|
|
128
|
-
if (!['submitted', 'unknown'].includes(request.state)) return request
|
|
210
|
+
if (!['queued', 'submitted', 'unknown'].includes(request.state)) return request
|
|
129
211
|
let s = sessionFor(request.to, cwd)
|
|
212
|
+
if (request.state === 'queued' && s.transport === 'console') return request
|
|
130
213
|
if (['pty', 'stdio'].includes(s.transport || '')) {
|
|
131
214
|
try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
|
|
132
|
-
catch (e) {
|
|
215
|
+
catch (e) {
|
|
216
|
+
const saved = path.join(bridgeHome(), 'jobs', `${id}.json`)
|
|
217
|
+
// A normal host exit persists outcomes before closing the pipe. Never replay
|
|
218
|
+
// a queued job after a crash or mistake an unexecuted job for completion.
|
|
219
|
+
if (s.transport === 'stdio' && fs.existsSync(saved)) {
|
|
220
|
+
const job = readJSON<Pick<RequestRecord, 'id' | 'to' | 'state'>>(saved)
|
|
221
|
+
if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state)) Object.assign(request, job)
|
|
222
|
+
else { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
223
|
+
} else { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
224
|
+
}
|
|
133
225
|
writeJSON(target, request)
|
|
134
226
|
return request
|
|
135
227
|
}
|
|
@@ -169,7 +261,7 @@ export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000)
|
|
|
169
261
|
const deadline = performance.now() + timeoutMs
|
|
170
262
|
for (;;) {
|
|
171
263
|
const result = requestResult(id, cwd)
|
|
172
|
-
if (
|
|
264
|
+
if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline) return result
|
|
173
265
|
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
|
|
174
266
|
}
|
|
175
267
|
}
|
|
@@ -179,20 +271,40 @@ export function sessionLeasePath(id: string): string {
|
|
|
179
271
|
return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
|
|
180
272
|
}
|
|
181
273
|
|
|
182
|
-
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
|
|
274
|
+
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null, queueIfBusy = false }: DispatchOptions): RequestRecord {
|
|
275
|
+
// 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
|
|
276
|
+
// 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
|
|
277
|
+
let sending = false
|
|
278
|
+
try {
|
|
183
279
|
if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768) throw new Error('요청 본문은 1~32768바이트여야 합니다.')
|
|
184
280
|
const s = sessionFor(to, cwd)
|
|
185
281
|
const lease = sessionLeasePath(to)
|
|
186
282
|
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
|
|
187
283
|
const dir = path.join(bridgeHome(), 'requests')
|
|
188
284
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
285
|
+
if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
|
|
286
|
+
validateConsolePrompt(prompt, from)
|
|
287
|
+
if (s.state === 'closed') throw new Error('Claude terminal is closed')
|
|
288
|
+
const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
289
|
+
const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
|
|
290
|
+
.filter(r => r.to === to && r.state === 'queued')
|
|
291
|
+
if (pending.length >= 100) throw new Error('Claude command queue is full')
|
|
292
|
+
const request: RequestRecord = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' }
|
|
293
|
+
writeJSON(file('requests', request.id), request)
|
|
294
|
+
return request
|
|
295
|
+
})
|
|
296
|
+
const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' })
|
|
297
|
+
worker.on('error', () => {}); worker.unref()
|
|
298
|
+
return request
|
|
299
|
+
}
|
|
189
300
|
return withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
190
301
|
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
|
|
191
302
|
for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
|
|
192
303
|
const previous = readJSON<RequestRecord>(path.join(dir, n))
|
|
193
|
-
if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
304
|
+
if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
194
305
|
const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
|
|
195
|
-
if (
|
|
306
|
+
if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state)) continue
|
|
307
|
+
if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
|
|
196
308
|
}
|
|
197
309
|
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
198
310
|
let consolePreflight
|
|
@@ -205,25 +317,45 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
205
317
|
createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
|
|
206
318
|
const target = file('requests', request.id)
|
|
207
319
|
writeJSON(target, request)
|
|
320
|
+
sending = true
|
|
208
321
|
try {
|
|
209
322
|
request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
|
|
210
|
-
? terminalRPC(to, { action: 'submit', request })
|
|
323
|
+
? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
|
|
211
324
|
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
|
|
212
|
-
request.state = 'submitted'
|
|
325
|
+
request.state = (request.receipt as { queued?: boolean })?.queued ? 'queued' : 'submitted'
|
|
213
326
|
} catch (e) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
327
|
+
if (notDelivered(e)) {
|
|
328
|
+
// 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
|
|
329
|
+
// 않았으므로 확실히 전달되지 않았다. unknown 으로 두면 이 세션의 다음 요청이
|
|
330
|
+
// 전부 막힌다. 자동 재전송은 여전히 하지 않는다.
|
|
331
|
+
request.state = 'failed'
|
|
332
|
+
request.error = `전달되지 않았습니다: ${errorMessage(e)}`
|
|
333
|
+
} else {
|
|
334
|
+
// A timeout may occur after delivery. Never retry automatically.
|
|
335
|
+
request.state = 'unknown'
|
|
336
|
+
request.error = errorMessage(e)
|
|
337
|
+
}
|
|
217
338
|
}
|
|
218
339
|
writeJSON(target, request)
|
|
219
340
|
return request
|
|
220
341
|
})
|
|
342
|
+
} catch (e) {
|
|
343
|
+
if (!sending && e instanceof Error) Object.assign(e, { notDelivered: true })
|
|
344
|
+
throw e
|
|
345
|
+
}
|
|
221
346
|
}
|
|
222
347
|
|
|
348
|
+
/** 연결 전에 실패해 아무것도 보내지 않은 오류만 true. 문구가 아니라 종료 코드나 보내기 전 단계로 가른다. */
|
|
349
|
+
export const notDelivered =(e: unknown) => e instanceof Error && (e as Error & { notDelivered?: boolean }).notDelivered === true
|
|
350
|
+
|
|
223
351
|
function terminalRPC(id: string, message: unknown): unknown {
|
|
224
352
|
const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
|
|
225
353
|
input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
|
|
226
354
|
})
|
|
355
|
+
// 시간 초과(r.error)나 그 밖의 실패는 연결 뒤에 났을 수 있어 전달 여부를 모른다.
|
|
356
|
+
if (!r.error && r.status === RPC_NOT_DELIVERED_EXIT) {
|
|
357
|
+
throw Object.assign(new Error(r.stderr?.trim() || '로컬 세션 파이프에 연결하지 못했습니다.'), { notDelivered: true })
|
|
358
|
+
}
|
|
227
359
|
if (r.error || r.status !== 0) throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.')
|
|
228
360
|
return JSON.parse(r.stdout)
|
|
229
361
|
}
|
|
@@ -280,3 +412,57 @@ export function installHooks({ executable, home = os.homedir() }: { executable:
|
|
|
280
412
|
return { hooks: target, backup: original === null ? null : backup,
|
|
281
413
|
note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
|
|
282
414
|
}
|
|
415
|
+
|
|
416
|
+
/** One local worker per ordinary Claude terminal; no provider is launched. */
|
|
417
|
+
export function runConsoleQueue(to: string, cwd: string) {
|
|
418
|
+
const dir = path.join(bridgeHome(), 'requests')
|
|
419
|
+
const runnerLock = path.join(dir, `${to}.runner.lock`)
|
|
420
|
+
if (fs.existsSync(runnerLock)) {
|
|
421
|
+
const owner = readJSON<{ pid: number }>(runnerLock)
|
|
422
|
+
try { process.kill(owner.pid, 0) } catch (e) {
|
|
423
|
+
if (errorCode(e) === 'ESRCH') fs.unlinkSync(runnerLock)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
try { withRepoLock(runnerLock, () => {
|
|
427
|
+
for (;;) {
|
|
428
|
+
const records: RequestRecord[] = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
|
|
429
|
+
.filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
|
|
430
|
+
.filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
|
|
431
|
+
const queued = records.filter(r => r.state === 'queued')
|
|
432
|
+
if (!queued.length) return
|
|
433
|
+
let waiting = false
|
|
434
|
+
for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
|
|
435
|
+
const latest = r.state === 'sending' ? r : requestResult(r.id, cwd)
|
|
436
|
+
if (['unknown', 'sending'].includes(latest.state)) return
|
|
437
|
+
if (latest.state === 'submitted') waiting = true
|
|
438
|
+
}
|
|
439
|
+
const s = sessionFor(to, cwd)
|
|
440
|
+
if (s.pid) {
|
|
441
|
+
try { process.kill(s.pid, 0) } catch (e) {
|
|
442
|
+
if (errorCode(e) === 'ESRCH') {
|
|
443
|
+
for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' })
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (s.state === 'closed') {
|
|
449
|
+
for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' })
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
let live
|
|
453
|
+
try { live = checkConsole(s) } catch { waiting = true }
|
|
454
|
+
if (waiting || !live) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); continue }
|
|
455
|
+
withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
456
|
+
const r = readJSON<RequestRecord>(file('requests', queued[0]!.id))
|
|
457
|
+
if (r.state !== 'queued') return
|
|
458
|
+
r.baseline = transcript(s).length; r.state = 'sending'; writeJSON(file('requests', r.id), r)
|
|
459
|
+
try { r.receipt = submitConsole(s, r, live); r.state = 'submitted' }
|
|
460
|
+
catch (e) { r.state = notDelivered(e) ? 'queued' : 'unknown'; r.error = errorMessage(e) }
|
|
461
|
+
writeJSON(file('requests', r.id), r)
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
}, 1000) } catch (e) {
|
|
465
|
+
// Another worker won the runner lease. It owns draining; never replay here.
|
|
466
|
+
if (!fs.existsSync(runnerLock)) throw e
|
|
467
|
+
}
|
|
468
|
+
}
|
package/src/external-console.mjs
CHANGED
|
@@ -25,19 +25,19 @@ export function consoleCall(pid, action = 'snapshot', payload = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
catch { /* report the underlying failure */ }
|
|
27
27
|
if (r.error || r.status !== 0 || data?.error || !data)
|
|
28
|
-
throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패');
|
|
28
|
+
throw Object.assign(new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패'), { notDelivered: !r.error && data?.notDelivered === true });
|
|
29
29
|
if (action === 'ancestor' && data.ignored)
|
|
30
30
|
return { ignored: true };
|
|
31
31
|
if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string'))
|
|
32
32
|
throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.');
|
|
33
33
|
return data;
|
|
34
34
|
}
|
|
35
|
-
function identityID({ pid, startedAt }) {
|
|
36
|
-
const hex = createHash('sha256').update(
|
|
35
|
+
function identityID({ pid, startedAt, provider = 'agy' }) {
|
|
36
|
+
const hex = createHash('sha256').update(`${provider}-console:${pid}:${startedAt}`).digest('hex');
|
|
37
37
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
38
38
|
}
|
|
39
39
|
const target = (id) => path.join(bridgeHome(), 'sessions', `${id}.json`);
|
|
40
|
-
export function consoleIdle(screen) {
|
|
40
|
+
export function consoleIdle(screen, live) {
|
|
41
41
|
// Use the last input box only. Old answers may contain quoted prompt boxes.
|
|
42
42
|
const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)];
|
|
43
43
|
if (borders.length < 2)
|
|
@@ -47,7 +47,15 @@ export function consoleIdle(screen) {
|
|
|
47
47
|
return false;
|
|
48
48
|
const input = screen.slice(top.index + top[0].length, bottom.index).trim();
|
|
49
49
|
const footer = screen.slice(bottom.index + bottom[0].length);
|
|
50
|
-
|
|
50
|
+
const empty = /^[>❯]$/.test(input);
|
|
51
|
+
// Claude paints a suggestion after the cursor in an otherwise empty editor.
|
|
52
|
+
// Require the cursor to remain at that editor's start, never just match text.
|
|
53
|
+
const placeholder = live?.provider === 'claude' && live.cursorX === 2 &&
|
|
54
|
+
/^[>❯]\s+Try "[^"\r\n]+"$/.test(input) &&
|
|
55
|
+
live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length;
|
|
56
|
+
const claudeEditor = live?.provider === 'claude' && live.cursorX === 2 &&
|
|
57
|
+
live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length;
|
|
58
|
+
return (empty || placeholder) && (live?.provider === 'claude' ? claudeEditor : /\? for shortcuts/.test(footer)) && !/esc to (cancel|interrupt)/i.test(footer);
|
|
51
59
|
}
|
|
52
60
|
export function attachConsole({ pid, cwd }) {
|
|
53
61
|
const live = consoleCall(pid);
|
|
@@ -56,19 +64,38 @@ export function attachConsole({ pid, cwd }) {
|
|
|
56
64
|
const repo = repoKey(cwd);
|
|
57
65
|
if (previous.repos && !previous.repos.includes(repo))
|
|
58
66
|
throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.');
|
|
59
|
-
const idle = consoleIdle(live.screen);
|
|
60
|
-
const s = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
67
|
+
const idle = consoleIdle(live.screen, live);
|
|
68
|
+
const s = { ...previous, id, provider: live.provider || 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
61
69
|
workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
|
|
62
70
|
state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' };
|
|
63
71
|
writeJSON(target(id), s);
|
|
64
72
|
return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces };
|
|
65
73
|
}
|
|
66
|
-
export function registerExternalHook(input, event) {
|
|
67
|
-
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event))
|
|
74
|
+
export function registerExternalHook(input, event, provider = 'agy') {
|
|
75
|
+
if (!['SessionStart', 'PreInvocation', 'UserPromptSubmit', 'Stop', 'SessionEnd'].includes(event))
|
|
68
76
|
throw new Error('지원하지 않는 훅 이벤트입니다.');
|
|
69
77
|
const identity = consoleCall(process.pid, 'ancestor');
|
|
70
78
|
if (identity.ignored)
|
|
71
79
|
return {};
|
|
80
|
+
if (identity.provider && identity.provider !== provider)
|
|
81
|
+
return {};
|
|
82
|
+
if (provider === 'claude') {
|
|
83
|
+
const conversationId = input.session_id;
|
|
84
|
+
const log = input.transcript_path;
|
|
85
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
86
|
+
const relative = typeof log === 'string' ? path.relative(path.join(home, 'projects'), path.resolve(log)) : '..';
|
|
87
|
+
if (!conversationId || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId) ||
|
|
88
|
+
!log || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(log) !== `${conversationId}.jsonl` ||
|
|
89
|
+
!input.cwd || !path.isAbsolute(input.cwd))
|
|
90
|
+
throw new Error('Claude 훅의 대화 기록·작업 폴더 신원이 올바르지 않습니다.');
|
|
91
|
+
const id = identityID(identity);
|
|
92
|
+
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
|
|
93
|
+
const state = event === 'SessionEnd' ? 'closed' : event === 'Stop' || event === 'SessionStart' ? 'settling' : 'running';
|
|
94
|
+
writeJSON(target(id), { ...previous, ...identity, id, provider: 'claude', transport: 'console', conversationId,
|
|
95
|
+
transcript: path.resolve(log), workspaces: [path.resolve(input.cwd)], repos: [repoKey(input.cwd)],
|
|
96
|
+
state, fullyIdle: false, event, registeredAt: new Date().toISOString() });
|
|
97
|
+
return { id };
|
|
98
|
+
}
|
|
72
99
|
const id = identityID(identity);
|
|
73
100
|
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
|
|
74
101
|
const workspaces = input.workspacePaths || previous.workspaces;
|
|
@@ -89,7 +116,8 @@ export function registerExternalHook(input, event) {
|
|
|
89
116
|
export function externalStatus(session) {
|
|
90
117
|
const s = readJSON(target(session.id));
|
|
91
118
|
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
|
|
92
|
-
const
|
|
119
|
+
const eligible = s.provider !== 'claude' || ['SessionStart', 'Stop'].includes(s.event || '');
|
|
120
|
+
const state = s.state === 'closed' ? 'closed' : eligible && consoleIdle(live.screen, live) ? 'idle' : 'unknown';
|
|
93
121
|
writeJSON(target(s.id), { ...readJSON(target(s.id)), state, fullyIdle: state === 'idle' });
|
|
94
122
|
return { id: s.id, state };
|
|
95
123
|
}
|
|
@@ -100,7 +128,9 @@ function consolePID(s) {
|
|
|
100
128
|
}
|
|
101
129
|
export function checkConsole(s) {
|
|
102
130
|
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
|
|
103
|
-
if (!
|
|
131
|
+
if (s.provider === 'claude' && !['SessionStart', 'Stop'].includes(s.event || ''))
|
|
132
|
+
throw new Error('Claude가 실행·승인 대기 중입니다.');
|
|
133
|
+
if (!consoleIdle(live.screen, live))
|
|
104
134
|
throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.');
|
|
105
135
|
return live;
|
|
106
136
|
}
|
|
@@ -111,6 +141,8 @@ export function validateConsolePrompt(prompt, from) {
|
|
|
111
141
|
export function findConsoleTranscript(s, request) {
|
|
112
142
|
if (s.transcript)
|
|
113
143
|
return s;
|
|
144
|
+
if (s.provider === 'claude')
|
|
145
|
+
return s;
|
|
114
146
|
const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain');
|
|
115
147
|
if (!fs.existsSync(brain))
|
|
116
148
|
return s;
|
|
@@ -153,7 +185,8 @@ export function submitConsole(s, request, preflight) {
|
|
|
153
185
|
// Bracketed paste preserves multiline prompts and prevents embedded Enter keys
|
|
154
186
|
// from becoming separate submissions. Only the final CR submits the message.
|
|
155
187
|
const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`;
|
|
156
|
-
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
|
|
188
|
+
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
|
|
189
|
+
expectedCursorX: live.cursorX, expectedCursorRow: live.cursorRow, text });
|
|
157
190
|
writeJSON(target(s.id), { ...readJSON(target(s.id)), state: 'running', fullyIdle: false });
|
|
158
191
|
return { accepted: true, pid: s.pid };
|
|
159
192
|
}
|
package/src/external-console.mts
CHANGED
|
@@ -7,7 +7,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
|
|
9
9
|
import { nativeConsoleHelper } from './windows-console.mjs'
|
|
10
|
-
import type { SessionRecord, RequestRecord, ConsoleSnapshot, HookInput, TranscriptEntry } from './session-types.mjs'
|
|
10
|
+
import type { SessionRecord, RequestRecord, ConsoleSnapshot, HookInput, TranscriptEntry, SessionState } from './session-types.mjs'
|
|
11
11
|
|
|
12
12
|
const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url))
|
|
13
13
|
export function consoleCall(pid: number, action?: 'snapshot' | 'send', payload?: Record<string, unknown>): ConsoleSnapshot
|
|
@@ -20,19 +20,20 @@ export function consoleCall(pid: number, action = 'snapshot', payload: Record<st
|
|
|
20
20
|
'-File', helper, '-TargetPid', String(pid), '-Action', action], {
|
|
21
21
|
input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
|
|
22
22
|
})
|
|
23
|
-
let data: (Omit<Partial<ConsoleSnapshot>, 'ignored'> & { error?: string; ignored?: boolean }) | undefined
|
|
23
|
+
let data: (Omit<Partial<ConsoleSnapshot>, 'ignored'> & { error?: string; ignored?: boolean; notDelivered?: boolean }) | undefined
|
|
24
24
|
try { data = JSON.parse(r.stdout) } catch { /* report the underlying failure */ }
|
|
25
|
-
if (r.error || r.status !== 0 || data?.error || !data) throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패')
|
|
25
|
+
if (r.error || r.status !== 0 || data?.error || !data) throw Object.assign(new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패'),
|
|
26
|
+
{ notDelivered: !r.error && data?.notDelivered === true })
|
|
26
27
|
if (action === 'ancestor' && data.ignored) return { ignored: true }
|
|
27
28
|
if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string')) throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.')
|
|
28
29
|
return data as ConsoleSnapshot
|
|
29
30
|
}
|
|
30
|
-
function identityID({ pid, startedAt }: Pick<ConsoleSnapshot, 'pid' | 'startedAt'>): string {
|
|
31
|
-
const hex = createHash('sha256').update(
|
|
31
|
+
function identityID({ pid, startedAt, provider = 'agy' }: Pick<ConsoleSnapshot, 'pid' | 'startedAt' | 'provider'>): string {
|
|
32
|
+
const hex = createHash('sha256').update(`${provider}-console:${pid}:${startedAt}`).digest('hex')
|
|
32
33
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`
|
|
33
34
|
}
|
|
34
35
|
const target = (id: string) => path.join(bridgeHome(), 'sessions', `${id}.json`)
|
|
35
|
-
export function consoleIdle(screen: string): boolean {
|
|
36
|
+
export function consoleIdle(screen: string, live?: ConsoleSnapshot): boolean {
|
|
36
37
|
// Use the last input box only. Old answers may contain quoted prompt boxes.
|
|
37
38
|
const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)]
|
|
38
39
|
if (borders.length < 2) return false
|
|
@@ -40,7 +41,15 @@ export function consoleIdle(screen: string): boolean {
|
|
|
40
41
|
if (!top || !bottom) return false
|
|
41
42
|
const input = screen.slice(top.index + top[0].length, bottom.index).trim()
|
|
42
43
|
const footer = screen.slice(bottom.index + bottom[0].length)
|
|
43
|
-
|
|
44
|
+
const empty = /^[>❯]$/.test(input)
|
|
45
|
+
// Claude paints a suggestion after the cursor in an otherwise empty editor.
|
|
46
|
+
// Require the cursor to remain at that editor's start, never just match text.
|
|
47
|
+
const placeholder = live?.provider === 'claude' && live.cursorX === 2 &&
|
|
48
|
+
/^[>❯]\s+Try "[^"\r\n]+"$/.test(input) &&
|
|
49
|
+
live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length
|
|
50
|
+
const claudeEditor = live?.provider === 'claude' && live.cursorX === 2 &&
|
|
51
|
+
live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length
|
|
52
|
+
return (empty || placeholder) && (live?.provider === 'claude' ? claudeEditor : /\? for shortcuts/.test(footer)) && !/esc to (cancel|interrupt)/i.test(footer)
|
|
44
53
|
}
|
|
45
54
|
export function attachConsole({ pid, cwd }: { pid: number; cwd: string }) {
|
|
46
55
|
const live = consoleCall(pid)
|
|
@@ -48,17 +57,34 @@ export function attachConsole({ pid, cwd }: { pid: number; cwd: string }) {
|
|
|
48
57
|
const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
|
|
49
58
|
const repo = repoKey(cwd)
|
|
50
59
|
if (previous.repos && !previous.repos.includes(repo)) throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.')
|
|
51
|
-
const idle = consoleIdle(live.screen)
|
|
52
|
-
const s: SessionRecord = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
60
|
+
const idle = consoleIdle(live.screen, live)
|
|
61
|
+
const s: SessionRecord = { ...previous, id, provider: live.provider || 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
53
62
|
workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
|
|
54
63
|
state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' }
|
|
55
64
|
writeJSON(target(id), s)
|
|
56
65
|
return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces }
|
|
57
66
|
}
|
|
58
|
-
export function registerExternalHook(input: HookInput, event: string) {
|
|
59
|
-
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
|
|
67
|
+
export function registerExternalHook(input: HookInput, event: string, provider = 'agy') {
|
|
68
|
+
if (!['SessionStart', 'PreInvocation', 'UserPromptSubmit', 'Stop', 'SessionEnd'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
|
|
60
69
|
const identity = consoleCall(process.pid, 'ancestor')
|
|
61
70
|
if (identity.ignored) return {}
|
|
71
|
+
if (identity.provider && identity.provider !== provider) return {}
|
|
72
|
+
if (provider === 'claude') {
|
|
73
|
+
const conversationId = input.session_id
|
|
74
|
+
const log = input.transcript_path
|
|
75
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
76
|
+
const relative = typeof log === 'string' ? path.relative(path.join(home, 'projects'), path.resolve(log)) : '..'
|
|
77
|
+
if (!conversationId || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId) ||
|
|
78
|
+
!log || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(log) !== `${conversationId}.jsonl` ||
|
|
79
|
+
!input.cwd || !path.isAbsolute(input.cwd)) throw new Error('Claude 훅의 대화 기록·작업 폴더 신원이 올바르지 않습니다.')
|
|
80
|
+
const id = identityID(identity)
|
|
81
|
+
const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
|
|
82
|
+
const state = event === 'SessionEnd' ? 'closed' : event === 'Stop' || event === 'SessionStart' ? 'settling' : 'running'
|
|
83
|
+
writeJSON(target(id), { ...previous, ...identity, id, provider: 'claude', transport: 'console', conversationId,
|
|
84
|
+
transcript: path.resolve(log), workspaces: [path.resolve(input.cwd)], repos: [repoKey(input.cwd)],
|
|
85
|
+
state, fullyIdle: false, event, registeredAt: new Date().toISOString() })
|
|
86
|
+
return { id }
|
|
87
|
+
}
|
|
62
88
|
const id = identityID(identity)
|
|
63
89
|
const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
|
|
64
90
|
const workspaces = input.workspacePaths || previous.workspaces
|
|
@@ -77,7 +103,8 @@ export function registerExternalHook(input: HookInput, event: string) {
|
|
|
77
103
|
export function externalStatus(session: Pick<SessionRecord, 'id'>) {
|
|
78
104
|
const s = readJSON<SessionRecord>(target(session.id))
|
|
79
105
|
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
|
|
80
|
-
const
|
|
106
|
+
const eligible = s.provider !== 'claude' || ['SessionStart', 'Stop'].includes(s.event || '')
|
|
107
|
+
const state: SessionState = s.state === 'closed' ? 'closed' : eligible && consoleIdle(live.screen, live) ? 'idle' : 'unknown'
|
|
81
108
|
writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state, fullyIdle: state === 'idle' })
|
|
82
109
|
return { id: s.id, state }
|
|
83
110
|
}
|
|
@@ -87,7 +114,8 @@ function consolePID(s: SessionRecord): number {
|
|
|
87
114
|
}
|
|
88
115
|
export function checkConsole(s: SessionRecord): ConsoleSnapshot {
|
|
89
116
|
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
|
|
90
|
-
if (!
|
|
117
|
+
if (s.provider === 'claude' && !['SessionStart', 'Stop'].includes(s.event || '')) throw new Error('Claude가 실행·승인 대기 중입니다.')
|
|
118
|
+
if (!consoleIdle(live.screen, live)) throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.')
|
|
91
119
|
return live
|
|
92
120
|
}
|
|
93
121
|
export function validateConsolePrompt(prompt: string, from: string): void {
|
|
@@ -95,6 +123,7 @@ export function validateConsolePrompt(prompt: string, from: string): void {
|
|
|
95
123
|
}
|
|
96
124
|
export function findConsoleTranscript(s: SessionRecord, request: RequestRecord): SessionRecord {
|
|
97
125
|
if (s.transcript) return s
|
|
126
|
+
if (s.provider === 'claude') return s
|
|
98
127
|
const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain')
|
|
99
128
|
if (!fs.existsSync(brain)) return s
|
|
100
129
|
const marker = `[axMap request ${request.id}]`
|
|
@@ -122,7 +151,8 @@ export function submitConsole(s: SessionRecord, request: RequestRecord, prefligh
|
|
|
122
151
|
// Bracketed paste preserves multiline prompts and prevents embedded Enter keys
|
|
123
152
|
// from becoming separate submissions. Only the final CR submits the message.
|
|
124
153
|
const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`
|
|
125
|
-
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
|
|
154
|
+
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
|
|
155
|
+
expectedCursorX: live.cursorX, expectedCursorRow: live.cursorRow, text })
|
|
126
156
|
writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state: 'running', fullyIdle: false })
|
|
127
157
|
return { accepted: true, pid: s.pid }
|
|
128
158
|
}
|