axmap-cli 1.5.0 → 1.7.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.
Files changed (53) hide show
  1. package/.claude/commands/pipeline.md +9 -0
  2. package/README.ko.md +37 -0
  3. package/README.md +37 -0
  4. package/bin/axmap.mjs +6 -0
  5. package/mcp/README.md +9 -0
  6. package/mcp/pipeline.html +184 -0
  7. package/mcp/server.mjs +161 -20
  8. package/package.json +12 -3
  9. package/skills/pipeline/SKILL.md +14 -0
  10. package/src/agentcli.mjs +17 -14
  11. package/src/agentcli.mts +19 -0
  12. package/src/bridge.mjs +297 -219
  13. package/src/bridge.mts +282 -0
  14. package/src/codex-connection.mjs +151 -0
  15. package/src/codex-connection.mts +83 -0
  16. package/src/external-console.mjs +140 -97
  17. package/src/external-console.mts +128 -0
  18. package/src/managed-session.mjs +325 -169
  19. package/src/managed-session.mts +242 -0
  20. package/src/monitor-cli.mjs +89 -0
  21. package/src/monitor-cli.mts +66 -0
  22. package/src/monitor.mjs +245 -0
  23. package/src/monitor.mts +199 -0
  24. package/src/node-pty.d.ts +13 -0
  25. package/src/pipeline-graph.mjs +55 -0
  26. package/src/pipeline-graph.mts +51 -0
  27. package/src/pipeline-mcp.mjs +111 -0
  28. package/src/pipeline-mcp.mts +105 -0
  29. package/src/pipeline-statusline.mjs +135 -0
  30. package/src/pipeline-statusline.mts +106 -0
  31. package/src/pipeline-web.mjs +56 -0
  32. package/src/pipeline-web.mts +31 -0
  33. package/src/session-dialogue.mjs +168 -116
  34. package/src/session-dialogue.mts +136 -0
  35. package/src/session-mailbox.mjs +190 -0
  36. package/src/session-mailbox.mts +157 -0
  37. package/src/session-status.mjs +47 -41
  38. package/src/session-status.mts +50 -0
  39. package/src/session-types.mjs +7 -0
  40. package/src/session-types.mts +119 -0
  41. package/src/session-usage.mjs +33 -28
  42. package/src/session-usage.mts +39 -0
  43. package/src/terminal-bridge.mjs +293 -209
  44. package/src/terminal-bridge.mts +235 -0
  45. package/src/windows-console.mjs +42 -0
  46. package/src/windows-console.mts +29 -0
  47. package/tools/bench-console.mjs +70 -0
  48. package/tools/bridge.mjs +41 -6
  49. package/tools/build.mjs +39 -0
  50. package/tools/monitor.mjs +5 -0
  51. package/tools/pipeline.mjs +34 -0
  52. package/tools/run-tests.mjs +3 -2
  53. package/tools/windows-console.cs +116 -0
@@ -1,116 +1,159 @@
1
+ // Generated from src/external-console.mts. Edit the .mts source and run npm run build.
1
2
  /** Attach to an existing Windows console; never launch or resume a model. */
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
- import os from 'node:os'
5
- import { createHash } from 'node:crypto'
6
- import { spawnSync } from 'node:child_process'
7
- import { fileURLToPath } from 'node:url'
8
- import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
9
-
10
- const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url))
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { createHash } from 'node:crypto';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
10
+ import { nativeConsoleHelper } from './windows-console.mjs';
11
+ const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url));
11
12
  export function consoleCall(pid, action = 'snapshot', payload = {}) {
12
- if (process.platform !== 'win32') throw new Error('독립 agy 콘솔 연결은 현재 Windows에서 지원합니다.')
13
- if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('양의 정수 PID가 필요합니다.')
14
- const r = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
15
- '-File', helper, '-TargetPid', String(pid), '-Action', action], {
16
- input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
17
- })
18
- let data
19
- try { data = JSON.parse(r.stdout) } catch { /* report the underlying failure */ }
20
- if (r.error || r.status !== 0 || data?.error || !data) throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패')
21
- return data
13
+ if (process.platform !== 'win32')
14
+ throw new Error('독립 agy 콘솔 연결은 현재 Windows에서 지원합니다.');
15
+ if (!Number.isSafeInteger(pid) || pid <= 0)
16
+ throw new Error('양의 정수 PID가 필요합니다.');
17
+ const native = nativeConsoleHelper();
18
+ const r = spawnSync(native || 'powershell.exe', native ? [String(pid), action] : ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
19
+ '-File', helper, '-TargetPid', String(pid), '-Action', action], {
20
+ input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
21
+ });
22
+ let data;
23
+ try {
24
+ data = JSON.parse(r.stdout);
25
+ }
26
+ catch { /* report the underlying failure */ }
27
+ if (r.error || r.status !== 0 || data?.error || !data)
28
+ throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패');
29
+ if (action === 'ancestor' && data.ignored)
30
+ return { ignored: true };
31
+ if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string'))
32
+ throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.');
33
+ return data;
22
34
  }
