axmap-cli 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/mcp/server.mjs +1 -1
- package/package.json +1 -1
- package/src/bridge.mjs +151 -11
- package/src/bridge.mts +104 -10
- 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 +4 -4
- package/src/pipeline-mcp.mts +4 -4
- package/src/session-dialogue.mjs +21 -5
- package/src/session-dialogue.mts +22 -5
- package/src/session-types.mts +9 -5
- package/src/session-usage.mjs +16 -0
- package/src/session-usage.mts +17 -1
- package/src/terminal-bridge.mjs +16 -8
- package/src/terminal-bridge.mts +16 -9
- package/tools/bridge.mjs +14 -6
- package/tools/console-queue.mjs +3 -0
- package/tools/run.mjs +3 -3
- package/tools/setup.mjs +1 -1
- package/tools/windows-console.cs +24 -8
- package/tools/windows-console.ps1 +14 -5
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
|
}
|
package/src/managed-session.mjs
CHANGED
|
@@ -10,12 +10,16 @@ import { bridgeHome, hostPipe, repoKey, writeJSON } from './bridge.mjs';
|
|
|
10
10
|
import { normalizeUsage, usageDelta } from './session-usage.mjs';
|
|
11
11
|
import { errorMessage } from './session-types.mjs';
|
|
12
12
|
import { connectCodexProxy } from './codex-connection.mjs';
|
|
13
|
+
const CLAUDE_USAGE_KEYS = ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens', 'thinking_tokens'];
|
|
13
14
|
// 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
|
|
14
15
|
const pipeFor = hostPipe;
|
|
15
16
|
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 || '');
|
|
16
|
-
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false, cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
|
|
17
|
-
if (!['codex', 'agy'].includes(provider))
|
|
18
|
-
throw new Error('지원하는 세션은 codex 또는
|
|
17
|
+
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false, permissionMode = 'default', cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
|
|
18
|
+
if (!['codex', 'agy', 'claude'].includes(provider))
|
|
19
|
+
throw new Error('지원하는 세션은 codex, agy 또는 claude입니다.');
|
|
20
|
+
// 읽기 전용이 기본이다(Codex 의 sandbox read-only 와 같은 자리). 고쳐도 되는 세션은 사람이 serve 할 때 고른다.
|
|
21
|
+
if (!['default', 'plan', 'acceptEdits'].includes(permissionMode))
|
|
22
|
+
throw new Error('claude 권한은 default, plan 또는 acceptEdits입니다.');
|
|
19
23
|
if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
20
24
|
throw new Error('양의 토큰/시간 한도가 필요합니다.');
|
|
21
25
|
if (typeof role !== 'string' || role.length > 1000)
|
|
@@ -28,7 +32,26 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
28
32
|
workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false, hostPid: process.pid,
|
|
29
33
|
registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens };
|
|
30
34
|
const jobs = new Map();
|
|
35
|
+
const queue = [];
|
|
36
|
+
const persistJob = (job) => writeJSON(path.join(bridgeHome(), 'jobs', `${job.id}.json`), job);
|
|
37
|
+
let draining = false;
|
|
38
|
+
const drain = () => {
|
|
39
|
+
if (closed || draining || pending || s.state !== 'idle' || !queue.length)
|
|
40
|
+
return;
|
|
41
|
+
draining = true;
|
|
42
|
+
const request = queue.shift();
|
|
43
|
+
jobs.delete(request.id);
|
|
44
|
+
void handle({ action: 'submit', token, request }).catch(e => {
|
|
45
|
+
const job = { id: request.id, to: id, state: 'failed', error: errorMessage(e), completedAt: new Date().toISOString() };
|
|
46
|
+
jobs.set(request.id, job);
|
|
47
|
+
persistJob(job);
|
|
48
|
+
emit({ event: 'result', request: job });
|
|
49
|
+
}).finally(() => { draining = false; if (!pending)
|
|
50
|
+
setImmediate(drain); });
|
|
51
|
+
};
|
|
31
52
|
let attachedUsageOrigin = null;
|
|
53
|
+
// Claude Code 는 실행마다 그 실행분만 준다. 다른 provider 처럼 누적값으로 다루려고 여기서 더한다.
|
|
54
|
+
let claudeTotals = null;
|
|
32
55
|
let initialThreadState = 'idle';
|
|
33
56
|
const calls = new Map();
|
|
34
57
|
let child, sequence = 0, pending = null, closed = false, buffer = '', stderr = '', turnTimer;
|
|
@@ -67,12 +90,14 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
67
90
|
attachedUsageOrigin = usage;
|
|
68
91
|
}
|
|
69
92
|
s.usageKnown = usage !== null;
|
|
93
|
+
persistJob(pending);
|
|
70
94
|
emit({ event: 'result', request: { ...pending } });
|
|
71
95
|
pending = null;
|
|
72
96
|
s.state = 'idle';
|
|
73
97
|
s.fullyIdle = true;
|
|
74
98
|
delete s.pendingApproval;
|
|
75
99
|
save();
|
|
100
|
+
setImmediate(drain);
|
|
76
101
|
};
|
|
77
102
|
const shutdown = (reason) => {
|
|
78
103
|
if (closed)
|
|
@@ -80,6 +105,11 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
80
105
|
finish('unknown', undefined, reason);
|
|
81
106
|
closed = true;
|
|
82
107
|
clearTimeout(turnTimer);
|
|
108
|
+
for (const request of queue.splice(0)) {
|
|
109
|
+
const job = { id: request.id, to: id, state: 'failed', error: reason, completedAt: new Date().toISOString() };
|
|
110
|
+
jobs.set(request.id, job);
|
|
111
|
+
persistJob(job);
|
|
112
|
+
}
|
|
83
113
|
for (const c of calls.values()) {
|
|
84
114
|
clearTimeout(c.timer);
|
|
85
115
|
c.reject(new Error(reason));
|
|
@@ -129,31 +159,49 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
129
159
|
}
|
|
130
160
|
if (message.action !== 'submit')
|
|
131
161
|
throw new Error('지원하지 않는 로컬 세션 요청입니다.');
|
|
162
|
+
// 🔴 여기부터 접수(pending) 전까지의 거절은 모델을 부르지 않은 확정 거절이다. refused 를 붙여 보낸 쪽이
|
|
163
|
+
// unknown 이 아니라 failed 로 기록하게 한다 — unknown 으로 남으면 그 세션의 다음 요청이 「이전 요청 미확정」으로
|
|
164
|
+
// 영영 막힌다(토큰 한도에 걸린 Claude 세션에 보낸 요청이 실제로 그렇게 막혔다).
|
|
165
|
+
const refuse = (text) => Object.assign(new Error(text), { refused: true });
|
|
132
166
|
if (jobs.has(message.request?.id))
|
|
133
|
-
throw
|
|
167
|
+
throw refuse('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.');
|
|
168
|
+
const queuedRequest = message.request;
|
|
169
|
+
if (!uuid(queuedRequest?.id) || queuedRequest.to !== id || typeof queuedRequest.prompt !== 'string' || !queuedRequest.prompt.trim() || Buffer.byteLength(queuedRequest.prompt) > 32768)
|
|
170
|
+
throw refuse('요청 형식이 올바르지 않습니다.');
|
|
171
|
+
if (message.queueIfBusy && !closed && (pending || draining || queue.length || s.state === 'running' || s.state === 'awaiting_user')) {
|
|
172
|
+
if (queue.length >= 100)
|
|
173
|
+
throw refuse('명령 큐가 가득 찼습니다.');
|
|
174
|
+
const job = { id: queuedRequest.id, to: id, state: 'queued' };
|
|
175
|
+
persistJob(job);
|
|
176
|
+
jobs.set(job.id, job);
|
|
177
|
+
queue.push(queuedRequest);
|
|
178
|
+
emit({ event: 'queued', request: job });
|
|
179
|
+
return { accepted: true, queued: true, session: id, position: queue.length };
|
|
180
|
+
}
|
|
134
181
|
if (closed || s.state !== 'idle' || pending)
|
|
135
|
-
throw
|
|
182
|
+
throw refuse('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.');
|
|
136
183
|
if (existingThreadId) {
|
|
137
184
|
if (checkingSubmission)
|
|
138
|
-
throw
|
|
185
|
+
throw refuse('다른 요청의 상태를 확인 중입니다.');
|
|
139
186
|
checkingSubmission = true;
|
|
140
187
|
try {
|
|
141
188
|
const read = await rpc('thread/read', { threadId: existingThreadId });
|
|
142
189
|
if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle')
|
|
143
|
-
throw
|
|
190
|
+
throw refuse('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.');
|
|
144
191
|
}
|
|
145
192
|
finally {
|
|
146
193
|
checkingSubmission = false;
|
|
147
194
|
}
|
|
148
195
|
}
|
|
149
196
|
if (!s.usageKnown)
|
|
150
|
-
throw
|
|
197
|
+
throw refuse('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.');
|
|
151
198
|
if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens)
|
|
152
|
-
throw
|
|
199
|
+
throw refuse('세션 토큰 한도에 도달했습니다.');
|
|
153
200
|
const request = message.request;
|
|
154
201
|
if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768)
|
|
155
|
-
throw
|
|
202
|
+
throw refuse('요청 형식이 올바르지 않습니다.');
|
|
156
203
|
pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown && (!existingThreadId || !!s.usage), result: '' };
|
|
204
|
+
persistJob(pending);
|
|
157
205
|
jobs.set(request.id, pending);
|
|
158
206
|
s.state = 'running';
|
|
159
207
|
s.fullyIdle = false;
|
|
@@ -162,6 +210,8 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
162
210
|
turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs);
|
|
163
211
|
if (provider === 'agy')
|
|
164
212
|
write({ event: 'user', message: { content: prompt } });
|
|
213
|
+
else if (provider === 'claude')
|
|
214
|
+
write({ type: 'user', message: { role: 'user', content: prompt } });
|
|
165
215
|
else
|
|
166
216
|
rpc('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], ...(!existingThreadId ? { effort: 'low' } : {}) })
|
|
167
217
|
.then(r => { if (pending?.id === request.id)
|
|
@@ -185,7 +235,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
185
235
|
return;
|
|
186
236
|
handled = true;
|
|
187
237
|
try {
|
|
188
|
-
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'));
|
|
238
|
+
void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e), ...(e?.refused ? { refused: true } : {}) }) + '\n'));
|
|
189
239
|
}
|
|
190
240
|
catch (e) {
|
|
191
241
|
socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n');
|
|
@@ -198,7 +248,9 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
198
248
|
fs.chmodSync(pipeFor(id), 0o600);
|
|
199
249
|
save();
|
|
200
250
|
const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
|
|
201
|
-
:
|
|
251
|
+
: provider === 'claude' ? ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
|
|
252
|
+
'--permission-mode', permissionMode, ...(model ? ['--model', model] : [])]
|
|
253
|
+
: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])]);
|
|
202
254
|
child = existingThreadId ? connectCodexProxy({ executable, prefix, socketPath: socketPath, cwd }) : spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
203
255
|
env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } });
|
|
204
256
|
child.stdin.on('error', e => shutdown(e.message));
|
|
@@ -220,6 +272,30 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
220
272
|
continue;
|
|
221
273
|
try {
|
|
222
274
|
const m = JSON.parse(line);
|
|
275
|
+
if (provider === 'claude') {
|
|
276
|
+
const c = m;
|
|
277
|
+
// init 은 실행마다 온다. 첫 init 의 대화 ID 를 붙잡고, 바뀌면 다른 대화에 답이 섞인 것이라 닫는다.
|
|
278
|
+
if (c.type === 'system' && c.subtype === 'init') {
|
|
279
|
+
if (!s.conversationId) {
|
|
280
|
+
s.conversationId = c.session_id;
|
|
281
|
+
save();
|
|
282
|
+
emit({ event: 'conversation', conversationId: s.conversationId });
|
|
283
|
+
}
|
|
284
|
+
else if (c.session_id !== s.conversationId)
|
|
285
|
+
throw new Error('claude 대화 ID가 바뀌었습니다.');
|
|
286
|
+
}
|
|
287
|
+
if (c.type === 'result' && pending) {
|
|
288
|
+
if (c.session_id !== s.conversationId)
|
|
289
|
+
throw new Error('claude 대화 ID가 바뀌었습니다.');
|
|
290
|
+
const turn = c.usage ? { ...c.usage, thinking_tokens: c.usage.output_tokens_details?.thinking_tokens ?? 0 } : null;
|
|
291
|
+
const known = !!turn && CLAUDE_USAGE_KEYS.every(k => Number.isSafeInteger(turn[k]) && turn[k] >= 0);
|
|
292
|
+
if (known)
|
|
293
|
+
claudeTotals = Object.fromEntries(CLAUDE_USAGE_KEYS.map(k => [k, (claudeTotals?.[k] || 0) + turn[k]]));
|
|
294
|
+
const ok = c.subtype === 'success' && !c.is_error;
|
|
295
|
+
finish(ok ? 'completed' : 'failed', c.result, ok ? undefined : String(c.result || c.subtype), known ? claudeTotals : null);
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
223
299
|
if (provider === 'agy') {
|
|
224
300
|
if (m.event === 'init') {
|
|
225
301
|
s.conversationId = m.conversation_id;
|
|
@@ -264,6 +340,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
264
340
|
s.state = status?.type === 'active' ? 'running' : status?.type === 'idle' ? 'idle' : 'unknown';
|
|
265
341
|
s.fullyIdle = s.state === 'idle';
|
|
266
342
|
save();
|
|
343
|
+
setImmediate(drain);
|
|
267
344
|
}
|
|
268
345
|
if (m.method === 'thread/tokenUsage/updated') {
|
|
269
346
|
if (pending)
|
|
@@ -317,8 +394,20 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
317
394
|
s.state = initialThreadState === 'active' ? 'running' : 'idle';
|
|
318
395
|
s.fullyIdle = s.state === 'idle';
|
|
319
396
|
save();
|
|
397
|
+
setImmediate(drain);
|
|
320
398
|
emit({ event: 'ready', conversationId: s.conversationId });
|
|
321
399
|
}
|
|
400
|
+
else if (provider === 'claude') {
|
|
401
|
+
// Claude Code 는 첫 입력이 올 때까지 아무것도 내보내지 않는다. 프로세스가 떴으면 대기로 둔다 —
|
|
402
|
+
// 대화 ID 는 첫 실행의 init 에서 정해진다. 기다리는 동안 모델은 부르지 않는다.
|
|
403
|
+
await new Promise((resolve, reject) => { child.once('spawn', () => resolve()); child.once('error', reject); });
|
|
404
|
+
if (closed)
|
|
405
|
+
throw new Error(`claude 시작 실패: ${stderr.slice(-1000)}`);
|
|
406
|
+
s.state = 'idle';
|
|
407
|
+
s.fullyIdle = true;
|
|
408
|
+
save();
|
|
409
|
+
emit({ event: 'ready', conversationId: null });
|
|
410
|
+
}
|
|
322
411
|
else {
|
|
323
412
|
const deadline = Date.now() + 30000;
|
|
324
413
|
while (s.state === 'starting' && !closed && Date.now() < deadline)
|
package/src/managed-session.mts
CHANGED
|
@@ -11,7 +11,7 @@ import { errorMessage } from './session-types.mjs'
|
|
|
11
11
|
import { connectCodexProxy } from './codex-connection.mjs'
|
|
12
12
|
import type { ProviderProcess } from './codex-connection.mjs'
|
|
13
13
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
14
|
-
import type { Provider, SessionRecord, RequestState, LocalMessage, JobRecord } from './session-types.mjs'
|
|
14
|
+
import type { Provider, SessionRecord, RequestState, LocalMessage, JobRecord, LocalRequest } from './session-types.mjs'
|
|
15
15
|
import type { Usage } from './session-types.mjs'
|
|
16
16
|
|
|
17
17
|
interface ManagedSessionOptions {
|
|
@@ -19,6 +19,7 @@ interface ManagedSessionOptions {
|
|
|
19
19
|
cwd?: string; maxTokens?: number; timeoutMs?: number; onEvent?: (event: Record<string, unknown>) => void
|
|
20
20
|
existingThreadId?: string; socketPath?: string
|
|
21
21
|
observeActive?: boolean
|
|
22
|
+
permissionMode?: string
|
|
22
23
|
}
|
|
23
24
|
interface ProviderMessage {
|
|
24
25
|
id?: string | number; method?: string; event?: string; conversation_id?: string
|
|
@@ -30,14 +31,19 @@ interface ProviderMessage {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
interface AgyResult { conversation_id: string; status: string; response?: unknown; error?: string; usage?: unknown }
|
|
34
|
+
interface ClaudeMessage { type?: string; subtype?: string; session_id?: string; result?: unknown; is_error?: boolean
|
|
35
|
+
usage?: { output_tokens_details?: { thinking_tokens?: number }; [key: string]: unknown } }
|
|
36
|
+
const CLAUDE_USAGE_KEYS = ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens', 'thinking_tokens'] as const
|
|
33
37
|
|
|
34
38
|
// 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
|
|
35
39
|
const pipeFor = hostPipe
|
|
36
40
|
const uuid = (value: string | undefined) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '')
|
|
37
41
|
|
|
38
42
|
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false,
|
|
39
|
-
cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => {} }: ManagedSessionOptions) {
|
|
40
|
-
if (!['codex', 'agy'].includes(provider)) throw new Error('지원하는 세션은 codex 또는
|
|
43
|
+
permissionMode = 'default', cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => {} }: ManagedSessionOptions) {
|
|
44
|
+
if (!['codex', 'agy', 'claude'].includes(provider)) throw new Error('지원하는 세션은 codex, agy 또는 claude입니다.')
|
|
45
|
+
// 읽기 전용이 기본이다(Codex 의 sandbox read-only 와 같은 자리). 고쳐도 되는 세션은 사람이 serve 할 때 고른다.
|
|
46
|
+
if (!['default', 'plan', 'acceptEdits'].includes(permissionMode)) throw new Error('claude 권한은 default, plan 또는 acceptEdits입니다.')
|
|
41
47
|
if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 토큰/시간 한도가 필요합니다.')
|
|
42
48
|
if (typeof role !== 'string' || role.length > 1000) throw new Error('역할은 1000자 이하여야 합니다.')
|
|
43
49
|
if (existingThreadId && (provider !== 'codex' || !uuid(existingThreadId) || !socketPath || model || args)) throw new Error('기존 Codex thread와 로컬 socket이 필요하며 모델·실행 인수를 바꾸지 않습니다.')
|
|
@@ -47,7 +53,22 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
47
53
|
workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false, hostPid: process.pid,
|
|
48
54
|
registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens }
|
|
49
55
|
const jobs = new Map<string, JobRecord>()
|
|
56
|
+
const queue: LocalRequest[] = []
|
|
57
|
+
const persistJob = (job: JobRecord) => writeJSON(path.join(bridgeHome(), 'jobs', `${job.id}.json`), job)
|
|
58
|
+
let draining = false
|
|
59
|
+
const drain = () => {
|
|
60
|
+
if (closed || draining || pending || s.state !== 'idle' || !queue.length) return
|
|
61
|
+
draining = true
|
|
62
|
+
const request = queue.shift()!
|
|
63
|
+
jobs.delete(request.id)
|
|
64
|
+
void handle({ action: 'submit', token, request }).catch(e => {
|
|
65
|
+
const job: JobRecord = { id: request.id, to: id, state: 'failed', error: errorMessage(e), completedAt: new Date().toISOString() }
|
|
66
|
+
jobs.set(request.id, job); persistJob(job); emit({ event: 'result', request: job })
|
|
67
|
+
}).finally(() => { draining = false; if (!pending) setImmediate(drain) })
|
|
68
|
+
}
|
|
50
69
|
let attachedUsageOrigin: Usage | null = null
|
|
70
|
+
// Claude Code 는 실행마다 그 실행분만 준다. 다른 provider 처럼 누적값으로 다루려고 여기서 더한다.
|
|
71
|
+
let claudeTotals: Record<string, number> | null = null
|
|
51
72
|
let initialThreadState = 'idle'
|
|
52
73
|
const calls = new Map<string | number, { resolve: (result: unknown) => void; reject: (reason: Error) => void; timer: NodeJS.Timeout }>()
|
|
53
74
|
let child: ProviderProcess | undefined, sequence = 0, pending: JobRecord | null = null, closed = false, buffer = '', stderr = '', turnTimer: NodeJS.Timeout | undefined
|
|
@@ -73,13 +94,18 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
73
94
|
delete pending.baselineKnown
|
|
74
95
|
if (usage) { s.usage = usage; if (existingThreadId && !attachedUsageOrigin) attachedUsageOrigin = usage }
|
|
75
96
|
s.usageKnown = usage !== null
|
|
97
|
+
persistJob(pending)
|
|
76
98
|
emit({ event: 'result', request: { ...pending } })
|
|
77
|
-
pending = null; s.state = 'idle'; s.fullyIdle = true; delete s.pendingApproval; save()
|
|
99
|
+
pending = null; s.state = 'idle'; s.fullyIdle = true; delete s.pendingApproval; save(); setImmediate(drain)
|
|
78
100
|
}
|
|
79
101
|
const shutdown = (reason: string) => {
|
|
80
102
|
if (closed) return
|
|
81
103
|
finish('unknown', undefined, reason)
|
|
82
104
|
closed = true; clearTimeout(turnTimer)
|
|
105
|
+
for (const request of queue.splice(0)) {
|
|
106
|
+
const job: JobRecord = { id: request.id, to: id, state: 'failed', error: reason, completedAt: new Date().toISOString() }
|
|
107
|
+
jobs.set(request.id, job); persistJob(job)
|
|
108
|
+
}
|
|
83
109
|
for (const c of calls.values()) { clearTimeout(c.timer); c.reject(new Error(reason)) }
|
|
84
110
|
calls.clear()
|
|
85
111
|
s.state = 'closed'; s.fullyIdle = false; save()
|
|
@@ -112,25 +138,39 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
112
138
|
delete s.pendingApproval; s.state = 'running'; save(); return { answered: true }
|
|
113
139
|
}
|
|
114
140
|
if (message.action !== 'submit') throw new Error('지원하지 않는 로컬 세션 요청입니다.')
|
|
115
|
-
|
|
116
|
-
|
|
141
|
+
// 🔴 여기부터 접수(pending) 전까지의 거절은 모델을 부르지 않은 확정 거절이다. refused 를 붙여 보낸 쪽이
|
|
142
|
+
// unknown 이 아니라 failed 로 기록하게 한다 — unknown 으로 남으면 그 세션의 다음 요청이 「이전 요청 미확정」으로
|
|
143
|
+
// 영영 막힌다(토큰 한도에 걸린 Claude 세션에 보낸 요청이 실제로 그렇게 막혔다).
|
|
144
|
+
const refuse = (text: string) => Object.assign(new Error(text), { refused: true })
|
|
145
|
+
if (jobs.has(message.request?.id)) throw refuse('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.')
|
|
146
|
+
const queuedRequest = message.request
|
|
147
|
+
if (!uuid(queuedRequest?.id) || queuedRequest.to !== id || typeof queuedRequest.prompt !== 'string' || !queuedRequest.prompt.trim() || Buffer.byteLength(queuedRequest.prompt) > 32768) throw refuse('요청 형식이 올바르지 않습니다.')
|
|
148
|
+
if (message.queueIfBusy && !closed && (pending || draining || queue.length || s.state === 'running' || s.state === 'awaiting_user')) {
|
|
149
|
+
if (queue.length >= 100) throw refuse('명령 큐가 가득 찼습니다.')
|
|
150
|
+
const job: JobRecord = { id: queuedRequest.id, to: id, state: 'queued' }
|
|
151
|
+
persistJob(job); jobs.set(job.id, job); queue.push(queuedRequest)
|
|
152
|
+
emit({ event: 'queued', request: job })
|
|
153
|
+
return { accepted: true, queued: true, session: id, position: queue.length }
|
|
154
|
+
}
|
|
155
|
+
if (closed || s.state !== 'idle' || pending) throw refuse('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.')
|
|
117
156
|
if (existingThreadId) {
|
|
118
|
-
if (checkingSubmission) throw
|
|
157
|
+
if (checkingSubmission) throw refuse('다른 요청의 상태를 확인 중입니다.')
|
|
119
158
|
checkingSubmission = true
|
|
120
159
|
try {
|
|
121
160
|
const read = await rpc<{ thread: { id: string; status: { type: string } } }>('thread/read', { threadId: existingThreadId })
|
|
122
|
-
if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle') throw
|
|
161
|
+
if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle') throw refuse('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.')
|
|
123
162
|
} finally { checkingSubmission = false }
|
|
124
163
|
}
|
|
125
|
-
if (!s.usageKnown) throw
|
|
126
|
-
if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens) throw
|
|
164
|
+
if (!s.usageKnown) throw refuse('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.')
|
|
165
|
+
if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens) throw refuse('세션 토큰 한도에 도달했습니다.')
|
|
127
166
|
const request = message.request
|
|
128
|
-
if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw
|
|
167
|
+
if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw refuse('요청 형식이 올바르지 않습니다.')
|
|
129
168
|
pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown && (!existingThreadId || !!s.usage), result: '' }
|
|
130
|
-
jobs.set(request.id, pending); s.state = 'running'; s.fullyIdle = false; save()
|
|
169
|
+
persistJob(pending); jobs.set(request.id, pending); s.state = 'running'; s.fullyIdle = false; save()
|
|
131
170
|
const prompt = `[axMap request ${request.id}]\n요청자: ${request.from || 'axmap'}\n${role ? `역할: ${role}\n` : ''}${request.prompt}`
|
|
132
171
|
turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs)
|
|
133
172
|
if (provider === 'agy') write({ event: 'user', message: { content: prompt } })
|
|
173
|
+
else if (provider === 'claude') write({ type: 'user', message: { role: 'user', content: prompt } })
|
|
134
174
|
else rpc<{ turn: { id: string } }>('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], ...(!existingThreadId ? { effort: 'low' } : {}) })
|
|
135
175
|
.then(r => { if (pending?.id === request.id) pending.turnId = r.turn.id })
|
|
136
176
|
.catch(e => shutdown(e.message))
|
|
@@ -146,7 +186,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
146
186
|
if (Buffer.byteLength(input) > 65536) return socket.destroy()
|
|
147
187
|
const end = input.indexOf('\n'); if (end < 0) return
|
|
148
188
|
handled = true
|
|
149
|
-
try { 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')) }
|
|
189
|
+
try { void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e), ...((e as { refused?: boolean })?.refused ? { refused: true } : {}) }) + '\n')) }
|
|
150
190
|
catch (e) { socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n') }
|
|
151
191
|
})
|
|
152
192
|
})
|
|
@@ -155,6 +195,8 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
155
195
|
if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
|
|
156
196
|
save()
|
|
157
197
|
const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
|
|
198
|
+
: provider === 'claude' ? ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
|
|
199
|
+
'--permission-mode', permissionMode, ...(model ? ['--model', model] : [])]
|
|
158
200
|
: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])])
|
|
159
201
|
child = existingThreadId ? connectCodexProxy({ executable, prefix, socketPath: socketPath!, cwd }) : spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
160
202
|
env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } })
|
|
@@ -174,6 +216,23 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
174
216
|
if (!line.trim()) continue
|
|
175
217
|
try {
|
|
176
218
|
const m = JSON.parse(line) as ProviderMessage
|
|
219
|
+
if (provider === 'claude') {
|
|
220
|
+
const c = m as unknown as ClaudeMessage
|
|
221
|
+
// init 은 실행마다 온다. 첫 init 의 대화 ID 를 붙잡고, 바뀌면 다른 대화에 답이 섞인 것이라 닫는다.
|
|
222
|
+
if (c.type === 'system' && c.subtype === 'init') {
|
|
223
|
+
if (!s.conversationId) { s.conversationId = c.session_id; save(); emit({ event: 'conversation', conversationId: s.conversationId }) }
|
|
224
|
+
else if (c.session_id !== s.conversationId) throw new Error('claude 대화 ID가 바뀌었습니다.')
|
|
225
|
+
}
|
|
226
|
+
if (c.type === 'result' && pending) {
|
|
227
|
+
if (c.session_id !== s.conversationId) throw new Error('claude 대화 ID가 바뀌었습니다.')
|
|
228
|
+
const turn: Record<string, unknown> | null = c.usage ? { ...c.usage, thinking_tokens: c.usage.output_tokens_details?.thinking_tokens ?? 0 } : null
|
|
229
|
+
const known = !!turn && CLAUDE_USAGE_KEYS.every(k => Number.isSafeInteger(turn[k]) && (turn[k] as number) >= 0)
|
|
230
|
+
if (known) claudeTotals = Object.fromEntries(CLAUDE_USAGE_KEYS.map(k => [k, (claudeTotals?.[k] || 0) + (turn![k] as number)]))
|
|
231
|
+
const ok = c.subtype === 'success' && !c.is_error
|
|
232
|
+
finish(ok ? 'completed' : 'failed', c.result, ok ? undefined : String(c.result || c.subtype), known ? claudeTotals : null)
|
|
233
|
+
}
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
177
236
|
if (provider === 'agy') {
|
|
178
237
|
if (m.event === 'init') { s.conversationId = m.conversation_id; s.state = 'idle'; s.fullyIdle = true; save(); emit({ event: 'ready', conversationId: s.conversationId }) }
|
|
179
238
|
if (m.event === 'result' && pending) {
|
|
@@ -199,7 +258,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
199
258
|
if (m.method === 'thread/status/changed' && !pending) {
|
|
200
259
|
const status = m.params?.status as { type?: string } | undefined
|
|
201
260
|
s.state = status?.type === 'active' ? 'running' : status?.type === 'idle' ? 'idle' : 'unknown'
|
|
202
|
-
s.fullyIdle = s.state === 'idle'; save()
|
|
261
|
+
s.fullyIdle = s.state === 'idle'; save(); setImmediate(drain)
|
|
203
262
|
}
|
|
204
263
|
if (m.method === 'thread/tokenUsage/updated') {
|
|
205
264
|
if (pending) pending.rawUsage = m.params?.tokenUsage?.total
|
|
@@ -231,8 +290,14 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
231
290
|
? { threadId: existingThreadId, excludeTurns: true }
|
|
232
291
|
: { cwd: path.resolve(cwd), ...(model ? { model } : {}), sandbox: 'read-only', approvalPolicy: 'on-request' })
|
|
233
292
|
if (existingThreadId && r.thread.id !== existingThreadId) throw new Error('기존 thread ID가 바뀌었습니다.')
|
|
234
|
-
s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = initialThreadState === 'active' ? 'running' : 'idle'; s.fullyIdle = s.state === 'idle'; save()
|
|
293
|
+
s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = initialThreadState === 'active' ? 'running' : 'idle'; s.fullyIdle = s.state === 'idle'; save(); setImmediate(drain)
|
|
235
294
|
emit({ event: 'ready', conversationId: s.conversationId })
|
|
295
|
+
} else if (provider === 'claude') {
|
|
296
|
+
// Claude Code 는 첫 입력이 올 때까지 아무것도 내보내지 않는다. 프로세스가 떴으면 대기로 둔다 —
|
|
297
|
+
// 대화 ID 는 첫 실행의 init 에서 정해진다. 기다리는 동안 모델은 부르지 않는다.
|
|
298
|
+
await new Promise<void>((resolve, reject) => { child!.once('spawn', () => resolve()); child!.once('error', reject) })
|
|
299
|
+
if (closed) throw new Error(`claude 시작 실패: ${stderr.slice(-1000)}`)
|
|
300
|
+
s.state = 'idle'; s.fullyIdle = true; save(); emit({ event: 'ready', conversationId: null })
|
|
236
301
|
} else {
|
|
237
302
|
const deadline = Date.now() + 30000
|
|
238
303
|
while (s.state === 'starting' && !closed && Date.now() < deadline) await new Promise(r => setTimeout(r, 25))
|
package/src/monitor.mjs
CHANGED
|
@@ -6,8 +6,8 @@ import { bridgeHome, repoKey, requestResult } from './bridge.mjs';
|
|
|
6
6
|
import { sessionStatus } from './session-status.mjs';
|
|
7
7
|
import { errorCode, errorMessage } from './session-types.mjs';
|
|
8
8
|
const uuidFile = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/i;
|
|
9
|
-
const pending = new Set(['sending', 'submitted', 'unknown']);
|
|
10
|
-
const requestLabels = { sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' };
|
|
9
|
+
const pending = new Set(['queued', 'sending', 'submitted', 'unknown']);
|
|
10
|
+
const requestLabels = { queued: '작업 큐 대기', sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' };
|
|
11
11
|
const sessionLabels = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '사용자 승인 필요', settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' };
|
|
12
12
|
const dialogueLabels = { queued: '접수', running: '중계 중', stopping: '중단 대기', completed: '완료', failed: '실패', cancelled: '중단', token_limit: '토큰 한도', unknown: '확인 필요', usage_unavailable: '사용량 미확인', message_limit: '메시지 한도', invalid_response: '응답 형식 확인 필요', turn_limit: '횟수 한도' };
|
|
13
13
|
function records(directory, issues, label, uuidOnly = true) {
|
|
@@ -74,7 +74,7 @@ export function createMonitor(cwd, options = {}) {
|
|
|
74
74
|
const refreshed = new Set();
|
|
75
75
|
for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
|
|
76
76
|
const request = requests[index];
|
|
77
|
-
if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to))
|
|
77
|
+
if (!['queued', 'submitted', 'unknown'].includes(request.state) || refreshed.has(request.to))
|
|
78
78
|
continue;
|
|
79
79
|
refreshed.add(request.to);
|
|
80
80
|
try {
|
package/src/monitor.mts
CHANGED
|
@@ -13,8 +13,8 @@ export interface MonitorSnapshot {
|
|
|
13
13
|
dialogues: DialogueRecord[]; claims: MonitorClaim[]; events: MonitorEvent[]; issues: string[]
|
|
14
14
|
}
|
|
15
15
|
const uuidFile = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/i
|
|
16
|
-
const pending = new Set(['sending', 'submitted', 'unknown'])
|
|
17
|
-
const requestLabels: Record<string, string> = { sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' }
|
|
16
|
+
const pending = new Set(['queued', 'sending', 'submitted', 'unknown'])
|
|
17
|
+
const requestLabels: Record<string, string> = { queued: '작업 큐 대기', sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' }
|
|
18
18
|
const sessionLabels: Record<string, string> = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '사용자 승인 필요', settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' }
|
|
19
19
|
const dialogueLabels: Record<string, string> = { queued: '접수', running: '중계 중', stopping: '중단 대기', completed: '완료', failed: '실패', cancelled: '중단', token_limit: '토큰 한도', unknown: '확인 필요', usage_unavailable: '사용량 미확인', message_limit: '메시지 한도', invalid_response: '응답 형식 확인 필요', turn_limit: '횟수 한도' }
|
|
20
20
|
|
|
@@ -64,7 +64,7 @@ export function createMonitor(cwd: string, options: {
|
|
|
64
64
|
const refreshed = new Set<string>()
|
|
65
65
|
for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
|
|
66
66
|
const request = requests[index]!
|
|
67
|
-
if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to)) continue
|
|
67
|
+
if (!['queued', 'submitted', 'unknown'].includes(request.state) || refreshed.has(request.to)) continue
|
|
68
68
|
refreshed.add(request.to)
|
|
69
69
|
try { requests[index] = resolve(request.id, workspace) }
|
|
70
70
|
catch (e) { issues.push(`요청 ${request.id.slice(0, 8)}: ${errorMessage(e)}`) }
|