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,178 +1,334 @@
1
+ // Generated from src/managed-session.mts. Edit the .mts source and run npm run build.
1
2
  /** Explicitly launched persistent CLI sessions. No transcript scraping, global
2
3
  * hook edits, per-message process spawning, or inference while the queue is idle. */
3
- import fs from 'node:fs'
4
- import path from 'node:path'
5
- import net from 'node:net'
6
- import { spawn } from 'node:child_process'
7
- import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
8
- import { bridgeHome, repoKey, writeJSON } from './bridge.mjs'
9
- import { normalizeUsage, usageDelta } from './session-usage.mjs'
10
-
11
- const pipeFor = id => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
12
- const uuid = value => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '')
13
-
14
- export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '',
15
- cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => {} }) {
16
- if (!['codex', 'agy'].includes(provider)) throw new Error('지원하는 세션은 codex 또는 agy입니다.')
17
- if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 토큰/시간 한도가 필요합니다.')
18
- if (typeof role !== 'string' || role.length > 1000) throw new Error('역할은 1000자 이하여야 합니다.')
19
- const id = randomUUID(), token = randomBytes(32).toString('hex')
20
- const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`)
21
- const s = { id, token, provider, transport: 'stdio', role, model: model || null,
22
- workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
23
- registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens }
24
- const jobs = new Map(), calls = new Map()
25
- let child, sequence = 0, pending = null, closed = false, buffer = '', stderr = '', turnTimer
26
- const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionPath, s) }
27
- const emit = event => onEvent({ session: id, ...event })
28
- const write = message => {
29
- if (closed || !child?.stdin.writable) throw new Error('세션 입력 연결이 종료됐습니다.')
30
- child.stdin.write(JSON.stringify(message) + '\n')
31
- }
32
- const rpc = (method, params) => new Promise((resolve, reject) => {
33
- const callId = ++sequence
34
- const timer = setTimeout(() => { calls.delete(callId); reject(new Error(`${method}: 응답 시간 초과`)) }, 15000)
35
- calls.set(callId, { resolve, reject, timer })
36
- try { write({ id: callId, method, params }) } catch (e) { clearTimeout(timer); calls.delete(callId); reject(e) }
37
- })
38
- const finish = (state, result, error, rawUsage) => {
39
- if (!pending) return
40
- clearTimeout(turnTimer)
41
- const usage = normalizeUsage(provider, rawUsage)
42
- Object.assign(pending, { state, result, error, usage: pending.baselineKnown ? usageDelta(usage, pending.baseline) : null,
43
- completedAt: new Date().toISOString(), conversationId: s.conversationId })
44
- delete pending.baseline
45
- delete pending.baselineKnown
46
- if (usage) s.usage = usage
47
- s.usageKnown = usage !== null
48
- emit({ event: 'result', request: { ...pending } })
49
- pending = null; s.state = 'idle'; s.fullyIdle = true; delete s.pendingApproval; save()
50
- }
51
- const shutdown = reason => {
52
- if (closed) return
53
- finish('unknown', undefined, reason)
54
- closed = true; clearTimeout(turnTimer)
55
- for (const c of calls.values()) { clearTimeout(c.timer); c.reject(new Error(reason)) }
56
- calls.clear()
57
- s.state = 'closed'; s.fullyIdle = false; save()
58
- child?.stdin.destroy(); child?.kill(); server.close()
59
- }
60
- const handle = message => {
61
- const offered = Buffer.from(typeof message.token === 'string' ? message.token : '')
62
- const expected = Buffer.from(token)
63
- if (offered.length !== expected.length || !timingSafeEqual(offered, expected)) throw new Error('로컬 세션 인증 실패')
64
- if (message.action === 'status') return { id, state: s.state, usage: s.usage, pendingApproval: s.pendingApproval }
65
- if (message.action === 'result') {
66
- const result = jobs.get(message.id)
67
- if (!result) throw new Error('이 세션에 해당 요청이 없습니다.')
68
- const { baseline, baselineKnown, ...publicResult } = result
69
- return publicResult
70
- }
71
- if (message.action === 'answer') {
72
- const approval = s.pendingApproval
73
- if (!approval || String(approval.id) !== String(message.id)) throw new Error('현재 대기 중인 승인 요청이 아닙니다.')
74
- if (!['accept', 'decline', 'cancel'].includes(message.decision)) throw new Error('이번 실행의 accept, decline 또는 cancel만 지원합니다.')
75
- write({ id: approval.id, result: { decision: message.decision } })
76
- delete s.pendingApproval; s.state = 'running'; save(); return { answered: true }
77
- }
78
- if (message.action !== 'submit') throw new Error('지원하지 않는 로컬 세션 요청입니다.')
79
- if (closed || s.state !== 'idle' || pending) throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.')
80
- if (!s.usageKnown) throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.')
81
- if (s.usage && s.usage.totalTokens >= maxTokens) throw new Error('세션 토큰 한도에 도달했습니다.')
82
- const request = message.request
83
- if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw new Error('요청 형식이 올바르지 않습니다.')
84
- pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown, result: '' }
85
- jobs.set(request.id, pending); s.state = 'running'; s.fullyIdle = false; save()
86
- const prompt = `[axMap request ${request.id}]\n요청자: ${request.from || 'axmap'}\n${role ? `역할: ${role}\n` : ''}${request.prompt}`
87
- turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs)
88
- if (provider === 'agy') write({ event: 'user', message: { content: prompt } })
89
- else rpc('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], effort: 'low' })
90
- .then(r => { if (pending?.id === request.id) pending.turnId = r.turn.id })
91
- .catch(e => shutdown(e.message))
92
- return { accepted: true, session: id }
93
- }
94
- const server = net.createServer(socket => {
95
- socket.setEncoding('utf8')
96
- let input = '', handled = false
97
- socket.setTimeout(15000, () => socket.destroy()); socket.on('error', () => {})
98
- socket.on('data', chunk => {
99
- if (handled) return
100
- input += chunk
101
- if (Buffer.byteLength(input) > 65536) return socket.destroy()
102
- const end = input.indexOf('\n'); if (end < 0) return
103
- handled = true
104
- try { socket.end(JSON.stringify({ result: handle(JSON.parse(input.slice(0, end))) }) + '\n') }
105
- catch (e) { socket.end(JSON.stringify({ error: e.message }) + '\n') }
106
- })
107
- })
108
- fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 })
109
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve) })
110
- if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
111
- save()
112
- const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
113
- : ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])])
114
- child = spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
115
- env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } })
116
- child.stdin.on('error', e => shutdown(e.message))
117
- const exited = new Promise(resolve => {
118
- child.once('error', e => { shutdown(e.message); resolve({ code: 1, error: e.message }) })
119
- child.once('exit', (code, signal) => { shutdown(`CLI가 종료됐습니다 (${code ?? signal}). ${stderr.slice(-1000)}`); resolve({ code, signal }) })
120
- })
121
- child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192) })
122
- child.stdout.setEncoding('utf8')
123
- child.stdout.on('data', chunk => {
124
- buffer += chunk
125
- if (Buffer.byteLength(buffer) > 4 * 1024 * 1024) return shutdown('CLI 응답 크기 제한 초과')
126
- while (buffer.includes('\n') && !closed) {
127
- const end = buffer.indexOf('\n'), line = buffer.slice(0, end); buffer = buffer.slice(end + 1)
128
- if (!line.trim()) continue
129
- try {
130
- const m = JSON.parse(line)
131
- if (provider === 'agy') {
132
- if (m.event === 'init') { s.conversationId = m.conversation_id; s.state = 'idle'; s.fullyIdle = true; save(); emit({ event: 'ready', conversationId: s.conversationId }) }
133
- if (m.event === 'result' && pending) {
134
- const r = m.result
135
- if (r.conversation_id !== s.conversationId) throw new Error('agy 대화 ID가 바뀌었습니다.')
136
- finish(r.status === 'SUCCESS' ? 'completed' : 'failed', r.response, r.error, r.usage)
137
- }
138
- continue
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import net from 'node:net';
7
+ import { spawn } from 'node:child_process';
8
+ import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
9
+ import { bridgeHome, repoKey, writeJSON } from './bridge.mjs';
10
+ import { normalizeUsage, usageDelta } from './session-usage.mjs';
11
+ import { errorMessage } from './session-types.mjs';
12
+ import { connectCodexProxy } from './codex-connection.mjs';
13
+ const pipeFor = (id) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
14
+ const uuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '');
15
+ export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false, cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
16
+ if (!['codex', 'agy'].includes(provider))
17
+ throw new Error('지원하는 세션은 codex 또는 agy입니다.');
18
+ if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
19
+ throw new Error('양의 토큰/시간 한도가 필요합니다.');
20
+ if (typeof role !== 'string' || role.length > 1000)
21
+ throw new Error('역할은 1000자 이하여야 합니다.');
22
+ if (existingThreadId && (provider !== 'codex' || !uuid(existingThreadId) || !socketPath || model || args))
23
+ throw new Error('기존 Codex thread와 로컬 socket이 필요하며 모델·실행 인수를 바꾸지 않습니다.');
24
+ const id = randomUUID(), token = randomBytes(32).toString('hex');
25
+ const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`);
26
+ const s = { id, token, provider, transport: 'stdio', role, model: model || null,
27
+ workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
28
+ registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens };
29
+ const jobs = new Map();
30
+ let attachedUsageOrigin = null;
31
+ let initialThreadState = 'idle';
32
+ const calls = new Map();
33
+ let child, sequence = 0, pending = null, closed = false, buffer = '', stderr = '', turnTimer;
34
+ const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionPath, s); };
35
+ const emit = (event) => onEvent({ session: id, ...event });
36
+ const write = (message) => {
37
+ if (closed || !child?.stdin.writable)
38
+ throw new Error('세션 입력 연결이 종료됐습니다.');
39
+ child.stdin.write(JSON.stringify(message) + '\n');
40
+ };
41
+ const rpc = (method, params) => new Promise((resolve, reject) => {
42
+ const callId = ++sequence;
43
+ const timer = setTimeout(() => { calls.delete(callId); reject(new Error(`${method}: 응답 시간 초과`)); }, 15000);
44
+ calls.set(callId, { resolve: value => resolve(value), reject, timer });
45
+ try {
46
+ write({ id: callId, method, params });
47
+ }
48
+ catch (e) {
49
+ clearTimeout(timer);
50
+ calls.delete(callId);
51
+ reject(e);
52
+ }
53
+ });
54
+ const finish = (state, result, error, rawUsage) => {
55
+ if (!pending)
56
+ return;
57
+ clearTimeout(turnTimer);
58
+ const usage = normalizeUsage(provider, rawUsage);
59
+ Object.assign(pending, { state, result, error, usage: pending.baselineKnown ? usageDelta(usage, pending.baseline) : null,
60
+ completedAt: new Date().toISOString(), conversationId: s.conversationId });
61
+ delete pending.baseline;
62
+ delete pending.baselineKnown;
63
+ if (usage) {
64
+ s.usage = usage;
65
+ if (existingThreadId && !attachedUsageOrigin)
66
+ attachedUsageOrigin = usage;
67
+ }
68
+ s.usageKnown = usage !== null;
69
+ emit({ event: 'result', request: { ...pending } });
70
+ pending = null;
71
+ s.state = 'idle';
72
+ s.fullyIdle = true;
73
+ delete s.pendingApproval;
74
+ save();
75
+ };
76
+ const shutdown = (reason) => {
77
+ if (closed)
78
+ return;
79
+ finish('unknown', undefined, reason);
80
+ closed = true;
81
+ clearTimeout(turnTimer);
82
+ for (const c of calls.values()) {
83
+ clearTimeout(c.timer);
84
+ c.reject(new Error(reason));
85
+ }
86
+ calls.clear();
87
+ s.state = 'closed';
88
+ s.fullyIdle = false;
89
+ save();
90
+ child?.stdin.destroy();
91
+ child?.kill();
92
+ server.close();
93
+ };
94
+ let checkingSubmission = false;
95
+ const handle = async (message) => {
96
+ const offered = Buffer.from(typeof message.token === 'string' ? message.token : '');
97
+ const expected = Buffer.from(token);
98
+ if (offered.length !== expected.length || !timingSafeEqual(offered, expected))
99
+ throw new Error('로컬 세션 인증 실패');
100
+ if (message.action === 'notice') {
101
+ if (!existingThreadId || provider !== 'codex' || typeof message.text !== 'string' || !message.text.trim() || Buffer.byteLength(message.text) > 8192)
102
+ throw new Error('기존 Codex 대화에 1~8192바이트 안내만 기록할 수 있습니다.');
103
+ const noticeId = randomUUID();
104
+ await rpc('thread/inject_items', { threadId: existingThreadId, items: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: `[axMap notice ${noticeId}; context only, no new model turn]\n${message.text}` }] }] });
105
+ writeJSON(path.join(bridgeHome(), 'notices', `${noticeId}.json`), { id: noticeId, to: id, conversationId: existingThreadId, repo: repoKey(cwd), text: message.text, createdAt: new Date().toISOString(), state: 'context_recorded', modelInvoked: false });
106
+ return { noticeId, conversationId: existingThreadId, state: 'context_recorded', modelInvoked: false };
107
+ }
108
+ if (message.action === 'status')
109
+ return { id, state: s.state, usage: s.usage, pendingApproval: s.pendingApproval };
110
+ if (message.action === 'result') {
111
+ const result = jobs.get(message.id);
112
+ if (!result)
113
+ throw new Error('이 세션에 해당 요청이 없습니다.');
114
+ const { baseline, baselineKnown, ...publicResult } = result;
115
+ return publicResult;
116
+ }
117
+ if (message.action === 'answer') {
118
+ const approval = s.pendingApproval;
119
+ if (!approval || String(approval.id) !== String(message.id))
120
+ throw new Error('현재 대기 중인 승인 요청이 아닙니다.');
121
+ if (!['accept', 'decline', 'cancel'].includes(message.decision))
122
+ throw new Error('이번 실행의 accept, decline 또는 cancel만 지원합니다.');
123
+ write({ id: approval.id, result: { decision: message.decision } });
124
+ delete s.pendingApproval;
125
+ s.state = 'running';
126
+ save();
127
+ return { answered: true };
128
+ }
129
+ if (message.action !== 'submit')
130
+ throw new Error('지원하지 않는 로컬 세션 요청입니다.');
131
+ if (jobs.has(message.request?.id))
132
+ throw new Error('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.');
133
+ if (closed || s.state !== 'idle' || pending)
134
+ throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.');
135
+ if (existingThreadId) {
136
+ if (checkingSubmission)
137
+ throw new Error('다른 요청의 상태를 확인 중입니다.');
138
+ checkingSubmission = true;
139
+ try {
140
+ const read = await rpc('thread/read', { threadId: existingThreadId });
141
+ if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle')
142
+ throw new Error('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.');
143
+ }
144
+ finally {
145
+ checkingSubmission = false;
146
+ }
139
147
  }