23
35
  function identityID({ pid, startedAt }) {
24
- const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).digest('hex')
25
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`
36
+ const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).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)}`;
26
38
  }
27
- const target = id => path.join(bridgeHome(), 'sessions', `${id}.json`)
39
+ const target = (id) => path.join(bridgeHome(), 'sessions', `${id}.json`);
28
40
  export function consoleIdle(screen) {
29
- // Use the last input box only. Old answers may contain quoted prompt boxes.
30
- const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)]
31
- if (borders.length < 2) return false
32
- const top = borders.at(-2), bottom = borders.at(-1)
33
- const input = screen.slice(top.index + top[0].length, bottom.index).trim()
34
- const footer = screen.slice(bottom.index + bottom[0].length)
35
- return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer)
41
+ // Use the last input box only. Old answers may contain quoted prompt boxes.
42
+ const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)];
43
+ if (borders.length < 2)
44
+ return false;
45
+ const top = borders.at(-2), bottom = borders.at(-1);
46
+ if (!top || !bottom)
47
+ return false;
48
+ const input = screen.slice(top.index + top[0].length, bottom.index).trim();
49
+ const footer = screen.slice(bottom.index + bottom[0].length);
50
+ return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer);
36
51
  }
37
52
  export function attachConsole({ pid, cwd }) {
38
- const live = consoleCall(pid)
39
- const id = identityID(live)
40
- const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {}
41
- const repo = repoKey(cwd)
42
- if (previous.repos && !previous.repos.includes(repo)) throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.')
43
- const idle = consoleIdle(live.screen)
44
- const s = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
45
- workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
46
- state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' }
47
- writeJSON(target(id), s)
48
- return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces }
53
+ const live = consoleCall(pid);
54
+ const id = identityID(live);
55
+ const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
56
+ const repo = repoKey(cwd);
57
+ if (previous.repos && !previous.repos.includes(repo))
58
+ throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.');
59
+ const idle = consoleIdle(live.screen);
60
+ const s = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
61
+ workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
62
+ state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' };
63
+ writeJSON(target(id), s);
64
+ return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces };
49
65
  }
50
66
  export function registerExternalHook(input, event) {
51
- if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
52
- const identity = consoleCall(process.pid, 'ancestor')
53
- if (identity.ignored) return {}
54
- const id = identityID(identity)
55
- const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {}
56
- const workspaces = input.workspacePaths || previous.workspaces
57
- if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) throw new Error('훅의 작업 저장소 경로가 없습니다.')
58
- const conversationId = input.conversationId
59
- if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId || '')) throw new Error('훅의 대화 UUID가 없습니다.')
60
- const transcript = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
61
- 'brain', conversationId, '.system_generated', 'logs', 'transcript.jsonl')
62
- if (path.resolve(input.transcriptPath || '') !== transcript) throw new Error('훅의 대화 기록 경로가 다릅니다.')
63
- const idle = event === 'Stop' && input.fullyIdle === true
64
- writeJSON(target(id), { ...previous, ...identity, id, provider: 'agy', transport: 'console', conversationId,
65
- transcript, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || previous.model,
66
- event, state: idle ? 'idle' : 'running', fullyIdle: idle, registeredAt: new Date().toISOString() })
67
- return { id }
67
+ if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event))
68
+ throw new Error('지원하지 않는 훅 이벤트입니다.');
69
+ const identity = consoleCall(process.pid, 'ancestor');
70
+ if (identity.ignored)
71
+ return {};
72
+ const id = identityID(identity);
73
+ const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
74
+ const workspaces = input.workspacePaths || previous.workspaces;
75
+ if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p)))
76
+ throw new Error('훅의 작업 저장소 경로가 없습니다.');
77
+ const conversationId = input.conversationId;
78
+ if (typeof conversationId !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId))
79
+ throw new Error('훅의 대화 UUID가 없습니다.');
80
+ const transcript = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain', conversationId, '.system_generated', 'logs', 'transcript.jsonl');
81
+ if (path.resolve(input.transcriptPath || '') !== transcript)
82
+ throw new Error('훅의 대화 기록 경로가 다릅니다.');
83
+ const idle = event === 'Stop' && input.fullyIdle === true;
84
+ writeJSON(target(id), { ...previous, ...identity, id, provider: 'agy', transport: 'console', conversationId,
85
+ transcript, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || previous.model,
86
+ event, state: idle ? 'idle' : 'running', fullyIdle: idle, registeredAt: new Date().toISOString() });
87
+ return { id };
68
88
  }
