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/src/bridge.mts
CHANGED
|
@@ -4,10 +4,11 @@ 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 { enqueueAppRequest } from './app-session.mjs'
|
|
11
12
|
import { errorCode, errorMessage, RPC_NOT_DELIVERED_EXIT } from './session-types.mjs'
|
|
12
13
|
import { commonKey, resolveTarget } from './workspace.mjs'
|
|
13
14
|
import type { HostLiveness } from './session-types.mjs'
|
|
@@ -82,6 +83,20 @@ function runAPI(session: SessionRecord, args: string[]): unknown {
|
|
|
82
83
|
return null
|
|
83
84
|
}
|
|
84
85
|
function transcript(session: SessionRecord): TranscriptEntry[] {
|
|
86
|
+
if (session.provider === 'claude') {
|
|
87
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
88
|
+
const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..'
|
|
89
|
+
if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`) throw new Error('Claude transcript identity mismatch')
|
|
90
|
+
if (!fs.existsSync(session.transcript)) return []
|
|
91
|
+
return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
|
|
92
|
+
const entry = JSON.parse(line) as { type?: string; sessionId?: string; message?: { content?: string | Array<{ type: string; text?: string }> } }
|
|
93
|
+
if (entry.sessionId && entry.sessionId !== session.conversationId) return []
|
|
94
|
+
const content = entry.message?.content
|
|
95
|
+
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : ''
|
|
96
|
+
if (!text || !['user', 'assistant'].includes(entry.type || '')) return []
|
|
97
|
+
return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
85
100
|
const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
86
101
|
if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
|
|
87
102
|
const lines = fs.readFileSync(expected, 'utf8').split('\n')
|
|
@@ -97,6 +112,7 @@ function publicSession(s: SessionRecord, liveness?: HostLiveness): PublicSession
|
|
|
97
112
|
// 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
|
|
98
113
|
fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
|
|
99
114
|
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
|
|
115
|
+
...(s.dialogueMode ? { dialogueMode: s.dialogueMode } : {}),
|
|
100
116
|
...(liveness ? { liveness } : {}) }
|
|
101
117
|
}
|
|
102
118
|
|
|
@@ -164,7 +180,7 @@ export function sessions(cwd: string): PublicSession[] {
|
|
|
164
180
|
function sessionFor(id: string, cwd: string): SessionRecord {
|
|
165
181
|
const s = readJSON<SessionRecord>(file('sessions', id))
|
|
166
182
|
if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
|
|
167
|
-
if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
183
|
+
if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio', 'hook'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
168
184
|
return s
|
|
169
185
|
}
|
|
170
186
|
|
|
@@ -193,11 +209,23 @@ export function requestResult(id: string, cwd: string): RequestRecord {
|
|
|
193
209
|
const target = file('requests', id)
|
|
194
210
|
const request = readJSON<RequestRecord>(target)
|
|
195
211
|
if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
|
|
196
|
-
if (!['submitted', 'unknown'].includes(request.state)) return request
|
|
212
|
+
if (!['queued', 'submitted', 'unknown'].includes(request.state)) return request
|
|
197
213
|
let s = sessionFor(request.to, cwd)
|
|
214
|
+
// 앱 세션의 요청 상태는 받는 쪽 훅이 직접 적는다. 여기서 추측하지 않는다.
|
|
215
|
+
if (s.transport === 'hook') return request
|
|
216
|
+
if (request.state === 'queued' && s.transport === 'console') return request
|
|
198
217
|
if (['pty', 'stdio'].includes(s.transport || '')) {
|
|
199
218
|
try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
|
|
200
|
-
catch (e) {
|
|
219
|
+
catch (e) {
|
|
220
|
+
const saved = path.join(bridgeHome(), 'jobs', `${id}.json`)
|
|
221
|
+
// A normal host exit persists outcomes before closing the pipe. Never replay
|
|
222
|
+
// a queued job after a crash or mistake an unexecuted job for completion.
|
|
223
|
+
if (s.transport === 'stdio' && fs.existsSync(saved)) {
|
|
224
|
+
const job = readJSON<Pick<RequestRecord, 'id' | 'to' | 'state'>>(saved)
|
|
225
|
+
if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state)) Object.assign(request, job)
|
|
226
|
+
else { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
227
|
+
} else { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
228
|
+
}
|
|
201
229
|
writeJSON(target, request)
|
|
202
230
|
return request
|
|
203
231
|
}
|
|
@@ -237,7 +265,7 @@ export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000)
|
|
|
237
265
|
const deadline = performance.now() + timeoutMs
|
|
238
266
|
for (;;) {
|
|
239
267
|
const result = requestResult(id, cwd)
|
|
240
|
-
if (
|
|
268
|
+
if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline) return result
|
|
241
269
|
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
|
|
242
270
|
}
|
|
243
271
|
}
|
|
@@ -247,7 +275,7 @@ export function sessionLeasePath(id: string): string {
|
|
|
247
275
|
return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
|
|
248
276
|
}
|
|
249
277
|
|
|
250
|
-
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
|
|
278
|
+
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', fromSession, dialogueId = null, queueIfBusy = false }: DispatchOptions): RequestRecord {
|
|
251
279
|
// 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
|
|
252
280
|
// 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
|
|
253
281
|
let sending = false
|
|
@@ -258,13 +286,37 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
258
286
|
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
|
|
259
287
|
const dir = path.join(bridgeHome(), 'requests')
|
|
260
288
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
289
|
+
if (fromSession !== undefined) {
|
|
290
|
+
if (fromSession === to) throw new Error('자기 자신에게는 보낼 수 없습니다.')
|
|
291
|
+
sessionFor(fromSession, cwd)
|
|
292
|
+
}
|
|
293
|
+
if (s.transport === 'hook') {
|
|
294
|
+
if (dialogueId) throw new Error('앱 세션은 자동 대화 중계(ax_chat)를 지원하지 않습니다. 사용량을 측정하지 못합니다.')
|
|
295
|
+
return enqueueAppRequest(s, { to, from, repo: repoKey(cwd), prompt, ...(fromSession ? { fromSession } : {}) })
|
|
296
|
+
}
|
|
297
|
+
if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
|
|
298
|
+
validateConsolePrompt(prompt, from)
|
|
299
|
+
if (s.state === 'closed') throw new Error('Claude terminal is closed')
|
|
300
|
+
const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
301
|
+
const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
|
|
302
|
+
.filter(r => r.to === to && r.state === 'queued')
|
|
303
|
+
if (pending.length >= 100) throw new Error('Claude command queue is full')
|
|
304
|
+
const request: RequestRecord = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' }
|
|
305
|
+
writeJSON(file('requests', request.id), request)
|
|
306
|
+
return request
|
|
307
|
+
})
|
|
308
|
+
const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' })
|
|
309
|
+
worker.on('error', () => {}); worker.unref()
|
|
310
|
+
return request
|
|
311
|
+
}
|
|
261
312
|
return withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
262
313
|
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
|
|
263
314
|
for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
|
|
264
315
|
const previous = readJSON<RequestRecord>(path.join(dir, n))
|
|
265
|
-
if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
316
|
+
if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
266
317
|
const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
|
|
267
|
-
if (
|
|
318
|
+
if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state)) continue
|
|
319
|
+
if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
|
|
268
320
|
}
|
|
269
321
|
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
270
322
|
let consolePreflight
|
|
@@ -273,16 +325,16 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
273
325
|
runAPI(s, ['get-conversation-metadata', to])
|
|
274
326
|
if (!s.fullyIdle || s.event !== 'Stop') throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.')
|
|
275
327
|
}
|
|
276
|
-
const request: RequestRecord = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
|
|
328
|
+
const request: RequestRecord = { id: randomUUID(), to, from, ...(fromSession ? { fromSession } : {}), repo: repoKey(cwd), prompt,
|
|
277
329
|
createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
|
|
278
330
|
const target = file('requests', request.id)
|
|
279
331
|
writeJSON(target, request)
|
|
280
332
|
sending = true
|
|
281
333
|
try {
|
|
282
334
|
request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
|
|
283
|
-
? terminalRPC(to, { action: 'submit', request })
|
|
335
|
+
? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
|
|
284
336
|
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
|
|
285
|
-
request.state = 'submitted'
|
|
337
|
+
request.state = (request.receipt as { queued?: boolean })?.queued ? 'queued' : 'submitted'
|
|
286
338
|
} catch (e) {
|
|
287
339
|
if (notDelivered(e)) {
|
|
288
340
|
// 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
|
|
@@ -372,3 +424,57 @@ export function installHooks({ executable, home = os.homedir() }: { executable:
|
|
|
372
424
|
return { hooks: target, backup: original === null ? null : backup,
|
|
373
425
|
note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
|
|
374
426
|
}
|
|
427
|
+
|
|
428
|
+
/** One local worker per ordinary Claude terminal; no provider is launched. */
|
|
429
|
+
export function runConsoleQueue(to: string, cwd: string) {
|
|
430
|
+
const dir = path.join(bridgeHome(), 'requests')
|
|
431
|
+
const runnerLock = path.join(dir, `${to}.runner.lock`)
|
|
432
|
+
if (fs.existsSync(runnerLock)) {
|
|
433
|
+
const owner = readJSON<{ pid: number }>(runnerLock)
|
|
434
|
+
try { process.kill(owner.pid, 0) } catch (e) {
|
|
435
|
+
if (errorCode(e) === 'ESRCH') fs.unlinkSync(runnerLock)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
try { withRepoLock(runnerLock, () => {
|
|
439
|
+
for (;;) {
|
|
440
|
+
const records: RequestRecord[] = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
|
|
441
|
+
.filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
|
|
442
|
+
.filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
|
|
443
|
+
const queued = records.filter(r => r.state === 'queued')
|
|
444
|
+
if (!queued.length) return
|
|
445
|
+
let waiting = false
|
|
446
|
+
for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
|
|
447
|
+
const latest = r.state === 'sending' ? r : requestResult(r.id, cwd)
|
|
448
|
+
if (['unknown', 'sending'].includes(latest.state)) return
|
|
449
|
+
if (latest.state === 'submitted') waiting = true
|
|
450
|
+
}
|
|
451
|
+
const s = sessionFor(to, cwd)
|
|
452
|
+
if (s.pid) {
|
|
453
|
+
try { process.kill(s.pid, 0) } catch (e) {
|
|
454
|
+
if (errorCode(e) === 'ESRCH') {
|
|
455
|
+
for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' })
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (s.state === 'closed') {
|
|
461
|
+
for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' })
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
let live
|
|
465
|
+
try { live = checkConsole(s) } catch { waiting = true }
|
|
466
|
+
if (waiting || !live) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); continue }
|
|
467
|
+
withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
468
|
+
const r = readJSON<RequestRecord>(file('requests', queued[0]!.id))
|
|
469
|
+
if (r.state !== 'queued') return
|
|
470
|
+
r.baseline = transcript(s).length; r.state = 'sending'; writeJSON(file('requests', r.id), r)
|
|
471
|
+
try { r.receipt = submitConsole(s, r, live); r.state = 'submitted' }
|
|
472
|
+
catch (e) { r.state = notDelivered(e) ? 'queued' : 'unknown'; r.error = errorMessage(e) }
|
|
473
|
+
writeJSON(file('requests', r.id), r)
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
}, 1000) } catch (e) {
|
|
477
|
+
// Another worker won the runner lease. It owns draining; never replay here.
|
|
478
|
+
if (!fs.existsSync(runnerLock)) throw e
|
|
479
|
+
}
|
|
480
|
+
}
|
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
|
}
|