140
- if (m.id !== undefined && !m.method) {
141
- const call = calls.get(m.id)
142
- if (call) { clearTimeout(call.timer); calls.delete(m.id); m.error ? call.reject(new Error(m.error.message)) : call.resolve(m.result) }
143
- continue
148
+ if (!s.usageKnown)
149
+ throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.');
150
+ if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens)
151
+ throw new Error('세션 토큰 한도에 도달했습니다.');
152
+ const request = message.request;
153
+ if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768)
154
+ throw new Error('요청 형식이 올바르지 않습니다.');
155
+ pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown && (!existingThreadId || !!s.usage), result: '' };
156
+ jobs.set(request.id, pending);
157
+ s.state = 'running';
158
+ s.fullyIdle = false;
159
+ save();
160
+ const prompt = `[axMap request ${request.id}]\n요청자: ${request.from || 'axmap'}\n${role ? `역할: ${role}\n` : ''}${request.prompt}`;
161
+ turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs);
162
+ if (provider === 'agy')
163
+ write({ event: 'user', message: { content: prompt } });
164
+ else
165
+ rpc('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], ...(!existingThreadId ? { effort: 'low' } : {}) })
166
+ .then(r => { if (pending?.id === request.id)
167
+ pending.turnId = r.turn.id; })
168
+ .catch(e => shutdown(e.message));
169
+ return { accepted: true, session: id };
170
+ };
171
+ const server = net.createServer(socket => {
172
+ socket.setEncoding('utf8');
173
+ let input = '', handled = false;
174
+ socket.setTimeout(15000, () => socket.destroy());
175
+ socket.on('error', () => { });
176
+ socket.on('data', chunk => {
177
+ if (handled)
178
+ return;
179
+ input += chunk;
180
+ if (Buffer.byteLength(input) > 65536)
181
+ return socket.destroy();
182
+ const end = input.indexOf('\n');
183
+ if (end < 0)
184
+ return;
185
+ handled = true;
186
+ try {
187
+ void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n'));
188
+ }
189
+ catch (e) {
190
+ socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n');
191
+ }
192
+ });
193
+ });
194
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
195
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve); });
196
+ if (process.platform !== 'win32')
197
+ fs.chmodSync(pipeFor(id), 0o600);
198
+ save();
199
+ const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
200
+ : ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])]);
201
+ child = existingThreadId ? connectCodexProxy({ executable, prefix, socketPath: socketPath, cwd }) : spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
202
+ env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } });
203
+ child.stdin.on('error', e => shutdown(e.message));
204
+ const exited = new Promise(resolve => {
205
+ child.once('error', e => { shutdown(e.message); resolve({ code: 1, error: e.message }); });
206
+ child.once('exit', (code, signal) => { shutdown(`CLI가 종료됐습니다 (${code ?? signal}). ${stderr.slice(-1000)}`); resolve({ code, signal }); });
207
+ });
208
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192); });
209
+ child.stdout.setEncoding('utf8');
210
+ child.stdout.on('error', e => shutdown(e.message));
211
+ child.stdout.on('data', chunk => {
212
+ buffer += chunk;
213
+ if (Buffer.byteLength(buffer) > 4 * 1024 * 1024)
214
+ return shutdown('CLI 응답 크기 제한 초과');
215
+ while (buffer.includes('\n') && !closed) {
216
+ const end = buffer.indexOf('\n'), line = buffer.slice(0, end);
217
+ buffer = buffer.slice(end + 1);
218
+ if (!line.trim())
219
+ continue;
220
+ try {
221
+ const m = JSON.parse(line);
222
+ if (provider === 'agy') {
223
+ if (m.event === 'init') {
224
+ s.conversationId = m.conversation_id;
225
+ s.state = 'idle';
226
+ s.fullyIdle = true;
227
+ save();
228
+ emit({ event: 'ready', conversationId: s.conversationId });
229
+ }
230
+ if (m.event === 'result' && pending) {
231
+ const r = m.result;
232
+ if (r.conversation_id !== s.conversationId)
233
+ throw new Error('agy 대화 ID가 바뀌었습니다.');
234
+ finish(r.status === 'SUCCESS' ? 'completed' : 'failed', r.response, r.error, r.usage);
235
+ }
236
+ continue;
237
+ }
238
+ if (m.id !== undefined && !m.method) {
239
+ const call = calls.get(m.id);
240
+ if (call) {
241
+ clearTimeout(call.timer);
242
+ calls.delete(m.id);
243
+ m.error ? call.reject(new Error(m.error.message)) : call.resolve(m.result);
244
+ }
245
+ continue;
246
+ }
247
+ if (m.id !== undefined && m.method) {
248
+ if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(m.method)) {
249
+ s.pendingApproval = { id: m.id, method: m.method, ...m.params };
250
+ s.state = 'awaiting_user';
251
+ save();
252
+ emit({ event: 'awaiting_user', request: s.pendingApproval });
253
+ }
254
+ else {
255
+ write({ id: m.id, error: { code: -32601, message: '이 연결에서 지원하지 않는 사용자 입력입니다. 대화형 CLI에서 처리하세요.' } });
256
+ }
257
+ continue;
258
+ }
259
+ if (m.params?.threadId !== s.conversationId)
260
+ continue;
261
+ if (m.method === 'thread/status/changed' && !pending) {
262
+ const status = m.params?.status;
263
+ s.state = status?.type === 'active' ? 'running' : status?.type === 'idle' ? 'idle' : 'unknown';
264
+ s.fullyIdle = s.state === 'idle';
265
+ save();
266
+ }
267
+ if (m.method === 'thread/tokenUsage/updated') {
268
+ if (pending)
269
+ pending.rawUsage = m.params?.tokenUsage?.total;
270
+ else {
271
+ const usage = normalizeUsage(provider, m.params?.tokenUsage?.total);
272
+ if (usage) {
273
+ s.usage = usage;
274
+ s.usageKnown = true;
275
+ save();
276
+ }
277
+ }
278
+ }
279
+ if (m.method === 'item/completed' && pending && m.params?.item?.type === 'agentMessage')
280
+ pending.result = m.params.item.text;
281
+ if (m.method === 'turn/completed' && pending) {
282
+ const r = m.params?.turn;
283
+ if (!r)
284
+ throw new Error('Codex 실행 결과가 없습니다.');
285
+ if (pending.turnId && pending.turnId !== r.id)
286
+ throw new Error('Codex 실행 ID가 다릅니다.');
287
+ finish(r.status === 'completed' ? 'completed' : 'failed', pending.result, r.error?.message, pending.rawUsage);
288
+ }
289
+ }
290
+ catch (e) {
291
+ shutdown(`CLI 프로토콜 오류: ${errorMessage(e)}`);
292
+ }
144
293
  }
145
- if (m.id !== undefined && m.method) {
146
- if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(m.method)) {
147
- s.pendingApproval = { id: m.id, method: m.method, ...m.params }; s.state = 'awaiting_user'; save()
148
- emit({ event: 'awaiting_user', request: s.pendingApproval })
149
- } else { write({ id: m.id, error: { code: -32601, message: '이 연결에서 지원하지 않는 사용자 입력입니다. 대화형 CLI에서 처리하세요.' } }) }
150
- continue
294
+ });
295
+ try {
296
+ if (provider === 'codex') {
297
+ await rpc('initialize', { clientInfo: { name: 'axmap', version: '1' } });
298
+ write({ method: 'initialized' });
299
+ if (existingThreadId) {
300
+ const read = await rpc('thread/read', { threadId: existingThreadId });
301
+ initialThreadState = read.thread.status.type;
302
+ if (read.thread.id !== existingThreadId || (initialThreadState !== 'idle' && !(observeActive && initialThreadState === 'active')))
303
+ throw new Error('기존 thread가 idle 상태가 아닙니다. 전송하거나 새 대화를 만들지 않았습니다.');
304
+ // Parent workspace sessions can explicitly join a child Git repo. Never attach across unrelated roots.
305
+ const relative = path.relative(path.resolve(read.thread.cwd), path.resolve(cwd));
306
+ if (relative.startsWith('..') || path.isAbsolute(relative))
307
+ throw new Error('기존 thread와 요청 저장소 경로가 다릅니다.');
308
+ }
309
+ const r = await rpc(existingThreadId ? 'thread/resume' : 'thread/start', existingThreadId
310
+ ? { threadId: existingThreadId, excludeTurns: true }
311
+ : { cwd: path.resolve(cwd), ...(model ? { model } : {}), sandbox: 'read-only', approvalPolicy: 'on-request' });
312
+ if (existingThreadId && r.thread.id !== existingThreadId)
313
+ throw new Error('기존 thread ID가 바뀌었습니다.');
314
+ s.conversationId = r.thread.id;
315
+ s.model = r.model || model || null;
316
+ s.state = initialThreadState === 'active' ? 'running' : 'idle';
317
+ s.fullyIdle = s.state === 'idle';
318
+ save();
319
+ emit({ event: 'ready', conversationId: s.conversationId });
151
320
  }
152
- if (m.params?.threadId !== s.conversationId) continue
153
- if (m.method === 'thread/tokenUsage/updated' && pending) pending.rawUsage = m.params.tokenUsage.total
154
- if (m.method === 'item/completed' && pending && m.params.item.type === 'agentMessage') pending.result = m.params.item.text
155
- if (m.method === 'turn/completed' && pending) {
156
- const r = m.params.turn
157
- if (pending.turnId && pending.turnId !== r.id) throw new Error('Codex 실행 ID가 다릅니다.')
158
- finish(r.status === 'completed' ? 'completed' : 'failed', pending.result, r.error?.message, pending.rawUsage)
321
+ else {
322
+ const deadline = Date.now() + 30000;
323
+ while (s.state === 'starting' && !closed && Date.now() < deadline)
324
+ await new Promise(r => setTimeout(r, 25));
325
+ if (s.state !== 'idle')
326
+ throw new Error(`agy 시작 실패: ${stderr.slice(-1000) || s.state}`);
159
327
  }
160
- } catch (e) { shutdown(`CLI 프로토콜 오류: ${e.message}`) }
161
328
  }