69
- export function externalStatus(s) {
70
- s = readJSON(target(s.id))
71
- const live = consoleCall(s.pid, 'snapshot', { startedAt: s.startedAt })
72
- const state = consoleIdle(live.screen) ? 'idle' : 'unknown'
73
- writeJSON(target(s.id), { ...readJSON(target(s.id)), state, fullyIdle: state === 'idle' })
74
- return { id: s.id, state }
89
+ export function externalStatus(session) {
90
+ const s = readJSON(target(session.id));
91
+ const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
92
+ const state = consoleIdle(live.screen) ? 'idle' : 'unknown';
93
+ writeJSON(target(s.id), { ...readJSON(target(s.id)), state, fullyIdle: state === 'idle' });
94
+ return { id: s.id, state };
95
+ }
96
+ function consolePID(s) {
97
+ if (!s.pid || !s.startedAt)
98
+ throw new Error('등록된 콘솔 프로세스 신원이 없습니다.');
99
+ return s.pid;
75
100
  }
76
101
  export function checkConsole(s) {
77
- const live = consoleCall(s.pid, 'snapshot', { startedAt: s.startedAt })
78
- if (!consoleIdle(live.screen)) throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.')
79
- return live
102
+ const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
103
+ if (!consoleIdle(live.screen))
104
+ throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.');
105
+ return live;
80
106
  }
81
107
  export function validateConsolePrompt(prompt, from) {
82
- if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt.replace(/\r\n/g, '\n')) || /[\x00-\x1f\x7f]/.test(from)) throw new Error('콘솔 요청에는 제어 문자를 포함할 수 없습니다.')
108
+ if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt.replace(/\r\n/g, '\n')) || /[\x00-\x1f\x7f]/.test(from))
109
+ throw new Error('콘솔 요청에는 제어 문자를 포함할 수 없습니다.');
83
110
  }
84
111
  export function findConsoleTranscript(s, request) {
85
- if (s.transcript) return s
86
- const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain')
87
- if (!fs.existsSync(brain)) return s
88
- const marker = `[axMap request ${request.id}]`
89
- const matches = []
90
- for (const entry of fs.readdirSync(brain, { withFileTypes: true })) {
91
- if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name)) continue
92
- const log = path.join(brain, entry.name, '.system_generated', 'logs', 'transcript.jsonl')
93
- let stat
94
- try { stat = fs.statSync(log) } catch { continue }
95
- if (stat.mtimeMs < Date.parse(request.createdAt) || stat.size > 32 * 1024 * 1024) continue
96
- const records = fs.readFileSync(log, 'utf8').split('\n').slice(0, -1)
97
- if (records.some(line => {
98
- try { const e = JSON.parse(line); return e.type === 'USER_INPUT' && String(e.content).includes(marker) }
99
- catch { return false }
100
- })) matches.push({ conversationId: entry.name, transcript: log })
101
- }
102
- if (matches.length !== 1) return s
103
- const updated = { ...readJSON(target(s.id)), ...matches[0] }
104
- writeJSON(target(s.id), updated)
105
- return updated
112
+ if (s.transcript)
113
+ return s;
114
+ const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain');
115
+ if (!fs.existsSync(brain))
116
+ return s;
117
+ const marker = `[axMap request ${request.id}]`;
118
+ const matches = [];
119
+ for (const entry of fs.readdirSync(brain, { withFileTypes: true })) {
120
+ if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name))
121
+ continue;
122
+ const log = path.join(brain, entry.name, '.system_generated', 'logs', 'transcript.jsonl');
123
+ let stat;
124
+ try {
125
+ stat = fs.statSync(log);
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ if (stat.mtimeMs < Date.parse(request.createdAt) || stat.size > 32 * 1024 * 1024)
131
+ continue;
132
+ const records = fs.readFileSync(log, 'utf8').split('\n').slice(0, -1);
133
+ if (records.some(line => {
134
+ try {
135
+ const e = JSON.parse(line);
136
+ return e.type === 'USER_INPUT' && String(e.content).includes(marker);
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }))
142
+ matches.push({ conversationId: entry.name, transcript: log });
143
+ }
144
+ if (matches.length !== 1)
145
+ return s;
146
+ const updated = { ...readJSON(target(s.id)), ...matches[0] };
147
+ writeJSON(target(s.id), updated);
148
+ return updated;
106
149
  }
