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/README.ko.md
CHANGED
|
@@ -31,6 +31,22 @@ axmap setup
|
|
|
31
31
|
|
|
32
32
|
최신 변경부터 정리합니다.
|
|
33
33
|
|
|
34
|
+
### 1.10.0
|
|
35
|
+
|
|
36
|
+
<sub>2026.11.03</sub>
|
|
37
|
+
|
|
38
|
+
**일반 Windows Claude Code 터미널을 연결하고, 상대가 준비될 때까지 실행 요청을 큐에 보관합니다.**
|
|
39
|
+
|
|
40
|
+
- `axmap setup` 후 Git 저장소에서 평소처럼 `claude`를 실행하면 원래 프로세스와 대화가 자동 등록됩니다. `ax_dispatch` / `ax_result`로 새 모델 프로세스 없이 요청·응답을 주고받습니다.
|
|
41
|
+
- 관리형 Claude Code·Codex·agy와 일반 Windows Claude 콘솔에서 실행 요청을 FIFO로 접수합니다. 입력 초안과 승인 화면을 유지하며, 큐 확인과 대기 중에는 모델을 호출하지 않습니다. AI 답변 생성에는 기존처럼 토큰이 사용됩니다.
|
|
42
|
+
- `bridge serve claude`의 지속 연결을 지원합니다. 자동 대화에서 코드 블록에 감싼 JSON 응답, 실행 시간 제한과 `--done-by` 종료 주체 지정을 제공합니다.
|
|
43
|
+
- 확정 미전달과 전달 불명을 구별하고 불명 요청은 자동 재전송하지 않습니다. 대기 조회가 큐에서 전송 중으로 넘어가는 순간에도 완료까지 기다립니다.
|
|
44
|
+
|
|
45
|
+
Windows의 실제 일반 Claude Code 터미널과 Codex 요청·응답으로 검증했습니다. 일반 콘솔 연결은 Windows 범위이며, 토큰 예산을 검사하는 `ax_chat`은 관리형 stdio 세션을 사용합니다. 앱의 도구 승인 절차는 유지합니다.
|
|
46
|
+
[연결 안내 →](docs/SESSION-BRIDGE.md)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
34
50
|
### 1.9.0
|
|
35
51
|
|
|
36
52
|
<sub>2026.10.29</sub>
|
package/README.md
CHANGED
|
@@ -31,6 +31,22 @@ View pipeline state inside compatible MCP Apps hosts, or add it to the existing
|
|
|
31
31
|
|
|
32
32
|
Newest changes first.
|
|
33
33
|
|
|
34
|
+
### 1.10.0
|
|
35
|
+
|
|
36
|
+
<sub>November 3, 2026</sub>
|
|
37
|
+
|
|
38
|
+
**Connect ordinary Windows Claude Code terminals and queue execution requests until the recipient is ready.**
|
|
39
|
+
|
|
40
|
+
- After `axmap setup`, launch `claude` normally in a Git repository. Hooks register the original process and conversation; `ax_dispatch` / `ax_result` deliver requests and collect replies without starting another model process.
|
|
41
|
+
- Managed Claude Code, Codex and agy sessions, plus ordinary Windows Claude consoles, accept FIFO execution queues. Input drafts and approval screens are preserved. Waiting and queue handling make no model calls; generating a response still uses provider tokens.
|
|
42
|
+
- `bridge serve claude` supports persistent stream-json conversations. Automatic dialogue accepts fenced JSON responses and provides execution timeouts and `--done-by` termination control.
|
|
43
|
+
- Requests that were not delivered remain distinct from uncertain delivery. Unknown requests are never automatically replayed. Wait operations now follow the queued-to-sending handoff through completion.
|
|
44
|
+
|
|
45
|
+
Validated with real Claude Code terminals and Codex request/reply exchanges on Windows. Ordinary console attachment is Windows-only; token-budgeted `ax_chat` continues to require managed stdio sessions. Tool approvals remain under the host's control.
|
|
46
|
+
[Connection guide →](docs/SESSION-BRIDGE.md)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
34
50
|
### 1.9.0
|
|
35
51
|
|
|
36
52
|
<sub>October 29, 2026</sub>
|
package/mcp/server.mjs
CHANGED
|
@@ -508,7 +508,7 @@ const TOOLS = [
|
|
|
508
508
|
},
|
|
509
509
|
{
|
|
510
510
|
name: 'ax_dispatch',
|
|
511
|
-
description: '명시적으로 상대의 실행 턴이 필요한 작업용
|
|
511
|
+
description: '명시적으로 상대의 실행 턴이 필요한 작업용 전달이다. 관리형 Claude Code·Codex·agy와 Windows 일반 Claude Code(console)는 대기 중 실행하고 작업·입력·승인 대기 중이면 FIFO 큐에 접수해 idle에서 자동 실행한다. 기존 맥락과 프로세스를 유지한다. queued/submitted는 완료가 아니며 ax_result로 확인한다. unknown은 재전송하지 않는다. 일반 진행 알림에는 새 턴 없는 ax_message를 사용한다. 작업 중인 세션을 강제 중단하지 않는다.',
|
|
512
512
|
inputSchema: { type: 'object', properties: {
|
|
513
513
|
to: { type: 'string', description: 'ax_sessions에서 확인한 상대 세션 UUID' },
|
|
514
514
|
prompt: { type: 'string', description: '상대가 실행할 작업 요청' },
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
|
|
3
3
|
"name": "axmap-cli",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.10.0",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
package/src/bridge.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { withRepoLock } from './repolock.mjs';
|
|
11
11
|
import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs';
|
|
@@ -100,6 +100,24 @@ function runAPI(session, args) {
|
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
102
|
function transcript(session) {
|
|
103
|
+
if (session.provider === 'claude') {
|
|
104
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
105
|
+
const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..';
|
|
106
|
+
if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`)
|
|
107
|
+
throw new Error('Claude transcript identity mismatch');
|
|
108
|
+
if (!fs.existsSync(session.transcript))
|
|
109
|
+
return [];
|
|
110
|
+
return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
|
|
111
|
+
const entry = JSON.parse(line);
|
|
112
|
+
if (entry.sessionId && entry.sessionId !== session.conversationId)
|
|
113
|
+
return [];
|
|
114
|
+
const content = entry.message?.content;
|
|
115
|
+
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : '';
|
|
116
|
+
if (!text || !['user', 'assistant'].includes(entry.type || ''))
|
|
117
|
+
return [];
|
|
118
|
+
return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
103
121
|
const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl');
|
|
104
122
|
if (session.transcript !== expected)
|
|
105
123
|
throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.');
|
|
@@ -203,7 +221,7 @@ function sessionFor(id, cwd) {
|
|
|
203
221
|
const s = readJSON(file('sessions', id));
|
|
204
222
|
if (!s.repos.includes(repoKey(cwd)))
|
|
205
223
|
throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.');
|
|
206
|
-
if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || ''))
|
|
224
|
+
if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio'].includes(s.transport || ''))
|
|
207
225
|
throw new Error('아직 지원하지 않는 수신 세션입니다.');
|
|
208
226
|
return s;
|
|
209
227
|
}
|
|
@@ -235,16 +253,32 @@ export function requestResult(id, cwd) {
|
|
|
235
253
|
const request = readJSON(target);
|
|
236
254
|
if (request.repo !== repoKey(cwd))
|
|
237
255
|
throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.');
|
|
238
|
-
if (!['submitted', 'unknown'].includes(request.state))
|
|
256
|
+
if (!['queued', 'submitted', 'unknown'].includes(request.state))
|
|
239
257
|
return request;
|
|
240
258
|
let s = sessionFor(request.to, cwd);
|
|
259
|
+
if (request.state === 'queued' && s.transport === 'console')
|
|
260
|
+
return request;
|
|
241
261
|
if (['pty', 'stdio'].includes(s.transport || '')) {
|
|
242
262
|
try {
|
|
243
263
|
Object.assign(request, terminalRPC(s.id, { action: 'result', id }));
|
|
244
264
|
}
|
|
245
265
|
catch (e) {
|
|
246
|
-
|
|
247
|
-
|
|
266
|
+
const saved = path.join(bridgeHome(), 'jobs', `${id}.json`);
|
|
267
|
+
// A normal host exit persists outcomes before closing the pipe. Never replay
|
|
268
|
+
// a queued job after a crash or mistake an unexecuted job for completion.
|
|
269
|
+
if (s.transport === 'stdio' && fs.existsSync(saved)) {
|
|
270
|
+
const job = readJSON(saved);
|
|
271
|
+
if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state))
|
|
272
|
+
Object.assign(request, job);
|
|
273
|
+
else {
|
|
274
|
+
request.state = 'unknown';
|
|
275
|
+
request.error = errorMessage(e);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
request.state = 'unknown';
|
|
280
|
+
request.error = errorMessage(e);
|
|
281
|
+
}
|
|
248
282
|
}
|
|
249
283
|
writeJSON(target, request);
|
|
250
284
|
return request;
|
|
@@ -292,7 +326,7 @@ export async function waitForRequest(id, cwd, timeoutMs = 30000) {
|
|
|
292
326
|
const deadline = performance.now() + timeoutMs;
|
|
293
327
|
for (;;) {
|
|
294
328
|
const result = requestResult(id, cwd);
|
|
295
|
-
if (
|
|
329
|
+
if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline)
|
|
296
330
|
return result;
|
|
297
331
|
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))));
|
|
298
332
|
}
|
|
@@ -302,7 +336,7 @@ export function sessionLeasePath(id) {
|
|
|
302
336
|
throw new Error('세션 ID는 UUID여야 합니다.');
|
|
303
337
|
return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`);
|
|
304
338
|
}
|
|
305
|
-
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }) {
|
|
339
|
+
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null, queueIfBusy = false }) {
|
|
306
340
|
// 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
|
|
307
341
|
// 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
|
|
308
342
|
let sending = false;
|
|
@@ -315,15 +349,35 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
315
349
|
throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.');
|
|
316
350
|
const dir = path.join(bridgeHome(), 'requests');
|
|
317
351
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
352
|
+
if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
|
|
353
|
+
validateConsolePrompt(prompt, from);
|
|
354
|
+
if (s.state === 'closed')
|
|
355
|
+
throw new Error('Claude terminal is closed');
|
|
356
|
+
const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
357
|
+
const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON(path.join(dir, n)))
|
|
358
|
+
.filter(r => r.to === to && r.state === 'queued');
|
|
359
|
+
if (pending.length >= 100)
|
|
360
|
+
throw new Error('Claude command queue is full');
|
|
361
|
+
const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' };
|
|
362
|
+
writeJSON(file('requests', request.id), request);
|
|
363
|
+
return request;
|
|
364
|
+
});
|
|
365
|
+
const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' });
|
|
366
|
+
worker.on('error', () => { });
|
|
367
|
+
worker.unref();
|
|
368
|
+
return request;
|
|
369
|
+
}
|
|
318
370
|
return withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
319
371
|
if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
|
|
320
372
|
throw new Error('이 세션은 자동 대화가 사용 중입니다.');
|
|
321
373
|
for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
|
|
322
374
|
const previous = readJSON(path.join(dir, n));
|
|
323
|
-
if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state))
|
|
375
|
+
if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state))
|
|
324
376
|
continue;
|
|
325
377
|
const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd);
|
|
326
|
-
if (
|
|
378
|
+
if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state))
|
|
379
|
+
continue;
|
|
380
|
+
if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state))
|
|
327
381
|
throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`);
|
|
328
382
|
}
|
|
329
383
|
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
@@ -344,9 +398,9 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
344
398
|
sending = true;
|
|
345
399
|
try {
|
|
346
400
|
request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
|
|
347
|
-
? terminalRPC(to, { action: 'submit', request })
|
|
401
|
+
? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
|
|
348
402
|
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`]);
|
|
349
|
-
request.state = 'submitted';
|
|
403
|
+
request.state = request.receipt?.queued ? 'queued' : 'submitted';
|
|
350
404
|
}
|
|
351
405
|
catch (e) {
|
|
352
406
|
if (notDelivered(e)) {
|
|
@@ -445,3 +499,89 @@ export function installHooks({ executable, home = os.homedir() }) {
|
|
|
445
499
|
return { hooks: target, backup: original === null ? null : backup,
|
|
446
500
|
note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' };
|
|
447
501
|
}
|
|
502
|
+
/** One local worker per ordinary Claude terminal; no provider is launched. */
|
|
503
|
+
export function runConsoleQueue(to, cwd) {
|
|
504
|
+
const dir = path.join(bridgeHome(), 'requests');
|
|
505
|
+
const runnerLock = path.join(dir, `${to}.runner.lock`);
|
|
506
|
+
if (fs.existsSync(runnerLock)) {
|
|
507
|
+
const owner = readJSON(runnerLock);
|
|
508
|
+
try {
|
|
509
|
+
process.kill(owner.pid, 0);
|
|
510
|
+
}
|
|
511
|
+
catch (e) {
|
|
512
|
+
if (errorCode(e) === 'ESRCH')
|
|
513
|
+
fs.unlinkSync(runnerLock);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
withRepoLock(runnerLock, () => {
|
|
518
|
+
for (;;) {
|
|
519
|
+
const records = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
|
|
520
|
+
.filter(n => n.endsWith('.json')).map(n => readJSON(path.join(dir, n)))
|
|
521
|
+
.filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
|
|
522
|
+
const queued = records.filter(r => r.state === 'queued');
|
|
523
|
+
if (!queued.length)
|
|
524
|
+
return;
|
|
525
|
+
let waiting = false;
|
|
526
|
+
for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
|
|
527
|
+
const latest = r.state === 'sending' ? r : requestResult(r.id, cwd);
|
|
528
|
+
if (['unknown', 'sending'].includes(latest.state))
|
|
529
|
+
return;
|
|
530
|
+
if (latest.state === 'submitted')
|
|
531
|
+
waiting = true;
|
|
532
|
+
}
|
|
533
|
+
const s = sessionFor(to, cwd);
|
|
534
|
+
if (s.pid) {
|
|
535
|
+
try {
|
|
536
|
+
process.kill(s.pid, 0);
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
if (errorCode(e) === 'ESRCH') {
|
|
540
|
+
for (const r of queued)
|
|
541
|
+
writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (s.state === 'closed') {
|
|
547
|
+
for (const r of queued)
|
|
548
|
+
writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
let live;
|
|
552
|
+
try {
|
|
553
|
+
live = checkConsole(s);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
waiting = true;
|
|
557
|
+
}
|
|
558
|
+
if (waiting || !live) {
|
|
559
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
563
|
+
const r = readJSON(file('requests', queued[0].id));
|
|
564
|
+
if (r.state !== 'queued')
|
|
565
|
+
return;
|
|
566
|
+
r.baseline = transcript(s).length;
|
|
567
|
+
r.state = 'sending';
|
|
568
|
+
writeJSON(file('requests', r.id), r);
|
|
569
|
+
try {
|
|
570
|
+
r.receipt = submitConsole(s, r, live);
|
|
571
|
+
r.state = 'submitted';
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
r.state = notDelivered(e) ? 'queued' : 'unknown';
|
|
575
|
+
r.error = errorMessage(e);
|
|
576
|
+
}
|
|
577
|
+
writeJSON(file('requests', r.id), r);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}, 1000);
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
// Another worker won the runner lease. It owns draining; never replay here.
|
|
584
|
+
if (!fs.existsSync(runnerLock))
|
|
585
|
+
throw e;
|
|
586
|
+
}
|
|
587
|
+
}
|
package/src/bridge.mts
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'node:fs'
|
|
|
4
4
|
import os from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
|
-
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { withRepoLock } from './repolock.mjs'
|
|
10
10
|
import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs'
|
|
@@ -82,6 +82,20 @@ function runAPI(session: SessionRecord, args: string[]): unknown {
|
|
|
82
82
|
return null
|
|
83
83
|
}
|
|
84
84
|
function transcript(session: SessionRecord): TranscriptEntry[] {
|
|
85
|
+
if (session.provider === 'claude') {
|
|
86
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
87
|
+
const relative = session.transcript ? path.relative(path.join(home, 'projects'), session.transcript) : '..'
|
|
88
|
+
if (!session.transcript || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(session.transcript) !== `${session.conversationId}.jsonl`) throw new Error('Claude transcript identity mismatch')
|
|
89
|
+
if (!fs.existsSync(session.transcript)) return []
|
|
90
|
+
return fs.readFileSync(session.transcript, 'utf8').split('\n').slice(0, -1).filter(Boolean).flatMap(line => {
|
|
91
|
+
const entry = JSON.parse(line) as { type?: string; sessionId?: string; message?: { content?: string | Array<{ type: string; text?: string }> } }
|
|
92
|
+
if (entry.sessionId && entry.sessionId !== session.conversationId) return []
|
|
93
|
+
const content = entry.message?.content
|
|
94
|
+
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n') : ''
|
|
95
|
+
if (!text || !['user', 'assistant'].includes(entry.type || '')) return []
|
|
96
|
+
return [{ type: entry.type === 'user' ? 'USER_INPUT' : 'PLANNER_RESPONSE', source: 'MODEL', status: 'DONE', content: text }]
|
|
97
|
+
})
|
|
98
|
+
}
|
|
85
99
|
const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
86
100
|
if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
|
|
87
101
|
const lines = fs.readFileSync(expected, 'utf8').split('\n')
|
|
@@ -164,7 +178,7 @@ export function sessions(cwd: string): PublicSession[] {
|
|
|
164
178
|
function sessionFor(id: string, cwd: string): SessionRecord {
|
|
165
179
|
const s = readJSON<SessionRecord>(file('sessions', id))
|
|
166
180
|
if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
|
|
167
|
-
if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
181
|
+
if (s.provider !== 'agy' && !(s.provider === 'claude' && s.transport === 'console') && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
168
182
|
return s
|
|
169
183
|
}
|
|
170
184
|
|
|
@@ -193,11 +207,21 @@ export function requestResult(id: string, cwd: string): RequestRecord {
|
|
|
193
207
|
const target = file('requests', id)
|
|
194
208
|
const request = readJSON<RequestRecord>(target)
|
|
195
209
|
if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
|
|
196
|
-
if (!['submitted', 'unknown'].includes(request.state)) return request
|
|
210
|
+
if (!['queued', 'submitted', 'unknown'].includes(request.state)) return request
|
|
197
211
|
let s = sessionFor(request.to, cwd)
|
|
212
|
+
if (request.state === 'queued' && s.transport === 'console') return request
|
|
198
213
|
if (['pty', 'stdio'].includes(s.transport || '')) {
|
|
199
214
|
try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
|
|
200
|
-
catch (e) {
|
|
215
|
+
catch (e) {
|
|
216
|
+
const saved = path.join(bridgeHome(), 'jobs', `${id}.json`)
|
|
217
|
+
// A normal host exit persists outcomes before closing the pipe. Never replay
|
|
218
|
+
// a queued job after a crash or mistake an unexecuted job for completion.
|
|
219
|
+
if (s.transport === 'stdio' && fs.existsSync(saved)) {
|
|
220
|
+
const job = readJSON<Pick<RequestRecord, 'id' | 'to' | 'state'>>(saved)
|
|
221
|
+
if (job.id === id && job.to === s.id && ['completed', 'failed', 'unknown'].includes(job.state)) Object.assign(request, job)
|
|
222
|
+
else { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
223
|
+
} else { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
224
|
+
}
|
|
201
225
|
writeJSON(target, request)
|
|
202
226
|
return request
|
|
203
227
|
}
|
|
@@ -237,7 +261,7 @@ export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000)
|
|
|
237
261
|
const deadline = performance.now() + timeoutMs
|
|
238
262
|
for (;;) {
|
|
239
263
|
const result = requestResult(id, cwd)
|
|
240
|
-
if (
|
|
264
|
+
if (!['queued', 'sending', 'submitted'].includes(result.state) || performance.now() >= deadline) return result
|
|
241
265
|
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
|
|
242
266
|
}
|
|
243
267
|
}
|
|
@@ -247,7 +271,7 @@ export function sessionLeasePath(id: string): string {
|
|
|
247
271
|
return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
|
|
248
272
|
}
|
|
249
273
|
|
|
250
|
-
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
|
|
274
|
+
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null, queueIfBusy = false }: DispatchOptions): RequestRecord {
|
|
251
275
|
// 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
|
|
252
276
|
// 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
|
|
253
277
|
let sending = false
|
|
@@ -258,13 +282,29 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
258
282
|
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
|
|
259
283
|
const dir = path.join(bridgeHome(), 'requests')
|
|
260
284
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
285
|
+
if (queueIfBusy && s.provider === 'claude' && s.transport === 'console') {
|
|
286
|
+
validateConsolePrompt(prompt, from)
|
|
287
|
+
if (s.state === 'closed') throw new Error('Claude terminal is closed')
|
|
288
|
+
const request = withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
289
|
+
const pending = fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
|
|
290
|
+
.filter(r => r.to === to && r.state === 'queued')
|
|
291
|
+
if (pending.length >= 100) throw new Error('Claude command queue is full')
|
|
292
|
+
const request: RequestRecord = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt, createdAt: new Date().toISOString(), baseline: null, state: 'queued' }
|
|
293
|
+
writeJSON(file('requests', request.id), request)
|
|
294
|
+
return request
|
|
295
|
+
})
|
|
296
|
+
const worker = spawn(process.execPath, [path.join(ROOT, 'tools', 'console-queue.mjs'), to, cwd], { detached: true, windowsHide: true, stdio: 'ignore' })
|
|
297
|
+
worker.on('error', () => {}); worker.unref()
|
|
298
|
+
return request
|
|
299
|
+
}
|
|
261
300
|
return withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
262
301
|
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
|
|
263
302
|
for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
|
|
264
303
|
const previous = readJSON<RequestRecord>(path.join(dir, n))
|
|
265
|
-
if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
304
|
+
if (previous.to !== to || !['queued', 'sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
266
305
|
const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
|
|
267
|
-
if (
|
|
306
|
+
if (queueIfBusy && s.transport === 'stdio' && ['queued', 'submitted'].includes(latest.state)) continue
|
|
307
|
+
if (['queued', 'sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
|
|
268
308
|
}
|
|
269
309
|
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
270
310
|
let consolePreflight
|
|
@@ -280,9 +320,9 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
280
320
|
sending = true
|
|
281
321
|
try {
|
|
282
322
|
request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
|
|
283
|
-
? terminalRPC(to, { action: 'submit', request })
|
|
323
|
+
? terminalRPC(to, { action: 'submit', request, ...(queueIfBusy && s.transport === 'stdio' ? { queueIfBusy: true } : {}) })
|
|
284
324
|
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
|
|
285
|
-
request.state = 'submitted'
|
|
325
|
+
request.state = (request.receipt as { queued?: boolean })?.queued ? 'queued' : 'submitted'
|
|
286
326
|
} catch (e) {
|
|
287
327
|
if (notDelivered(e)) {
|
|
288
328
|
// 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
|
|
@@ -372,3 +412,57 @@ export function installHooks({ executable, home = os.homedir() }: { executable:
|
|
|
372
412
|
return { hooks: target, backup: original === null ? null : backup,
|
|
373
413
|
note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
|
|
374
414
|
}
|
|
415
|
+
|
|
416
|
+
/** One local worker per ordinary Claude terminal; no provider is launched. */
|
|
417
|
+
export function runConsoleQueue(to: string, cwd: string) {
|
|
418
|
+
const dir = path.join(bridgeHome(), 'requests')
|
|
419
|
+
const runnerLock = path.join(dir, `${to}.runner.lock`)
|
|
420
|
+
if (fs.existsSync(runnerLock)) {
|
|
421
|
+
const owner = readJSON<{ pid: number }>(runnerLock)
|
|
422
|
+
try { process.kill(owner.pid, 0) } catch (e) {
|
|
423
|
+
if (errorCode(e) === 'ESRCH') fs.unlinkSync(runnerLock)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
try { withRepoLock(runnerLock, () => {
|
|
427
|
+
for (;;) {
|
|
428
|
+
const records: RequestRecord[] = withRepoLock(path.join(dir, `${to}.lock`), () => fs.readdirSync(dir)
|
|
429
|
+
.filter(n => n.endsWith('.json')).map(n => readJSON<RequestRecord>(path.join(dir, n)))
|
|
430
|
+
.filter(r => r.to === to).sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
|
|
431
|
+
const queued = records.filter(r => r.state === 'queued')
|
|
432
|
+
if (!queued.length) return
|
|
433
|
+
let waiting = false
|
|
434
|
+
for (const r of records.filter(r => ['sending', 'submitted', 'unknown'].includes(r.state))) {
|
|
435
|
+
const latest = r.state === 'sending' ? r : requestResult(r.id, cwd)
|
|
436
|
+
if (['unknown', 'sending'].includes(latest.state)) return
|
|
437
|
+
if (latest.state === 'submitted') waiting = true
|
|
438
|
+
}
|
|
439
|
+
const s = sessionFor(to, cwd)
|
|
440
|
+
if (s.pid) {
|
|
441
|
+
try { process.kill(s.pid, 0) } catch (e) {
|
|
442
|
+
if (errorCode(e) === 'ESRCH') {
|
|
443
|
+
for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude process exited before execution' })
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (s.state === 'closed') {
|
|
449
|
+
for (const r of queued) writeJSON(file('requests', r.id), { ...r, state: 'failed', error: 'Claude terminal closed before execution' })
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
let live
|
|
453
|
+
try { live = checkConsole(s) } catch { waiting = true }
|
|
454
|
+
if (waiting || !live) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); continue }
|
|
455
|
+
withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
456
|
+
const r = readJSON<RequestRecord>(file('requests', queued[0]!.id))
|
|
457
|
+
if (r.state !== 'queued') return
|
|
458
|
+
r.baseline = transcript(s).length; r.state = 'sending'; writeJSON(file('requests', r.id), r)
|
|
459
|
+
try { r.receipt = submitConsole(s, r, live); r.state = 'submitted' }
|
|
460
|
+
catch (e) { r.state = notDelivered(e) ? 'queued' : 'unknown'; r.error = errorMessage(e) }
|
|
461
|
+
writeJSON(file('requests', r.id), r)
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
}, 1000) } catch (e) {
|
|
465
|
+
// Another worker won the runner lease. It owns draining; never replay here.
|
|
466
|
+
if (!fs.existsSync(runnerLock)) throw e
|
|
467
|
+
}
|
|
468
|
+
}
|
package/src/external-console.mjs
CHANGED
|
@@ -25,19 +25,19 @@ export function consoleCall(pid, action = 'snapshot', payload = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
catch { /* report the underlying failure */ }
|
|
27
27
|
if (r.error || r.status !== 0 || data?.error || !data)
|
|
28
|
-
throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패');
|
|
28
|
+
throw Object.assign(new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패'), { notDelivered: !r.error && data?.notDelivered === true });
|
|
29
29
|
if (action === 'ancestor' && data.ignored)
|
|
30
30
|
return { ignored: true };
|
|
31
31
|
if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string'))
|
|
32
32
|
throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.');
|
|
33
33
|
return data;
|
|
34
34
|
}
|
|
35
|
-
function identityID({ pid, startedAt }) {
|
|
36
|
-
const hex = createHash('sha256').update(
|
|
35
|
+
function identityID({ pid, startedAt, provider = 'agy' }) {
|
|
36
|
+
const hex = createHash('sha256').update(`${provider}-console:${pid}:${startedAt}`).digest('hex');
|
|
37
37
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
38
38
|
}
|
|
39
39
|
const target = (id) => path.join(bridgeHome(), 'sessions', `${id}.json`);
|
|
40
|
-
export function consoleIdle(screen) {
|
|
40
|
+
export function consoleIdle(screen, live) {
|
|
41
41
|
// Use the last input box only. Old answers may contain quoted prompt boxes.
|
|
42
42
|
const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)];
|
|
43
43
|
if (borders.length < 2)
|
|
@@ -47,7 +47,15 @@ export function consoleIdle(screen) {
|
|
|
47
47
|
return false;
|
|
48
48
|
const input = screen.slice(top.index + top[0].length, bottom.index).trim();
|
|
49
49
|
const footer = screen.slice(bottom.index + bottom[0].length);
|
|
50
|
-
|
|
50
|
+
const empty = /^[>❯]$/.test(input);
|
|
51
|
+
// Claude paints a suggestion after the cursor in an otherwise empty editor.
|
|
52
|
+
// Require the cursor to remain at that editor's start, never just match text.
|
|
53
|
+
const placeholder = live?.provider === 'claude' && live.cursorX === 2 &&
|
|
54
|
+
/^[>❯]\s+Try "[^"\r\n]+"$/.test(input) &&
|
|
55
|
+
live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length;
|
|
56
|
+
const claudeEditor = live?.provider === 'claude' && live.cursorX === 2 &&
|
|
57
|
+
live.cursorRow === screen.slice(0, top.index + top[0].length).split('\n').length;
|
|
58
|
+
return (empty || placeholder) && (live?.provider === 'claude' ? claudeEditor : /\? for shortcuts/.test(footer)) && !/esc to (cancel|interrupt)/i.test(footer);
|
|
51
59
|
}
|
|
52
60
|
export function attachConsole({ pid, cwd }) {
|
|
53
61
|
const live = consoleCall(pid);
|
|
@@ -56,19 +64,38 @@ export function attachConsole({ pid, cwd }) {
|
|
|
56
64
|
const repo = repoKey(cwd);
|
|
57
65
|
if (previous.repos && !previous.repos.includes(repo))
|
|
58
66
|
throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.');
|
|
59
|
-
const idle = consoleIdle(live.screen);
|
|
60
|
-
const s = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
67
|
+
const idle = consoleIdle(live.screen, live);
|
|
68
|
+
const s = { ...previous, id, provider: live.provider || 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
61
69
|
workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
|
|
62
70
|
state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' };
|
|
63
71
|
writeJSON(target(id), s);
|
|
64
72
|
return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces };
|
|
65
73
|
}
|
|
66
|
-
export function registerExternalHook(input, event) {
|
|
67
|
-
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event))
|
|
74
|
+
export function registerExternalHook(input, event, provider = 'agy') {
|
|
75
|
+
if (!['SessionStart', 'PreInvocation', 'UserPromptSubmit', 'Stop', 'SessionEnd'].includes(event))
|
|
68
76
|
throw new Error('지원하지 않는 훅 이벤트입니다.');
|
|
69
77
|
const identity = consoleCall(process.pid, 'ancestor');
|
|
70
78
|
if (identity.ignored)
|
|
71
79
|
return {};
|
|
80
|
+
if (identity.provider && identity.provider !== provider)
|
|
81
|
+
return {};
|
|
82
|
+
if (provider === 'claude') {
|
|
83
|
+
const conversationId = input.session_id;
|
|
84
|
+
const log = input.transcript_path;
|
|
85
|
+
const home = process.env.AXMAP_CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
86
|
+
const relative = typeof log === 'string' ? path.relative(path.join(home, 'projects'), path.resolve(log)) : '..';
|
|
87
|
+
if (!conversationId || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId) ||
|
|
88
|
+
!log || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(log) !== `${conversationId}.jsonl` ||
|
|
89
|
+
!input.cwd || !path.isAbsolute(input.cwd))
|
|
90
|
+
throw new Error('Claude 훅의 대화 기록·작업 폴더 신원이 올바르지 않습니다.');
|
|
91
|
+
const id = identityID(identity);
|
|
92
|
+
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
|
|
93
|
+
const state = event === 'SessionEnd' ? 'closed' : event === 'Stop' || event === 'SessionStart' ? 'settling' : 'running';
|
|
94
|
+
writeJSON(target(id), { ...previous, ...identity, id, provider: 'claude', transport: 'console', conversationId,
|
|
95
|
+
transcript: path.resolve(log), workspaces: [path.resolve(input.cwd)], repos: [repoKey(input.cwd)],
|
|
96
|
+
state, fullyIdle: false, event, registeredAt: new Date().toISOString() });
|
|
97
|
+
return { id };
|
|
98
|
+
}
|
|
72
99
|
const id = identityID(identity);
|
|
73
100
|
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
|
|
74
101
|
const workspaces = input.workspacePaths || previous.workspaces;
|
|
@@ -89,7 +116,8 @@ export function registerExternalHook(input, event) {
|
|
|
89
116
|
export function externalStatus(session) {
|
|
90
117
|
const s = readJSON(target(session.id));
|
|
91
118
|
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
|
|
92
|
-
const
|
|
119
|
+
const eligible = s.provider !== 'claude' || ['SessionStart', 'Stop'].includes(s.event || '');
|
|
120
|
+
const state = s.state === 'closed' ? 'closed' : eligible && consoleIdle(live.screen, live) ? 'idle' : 'unknown';
|
|
93
121
|
writeJSON(target(s.id), { ...readJSON(target(s.id)), state, fullyIdle: state === 'idle' });
|
|
94
122
|
return { id: s.id, state };
|
|
95
123
|
}
|
|
@@ -100,7 +128,9 @@ function consolePID(s) {
|
|
|
100
128
|
}
|
|
101
129
|
export function checkConsole(s) {
|
|
102
130
|
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
|
|
103
|
-
if (!
|
|
131
|
+
if (s.provider === 'claude' && !['SessionStart', 'Stop'].includes(s.event || ''))
|
|
132
|
+
throw new Error('Claude가 실행·승인 대기 중입니다.');
|
|
133
|
+
if (!consoleIdle(live.screen, live))
|
|
104
134
|
throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.');
|
|
105
135
|
return live;
|
|
106
136
|
}
|
|
@@ -111,6 +141,8 @@ export function validateConsolePrompt(prompt, from) {
|
|
|
111
141
|
export function findConsoleTranscript(s, request) {
|
|
112
142
|
if (s.transcript)
|
|
113
143
|
return s;
|
|
144
|
+
if (s.provider === 'claude')
|
|
145
|
+
return s;
|
|
114
146
|
const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain');
|
|
115
147
|
if (!fs.existsSync(brain))
|
|
116
148
|
return s;
|
|
@@ -153,7 +185,8 @@ export function submitConsole(s, request, preflight) {
|
|
|
153
185
|
// Bracketed paste preserves multiline prompts and prevents embedded Enter keys
|
|
154
186
|
// from becoming separate submissions. Only the final CR submits the message.
|
|
155
187
|
const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`;
|
|
156
|
-
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
|
|
188
|
+
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen,
|
|
189
|
+
expectedCursorX: live.cursorX, expectedCursorRow: live.cursorRow, text });
|
|
157
190
|
writeJSON(target(s.id), { ...readJSON(target(s.id)), state: 'running', fullyIdle: false });
|
|
158
191
|
return { accepted: true, pid: s.pid };
|
|
159
192
|
}
|