162
- })
163
- try {
164
- if (provider === 'codex') {
165
- await rpc('initialize', { clientInfo: { name: 'axmap', version: '1' } })
166
- write({ method: 'initialized' })
167
- const r = await rpc('thread/start', { cwd: path.resolve(cwd), ...(model ? { model } : {}),
168
- sandbox: 'read-only', approvalPolicy: 'on-request' })
169
- s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = 'idle'; s.fullyIdle = true; save()
170
- emit({ event: 'ready', conversationId: s.conversationId })
171
- } else {
172
- const deadline = Date.now() + 30000
173
- while (s.state === 'starting' && !closed && Date.now() < deadline) await new Promise(r => setTimeout(r, 25))
174
- if (s.state !== 'idle') throw new Error(`agy 시작 실패: ${stderr.slice(-1000) || s.state}`)
329
+ catch (e) {
330
+ shutdown(errorMessage(e));
331
+ throw e;
175
332
  }
176
- } catch (e) { shutdown(e.message); throw e }
177
- return { id, pid: child.pid, conversationId: s.conversationId, exited, close: () => shutdown('사용자가 관리형 연결을 종료했습니다.') }
333
+ return { id, pid: child.pid, conversationId: s.conversationId, exited, close: () => shutdown('사용자가 관리형 연결을 종료했습니다.') };
178
334
  }