107
- export function submitConsole(s, request) {
108
- validateConsolePrompt(request.prompt, request.from)
109
- const live = checkConsole(s)
110
- // Bracketed paste preserves multiline prompts and prevents embedded Enter keys
111
- // from becoming separate submissions. Only the final CR submits the message.
112
- const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`
113
- consoleCall(s.pid, 'send', { startedAt: s.startedAt, expectedScreen: live.screen, text })
114
- writeJSON(target(s.id), { ...readJSON(target(s.id)), state: 'running', fullyIdle: false })
115
- return { accepted: true, pid: s.pid }
150
+ export function submitConsole(s, request, preflight) {
151
+ validateConsolePrompt(request.prompt, request.from);
152
+ const live = preflight || checkConsole(s);
153
+ // Bracketed paste preserves multiline prompts and prevents embedded Enter keys
154
+ // from becoming separate submissions. Only the final CR submits the message.
155
+ 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, text });
157
+ writeJSON(target(s.id), { ...readJSON(target(s.id)), state: 'running', fullyIdle: false });
158
+ return { accepted: true, pid: s.pid };
116
159
  }
@@ -0,0 +1,128 @@
1
+ /** Attach to an existing Windows console; never launch or resume a model. */
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import { createHash } from 'node:crypto'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
9
+ import { nativeConsoleHelper } from './windows-console.mjs'
10
+ import type { SessionRecord, RequestRecord, ConsoleSnapshot, HookInput, TranscriptEntry } from './session-types.mjs'
11
+
12
+ const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url))
13
+ export function consoleCall(pid: number, action?: 'snapshot' | 'send', payload?: Record<string, unknown>): ConsoleSnapshot
14
+ export function consoleCall(pid: number, action: 'ancestor', payload?: Record<string, unknown>): ConsoleSnapshot | { ignored: true }
15
+ export function consoleCall(pid: number, action = 'snapshot', payload: Record<string, unknown> = {}): ConsoleSnapshot | { ignored: true } {
16
+ if (process.platform !== 'win32') throw new Error('독립 agy 콘솔 연결은 현재 Windows에서 지원합니다.')
17
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('양의 정수 PID가 필요합니다.')
18
+ const native = nativeConsoleHelper()
19
+ const r = spawnSync(native || 'powershell.exe', native ? [String(pid), action] : ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
20
+ '-File', helper, '-TargetPid', String(pid), '-Action', action], {
21
+ input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
22
+ })
23
+ let data: (Omit<Partial<ConsoleSnapshot>, 'ignored'> & { error?: string; ignored?: boolean }) | undefined
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() || '기존 콘솔 연결 실패')
26
+ if (action === 'ancestor' && data.ignored) return { ignored: true }
27
+ if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string')) throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.')
28
+ return data as ConsoleSnapshot
29
+ }
30
+ function identityID({ pid, startedAt }: Pick<ConsoleSnapshot, 'pid' | 'startedAt'>): string {
31
+ const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).digest('hex')
32
+ 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
+ const target = (id: string) => path.join(bridgeHome(), 'sessions', `${id}.json`)
35
+ export function consoleIdle(screen: string): boolean {
36
+ // Use the last input box only. Old answers may contain quoted prompt boxes.
37
+ const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)]
38
+ if (borders.length < 2) return false
39
+ const top = borders.at(-2), bottom = borders.at(-1)
40
+ if (!top || !bottom) return false
41
+ const input = screen.slice(top.index + top[0].length, bottom.index).trim()
42
+ const footer = screen.slice(bottom.index + bottom[0].length)
43
+ return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer)
44
+ }
45
+ export function attachConsole({ pid, cwd }: { pid: number; cwd: string }) {
46
+ const live = consoleCall(pid)
47
+ const id = identityID(live)
48
+ const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
49
+ const repo = repoKey(cwd)
50
+ 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,
53
+ workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
54
+ state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' }
55
+ writeJSON(target(id), s)
56
+ return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces }
57
+ }
58
+ export function registerExternalHook(input: HookInput, event: string) {
59
+ if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
60
+ const identity = consoleCall(process.pid, 'ancestor')
61
+ if (identity.ignored) return {}
62
+ const id = identityID(identity)
63
+ const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
64
+ const workspaces = input.workspacePaths || previous.workspaces
65
+ if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) throw new Error('훅의 작업 저장소 경로가 없습니다.')
66
+ const conversationId = input.conversationId
67
+ if (typeof conversationId !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId)) throw new Error('훅의 대화 UUID가 없습니다.')
68
+ const transcript = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
69
+ 'brain', conversationId, '.system_generated', 'logs', 'transcript.jsonl')
70
+ if (path.resolve(input.transcriptPath || '') !== transcript) throw new Error('훅의 대화 기록 경로가 다릅니다.')
71
+ const idle = event === 'Stop' && input.fullyIdle === true
72
+ writeJSON(target(id), { ...previous, ...identity, id, provider: 'agy', transport: 'console', conversationId,
73
+ transcript, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || previous.model,
74
+ event, state: idle ? 'idle' : 'running', fullyIdle: idle, registeredAt: new Date().toISOString() })
75
+ return { id }
76
+ }
77
+ export function externalStatus(session: Pick<SessionRecord, 'id'>) {
78
+ const s = readJSON<SessionRecord>(target(session.id))
79
+ const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
80
+ const state: 'idle' | 'unknown' = consoleIdle(live.screen) ? 'idle' : 'unknown'
81
+ writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state, fullyIdle: state === 'idle' })
82
+ return { id: s.id, state }
83
+ }
84
+ function consolePID(s: SessionRecord): number {
85
+ if (!s.pid || !s.startedAt) throw new Error('등록된 콘솔 프로세스 신원이 없습니다.')
86
+ return s.pid
87
+ }
88
+ export function checkConsole(s: SessionRecord): ConsoleSnapshot {
89
+ const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
90
+ if (!consoleIdle(live.screen)) throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.')
91
+ return live
92
+ }
93
+ export function validateConsolePrompt(prompt: string, from: string): void {
94
+ if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt.replace(/\r\n/g, '\n')) || /[\x00-\x1f\x7f]/.test(from)) throw new Error('콘솔 요청에는 제어 문자를 포함할 수 없습니다.')
95
+ }
96
+ export function findConsoleTranscript(s: SessionRecord, request: RequestRecord): SessionRecord {
97
+ if (s.transcript) return s
98
+ const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain')
99
+ if (!fs.existsSync(brain)) return s
100
+ const marker = `[axMap request ${request.id}]`
101
+ const matches = []
102
+ for (const entry of fs.readdirSync(brain, { withFileTypes: true })) {
103
+ if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name)) continue
104
+ const log = path.join(brain, entry.name, '.system_generated', 'logs', 'transcript.jsonl')
105
+ let stat
106
+ try { stat = fs.statSync(log) } catch { continue }
107
+ if (stat.mtimeMs < Date.parse(request.createdAt) || stat.size > 32 * 1024 * 1024) continue
108
+ const records = fs.readFileSync(log, 'utf8').split('\n').slice(0, -1)
109
+ if (records.some(line => {
110
+ try { const e = JSON.parse(line) as TranscriptEntry; return e.type === 'USER_INPUT' && String(e.content).includes(marker) }
111
+ catch { return false }
112
+ })) matches.push({ conversationId: entry.name, transcript: log })
113
+ }
114
+ if (matches.length !== 1) return s
115
+ const updated = { ...readJSON<SessionRecord>(target(s.id)), ...matches[0] }
116
+ writeJSON(target(s.id), updated)
117
+ return updated
118
+ }
119
+ export function submitConsole(s: SessionRecord, request: RequestRecord, preflight?: ConsoleSnapshot) {
120
+ validateConsolePrompt(request.prompt, request.from)
121
+ const live = preflight || checkConsole(s)
122
+ // Bracketed paste preserves multiline prompts and prevents embedded Enter keys
123
+ // from becoming separate submissions. Only the final CR submits the message.
124
+ 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, text })
126
+ writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state: 'running', fullyIdle: false })
127
+ return { accepted: true, pid: s.pid }
128
+ }