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.
- package/.claude/commands/pipeline.md +9 -0
- package/README.ko.md +37 -0
- package/README.md +37 -0
- package/bin/axmap.mjs +6 -0
- package/mcp/README.md +9 -0
- package/mcp/pipeline.html +184 -0
- package/mcp/server.mjs +161 -20
- package/package.json +12 -3
- package/skills/pipeline/SKILL.md +14 -0
- package/src/agentcli.mjs +17 -14
- package/src/agentcli.mts +19 -0
- package/src/bridge.mjs +297 -219
- package/src/bridge.mts +282 -0
- package/src/codex-connection.mjs +151 -0
- package/src/codex-connection.mts +83 -0
- package/src/external-console.mjs +140 -97
- package/src/external-console.mts +128 -0
- package/src/managed-session.mjs +325 -169
- package/src/managed-session.mts +242 -0
- package/src/monitor-cli.mjs +89 -0
- package/src/monitor-cli.mts +66 -0
- package/src/monitor.mjs +245 -0
- package/src/monitor.mts +199 -0
- package/src/node-pty.d.ts +13 -0
- package/src/pipeline-graph.mjs +55 -0
- package/src/pipeline-graph.mts +51 -0
- package/src/pipeline-mcp.mjs +111 -0
- package/src/pipeline-mcp.mts +105 -0
- package/src/pipeline-statusline.mjs +135 -0
- package/src/pipeline-statusline.mts +106 -0
- package/src/pipeline-web.mjs +56 -0
- package/src/pipeline-web.mts +31 -0
- package/src/session-dialogue.mjs +168 -116
- package/src/session-dialogue.mts +136 -0
- package/src/session-mailbox.mjs +190 -0
- package/src/session-mailbox.mts +157 -0
- package/src/session-status.mjs +47 -41
- package/src/session-status.mts +50 -0
- package/src/session-types.mjs +7 -0
- package/src/session-types.mts +119 -0
- package/src/session-usage.mjs +33 -28
- package/src/session-usage.mts +39 -0
- package/src/terminal-bridge.mjs +293 -209
- package/src/terminal-bridge.mts +235 -0
- package/src/windows-console.mjs +42 -0
- package/src/windows-console.mts +29 -0
- package/tools/bench-console.mjs +70 -0
- package/tools/bridge.mjs +41 -6
- package/tools/build.mjs +39 -0
- package/tools/monitor.mjs +5 -0
- package/tools/pipeline.mjs +34 -0
- package/tools/run-tests.mjs +3 -2
- package/tools/windows-console.cs +116 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Generated from src/pipeline-mcp.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Portable MCP observation: structured snapshots, plain text and progress share one source. */
|
|
3
|
+
import { createMonitor, terminalText, fitTerminal } from './monitor.mjs';
|
|
4
|
+
import { dispatchRequest, requestResult } from './bridge.mjs';
|
|
5
|
+
const labels = { sending: '전달 중', submitted: '응답 대기', completed: '완료 확인', unknown: '전송 확인 필요', failed: '실패', superseded: '다른 입력으로 대체됨' };
|
|
6
|
+
const pending = new Set(['sending', 'submitted', 'unknown']);
|
|
7
|
+
export function pipelineView(snapshot) {
|
|
8
|
+
return {
|
|
9
|
+
schemaVersion: 1, type: 'pipeline', cwd: snapshot.cwd, checkedAt: snapshot.checkedAt,
|
|
10
|
+
sessions: snapshot.sessions.map(s => ({ id: s.id, provider: s.provider, state: s.state, connection: s.connection,
|
|
11
|
+
model: s.model ?? null, transport: s.transport ?? null, conversationId: s.conversationId ?? null,
|
|
12
|
+
role: fitTerminal(s.role, 120), usage: s.usage ?? null, pendingApproval: !!s.pendingApproval })),
|
|
13
|
+
requests: snapshot.requests.map(r => ({ id: r.id, to: r.to, from: terminalText(r.from), state: r.state,
|
|
14
|
+
task: fitTerminal(r.prompt, 200), createdAt: r.createdAt, completedAt: r.completedAt, usage: r.usage ?? null,
|
|
15
|
+
latencyMs: r.completedAt ? Math.max(0, Date.parse(r.completedAt) - Date.parse(r.createdAt)) : null, error: fitTerminal(r.error, 240) })),
|
|
16
|
+
dialogues: snapshot.dialogues.map(d => ({ id: d.id, state: d.state, turns: d.turns.length,
|
|
17
|
+
maxTurns: d.limits?.maxTurns, usage: d.usage ?? null, usagePending: d.usagePending })),
|
|
18
|
+
claims: snapshot.claims.slice(0, 10).map(c => ({ agent: terminalText(c.agent), task: fitTerminal(c.intent || c.task, 160),
|
|
19
|
+
paths: c.paths.slice(0, 6).map(terminalText), pathCount: c.paths.length })),
|
|
20
|
+
events: snapshot.events.slice(0, 15).map(e => ({ ...e, label: terminalText(e.label) })),
|
|
21
|
+
issues: snapshot.issues.map(terminalText),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function pipelineText(view) {
|
|
25
|
+
const active = view.requests.filter(r => pending.has(r.state));
|
|
26
|
+
const lines = [`axMap · 진행 확인 ${active.length}개 · ${view.sessions.length}개 세션`, `저장소: ${terminalText(view.cwd)}`];
|
|
27
|
+
for (const session of view.sessions.slice(0, 5))
|
|
28
|
+
lines.push(`${session.provider} ${session.id.slice(0, 8)} · ${session.pendingApproval ? '사용자 승인 필요' : session.state} · ${session.connection}`);
|
|
29
|
+
const selected = [...active, ...view.requests.filter(r => !pending.has(r.state))].slice(0, 3);
|
|
30
|
+
for (const request of selected) {
|
|
31
|
+
lines.push(`${request.from} → ${request.to.slice(0, 8)} · ${labels[request.state] || request.state}`);
|
|
32
|
+
lines.push(request.state === 'completed' ? '접수 ✓ → 전달 ✓ → 응답 ✓ → 완료 확인 ✓' :
|
|
33
|
+
request.state === 'submitted' ? '접수 ✓ → 전달 ✓ → 응답 대기 → 완료 미확정' :
|
|
34
|
+
request.state === 'unknown' ? '접수 ✓ → 전달 여부 확인 필요 → 완료 미확정' : `접수 → ${labels[request.state] || request.state} → 완료 미확정`);
|
|
35
|
+
lines.push(`작업: ${request.task}`);
|
|
36
|
+
if (request.error)
|
|
37
|
+
lines.push(`사유: ${request.error}`);
|
|
38
|
+
}
|
|
39
|
+
if (!selected.length)
|
|
40
|
+
lines.push('새 요청을 기다리는 중');
|
|
41
|
+
for (const issue of view.issues.slice(0, 2))
|
|
42
|
+
lines.push(`확인 필요: ${issue}`);
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
45
|
+
function integer(value, fallback, min, max, name) {
|
|
46
|
+
const result = value === undefined ? fallback : value;
|
|
47
|
+
if (typeof result !== 'number' || !Number.isInteger(result) || result < min || result > max)
|
|
48
|
+
throw new Error(`${name}: ${min}~${max} 정수여야 합니다.`);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
52
|
+
const textResult = (text, structuredContent, isError = false) => ({ content: [{ type: 'text', text }], structuredContent, isError });
|
|
53
|
+
export async function runPipelineOperation(action, args, emit = () => { }, cancelled = () => false) {
|
|
54
|
+
const cwd = process.cwd(), progress = args.__progress === true;
|
|
55
|
+
if (action === 'snapshot' || action === 'watch') {
|
|
56
|
+
const duration = integer(args.duration_ms, action === 'watch' ? 5000 : 0, 0, 25000, 'duration_ms');
|
|
57
|
+
const interval = integer(args.interval_ms, 1000, 500, 5000, 'interval_ms');
|
|
58
|
+
const collect = createMonitor(cwd), deadline = Date.now() + (progress ? duration : 0);
|
|
59
|
+
let view;
|
|
60
|
+
do {
|
|
61
|
+
view = pipelineView(await collect());
|
|
62
|
+
if (progress) {
|
|
63
|
+
const active = view.requests.filter(r => pending.has(r.state));
|
|
64
|
+
emit(active.length ? active.slice(0, 3).map(r => `${r.id.slice(0, 8)} ${labels[r.state]}`).join(' · ') : 'axMap · 진행 중인 요청 없음');
|
|
65
|
+
}
|
|
66
|
+
if (cancelled() || Date.now() >= deadline)
|
|
67
|
+
break;
|
|
68
|
+
await delay(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
69
|
+
if (cancelled())
|
|
70
|
+
break;
|
|
71
|
+
} while (true);
|
|
72
|
+
const note = action === 'watch' && !progress ? '\n클라이언트가 progressToken을 제공하지 않아 현재 상태를 한 번 반환했습니다.' : '';
|
|
73
|
+
return textResult(pipelineText(view) + note + (cancelled() ? '\n관측 대기를 중단했습니다. 이미 전달한 작업은 계속 실행될 수 있습니다.' : ''), view, cancelled());
|
|
74
|
+
}
|
|
75
|
+
if (action === 'dispatch' || action === 'result') {
|
|
76
|
+
// Validate the wait budget before any irreversible submission.
|
|
77
|
+
const waitMs = integer(args.wait_ms, 0, 0, 30000, 'wait-ms');
|
|
78
|
+
let request;
|
|
79
|
+
if (action === 'dispatch') {
|
|
80
|
+
if (typeof args.to !== 'string' || typeof args.prompt !== 'string')
|
|
81
|
+
throw new Error('to와 prompt 문자열이 필요합니다.');
|
|
82
|
+
if (progress)
|
|
83
|
+
emit('axMap · 대상 세션과 입력 상태 확인');
|
|
84
|
+
request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap' });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
if (typeof args.id !== 'string')
|
|
88
|
+
throw new Error('요청 UUID가 필요합니다.');
|
|
89
|
+
request = requestResult(args.id, cwd);
|
|
90
|
+
}
|
|
91
|
+
const deadline = Date.now() + waitMs;
|
|
92
|
+
let lastMessage = '', lastAt = 0;
|
|
93
|
+
do {
|
|
94
|
+
const message = `axMap · ${request.id.slice(0, 8)} · ${labels[request.state] || request.state}`;
|
|
95
|
+
if (progress && (message !== lastMessage || Date.now() - lastAt >= 1000)) {
|
|
96
|
+
emit(message);
|
|
97
|
+
lastMessage = message;
|
|
98
|
+
lastAt = Date.now();
|
|
99
|
+
}
|
|
100
|
+
if (cancelled() || request.state !== 'submitted' || Date.now() >= deadline)
|
|
101
|
+
break;
|
|
102
|
+
await delay(Math.min(200, Math.max(0, deadline - Date.now())));
|
|
103
|
+
if (cancelled())
|
|
104
|
+
break;
|
|
105
|
+
request = requestResult(request.id, cwd);
|
|
106
|
+
} while (true);
|
|
107
|
+
return textResult(JSON.stringify(request, null, 2), { schemaVersion: 1, type: 'request', request,
|
|
108
|
+
observationCancelled: cancelled() }, action === 'dispatch' && request.state === 'unknown');
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`지원하지 않는 파이프라인 동작: ${action}`);
|
|
111
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Portable MCP observation: structured snapshots, plain text and progress share one source. */
|
|
2
|
+
import { createMonitor, terminalText, fitTerminal } from './monitor.mjs'
|
|
3
|
+
import { dispatchRequest, requestResult } from './bridge.mjs'
|
|
4
|
+
import type { MonitorSnapshot } from './monitor.mjs'
|
|
5
|
+
import type { RequestRecord } from './session-types.mjs'
|
|
6
|
+
|
|
7
|
+
export interface PipelineOutput {
|
|
8
|
+
content: Array<{ type: 'text'; text: string }>
|
|
9
|
+
structuredContent?: Record<string, unknown>
|
|
10
|
+
isError: boolean
|
|
11
|
+
}
|
|
12
|
+
export type PipelineEmit = (message: string) => void
|
|
13
|
+
const labels: Record<string, string> = { sending: '전달 중', submitted: '응답 대기', completed: '완료 확인', unknown: '전송 확인 필요', failed: '실패', superseded: '다른 입력으로 대체됨' }
|
|
14
|
+
const pending = new Set(['sending', 'submitted', 'unknown'])
|
|
15
|
+
|
|
16
|
+
export function pipelineView(snapshot: MonitorSnapshot) {
|
|
17
|
+
return {
|
|
18
|
+
schemaVersion: 1, type: 'pipeline', cwd: snapshot.cwd, checkedAt: snapshot.checkedAt,
|
|
19
|
+
sessions: snapshot.sessions.map(s => ({ id: s.id, provider: s.provider, state: s.state, connection: s.connection,
|
|
20
|
+
model: s.model ?? null, transport: s.transport ?? null, conversationId: s.conversationId ?? null,
|
|
21
|
+
role: fitTerminal(s.role, 120), usage: s.usage ?? null, pendingApproval: !!s.pendingApproval })),
|
|
22
|
+
requests: snapshot.requests.map(r => ({ id: r.id, to: r.to, from: terminalText(r.from), state: r.state,
|
|
23
|
+
task: fitTerminal(r.prompt, 200), createdAt: r.createdAt, completedAt: r.completedAt, usage: r.usage ?? null,
|
|
24
|
+
latencyMs: r.completedAt ? Math.max(0, Date.parse(r.completedAt) - Date.parse(r.createdAt)) : null, error: fitTerminal(r.error, 240) })),
|
|
25
|
+
dialogues: snapshot.dialogues.map(d => ({ id: d.id, state: d.state, turns: d.turns.length,
|
|
26
|
+
maxTurns: d.limits?.maxTurns, usage: d.usage ?? null, usagePending: d.usagePending })),
|
|
27
|
+
claims: snapshot.claims.slice(0, 10).map(c => ({ agent: terminalText(c.agent), task: fitTerminal(c.intent || c.task, 160),
|
|
28
|
+
paths: c.paths.slice(0, 6).map(terminalText), pathCount: c.paths.length })),
|
|
29
|
+
events: snapshot.events.slice(0, 15).map(e => ({ ...e, label: terminalText(e.label) })),
|
|
30
|
+
issues: snapshot.issues.map(terminalText),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export type PipelineView = ReturnType<typeof pipelineView>
|
|
34
|
+
export function pipelineText(view: PipelineView): string {
|
|
35
|
+
const active = view.requests.filter(r => pending.has(r.state))
|
|
36
|
+
const lines = [`axMap · 진행 확인 ${active.length}개 · ${view.sessions.length}개 세션`, `저장소: ${terminalText(view.cwd)}`]
|
|
37
|
+
for (const session of view.sessions.slice(0, 5)) lines.push(`${session.provider} ${session.id.slice(0, 8)} · ${session.pendingApproval ? '사용자 승인 필요' : session.state} · ${session.connection}`)
|
|
38
|
+
const selected = [...active, ...view.requests.filter(r => !pending.has(r.state))].slice(0, 3)
|
|
39
|
+
for (const request of selected) {
|
|
40
|
+
lines.push(`${request.from} → ${request.to.slice(0, 8)} · ${labels[request.state] || request.state}`)
|
|
41
|
+
lines.push(request.state === 'completed' ? '접수 ✓ → 전달 ✓ → 응답 ✓ → 완료 확인 ✓' :
|
|
42
|
+
request.state === 'submitted' ? '접수 ✓ → 전달 ✓ → 응답 대기 → 완료 미확정' :
|
|
43
|
+
request.state === 'unknown' ? '접수 ✓ → 전달 여부 확인 필요 → 완료 미확정' : `접수 → ${labels[request.state] || request.state} → 완료 미확정`)
|
|
44
|
+
lines.push(`작업: ${request.task}`)
|
|
45
|
+
if (request.error) lines.push(`사유: ${request.error}`)
|
|
46
|
+
}
|
|
47
|
+
if (!selected.length) lines.push('새 요청을 기다리는 중')
|
|
48
|
+
for (const issue of view.issues.slice(0, 2)) lines.push(`확인 필요: ${issue}`)
|
|
49
|
+
return lines.join('\n')
|
|
50
|
+
}
|
|
51
|
+
function integer(value: unknown, fallback: number, min: number, max: number, name: string): number {
|
|
52
|
+
const result = value === undefined ? fallback : value
|
|
53
|
+
if (typeof result !== 'number' || !Number.isInteger(result) || result < min || result > max) throw new Error(`${name}: ${min}~${max} 정수여야 합니다.`)
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
57
|
+
const textResult = (text: string, structuredContent?: Record<string, unknown>, isError = false): PipelineOutput => ({ content: [{ type: 'text', text }], structuredContent, isError })
|
|
58
|
+
|
|
59
|
+
export async function runPipelineOperation(action: string, args: Record<string, unknown>, emit: PipelineEmit = () => {}, cancelled: () => boolean = () => false): Promise<PipelineOutput> {
|
|
60
|
+
const cwd = process.cwd(), progress = args.__progress === true
|
|
61
|
+
if (action === 'snapshot' || action === 'watch') {
|
|
62
|
+
const duration = integer(args.duration_ms, action === 'watch' ? 5000 : 0, 0, 25000, 'duration_ms')
|
|
63
|
+
const interval = integer(args.interval_ms, 1000, 500, 5000, 'interval_ms')
|
|
64
|
+
const collect = createMonitor(cwd), deadline = Date.now() + (progress ? duration : 0)
|
|
65
|
+
let view: PipelineView
|
|
66
|
+
do {
|
|
67
|
+
view = pipelineView(await collect())
|
|
68
|
+
if (progress) {
|
|
69
|
+
const active = view.requests.filter(r => pending.has(r.state))
|
|
70
|
+
emit(active.length ? active.slice(0, 3).map(r => `${r.id.slice(0, 8)} ${labels[r.state]}`).join(' · ') : 'axMap · 진행 중인 요청 없음')
|
|
71
|
+
}
|
|
72
|
+
if (cancelled() || Date.now() >= deadline) break
|
|
73
|
+
await delay(Math.min(interval, Math.max(0, deadline - Date.now())))
|
|
74
|
+
if (cancelled()) break
|
|
75
|
+
} while (true)
|
|
76
|
+
const note = action === 'watch' && !progress ? '\n클라이언트가 progressToken을 제공하지 않아 현재 상태를 한 번 반환했습니다.' : ''
|
|
77
|
+
return textResult(pipelineText(view) + note + (cancelled() ? '\n관측 대기를 중단했습니다. 이미 전달한 작업은 계속 실행될 수 있습니다.' : ''), view, cancelled())
|
|
78
|
+
}
|
|
79
|
+
if (action === 'dispatch' || action === 'result') {
|
|
80
|
+
// Validate the wait budget before any irreversible submission.
|
|
81
|
+
const waitMs = integer(args.wait_ms, 0, 0, 30000, 'wait-ms')
|
|
82
|
+
let request: RequestRecord
|
|
83
|
+
if (action === 'dispatch') {
|
|
84
|
+
if (typeof args.to !== 'string' || typeof args.prompt !== 'string') throw new Error('to와 prompt 문자열이 필요합니다.')
|
|
85
|
+
if (progress) emit('axMap · 대상 세션과 입력 상태 확인')
|
|
86
|
+
request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap' })
|
|
87
|
+
} else {
|
|
88
|
+
if (typeof args.id !== 'string') throw new Error('요청 UUID가 필요합니다.')
|
|
89
|
+
request = requestResult(args.id, cwd)
|
|
90
|
+
}
|
|
91
|
+
const deadline = Date.now() + waitMs
|
|
92
|
+
let lastMessage = '', lastAt = 0
|
|
93
|
+
do {
|
|
94
|
+
const message = `axMap · ${request.id.slice(0, 8)} · ${labels[request.state] || request.state}`
|
|
95
|
+
if (progress && (message !== lastMessage || Date.now() - lastAt >= 1000)) { emit(message); lastMessage = message; lastAt = Date.now() }
|
|
96
|
+
if (cancelled() || request.state !== 'submitted' || Date.now() >= deadline) break
|
|
97
|
+
await delay(Math.min(200, Math.max(0, deadline - Date.now())))
|
|
98
|
+
if (cancelled()) break
|
|
99
|
+
request = requestResult(request.id, cwd)
|
|
100
|
+
} while (true)
|
|
101
|
+
return textResult(JSON.stringify(request, null, 2), { schemaVersion: 1, type: 'request', request,
|
|
102
|
+
observationCancelled: cancelled() }, action === 'dispatch' && request.state === 'unknown')
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`지원하지 않는 파이프라인 동작: ${action}`)
|
|
105
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Generated from src/pipeline-statusline.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Native CLI adapter. Reads local records only and preserves the user's old status command. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
|
|
10
|
+
import { createMonitor, fitTerminal, terminalText } from './monitor.mjs';
|
|
11
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const savedFile = (home, provider) => path.join(home, '.axmap', 'bridge', `statusline-${provider}.json`);
|
|
13
|
+
function client(value) { if (value !== 'claude' && value !== 'agy')
|
|
14
|
+
throw new Error('claude 또는 agy를 지정하세요.'); return value; }
|
|
15
|
+
function settings(home, provider) { return provider === 'claude' ? path.join(home, '.claude', 'settings.json') : path.join(home, '.gemini', 'antigravity-cli', 'settings.json'); }
|
|
16
|
+
export function installPipelineStatusline(providerName, home = os.homedir(), remove = false) {
|
|
17
|
+
const provider = client(providerName), target = settings(home, provider), file = savedFile(home, provider);
|
|
18
|
+
const original = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
19
|
+
const config = original === null ? {} : JSON.parse(original);
|
|
20
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
21
|
+
throw new Error('기존 설정을 읽지 못했습니다. 변경하지 않았습니다.');
|
|
22
|
+
const current = config.statusLine;
|
|
23
|
+
if (current !== undefined && (!current || typeof current !== 'object' || Array.isArray(current)))
|
|
24
|
+
throw new Error('기존 statusLine 설정이 객체가 아닙니다. 변경하지 않았습니다.');
|
|
25
|
+
const owned = typeof current?.command === 'string' && current.command.includes(`axmap-statusline-${provider}`);
|
|
26
|
+
if (remove && !owned)
|
|
27
|
+
return { provider, changed: false, note: '다른 상태줄 설정을 유지합니다.' };
|
|
28
|
+
const saved = fs.existsSync(file) ? readJSON(file) : null;
|
|
29
|
+
if (owned && !saved)
|
|
30
|
+
throw new Error('기존 상태줄 복원 정보를 찾지 못했습니다. 변경하지 않았습니다.');
|
|
31
|
+
const previous = owned ? saved.previous : current;
|
|
32
|
+
const script = path.join(root, 'tools', 'pipeline.mjs');
|
|
33
|
+
const wrapper = path.join(home, '.axmap', 'bridge', `axmap-statusline-${provider}${process.platform === 'win32' ? '.cmd' : '.sh'}`);
|
|
34
|
+
if (process.platform === 'win32' && [process.execPath, script, wrapper].some(p => /["\r\n%&|<>^!]/.test(p)))
|
|
35
|
+
throw new Error('상태줄 실행 경로에 지원하지 않는 셸 문자가 있습니다.');
|
|
36
|
+
if (process.platform === 'win32' && /\s/.test(wrapper))
|
|
37
|
+
throw new Error('이 Windows 상태줄 실행 경로에는 공백을 사용할 수 없습니다. 기존 설정을 유지합니다.');
|
|
38
|
+
const backup = `${target}.axmap-${randomUUID()}.bak`;
|
|
39
|
+
if (original !== null)
|
|
40
|
+
fs.writeFileSync(backup, original, { mode: 0o600 });
|
|
41
|
+
writeJSON(file, { previous, active: !remove });
|
|
42
|
+
if (remove) {
|
|
43
|
+
if (previous === undefined)
|
|
44
|
+
delete config.statusLine;
|
|
45
|
+
else
|
|
46
|
+
config.statusLine = previous;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
fs.mkdirSync(path.dirname(wrapper), { recursive: true });
|
|
50
|
+
const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
51
|
+
const command = process.platform === 'win32'
|
|
52
|
+
? `@echo off\r\n"${process.execPath}" "${script}" statusline --provider ${provider}\r\n`
|
|
53
|
+
: `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} statusline --provider ${provider}\n`;
|
|
54
|
+
fs.writeFileSync(wrapper, command, { mode: 0o700 });
|
|
55
|
+
config.statusLine = { ...previous, type: 'command',
|
|
56
|
+
command: provider === 'claude' ? `"${wrapper.replaceAll('\\', '/')}"` : process.platform === 'win32' ? wrapper : quote(wrapper),
|
|
57
|
+
...(provider === 'claude' ? { refreshInterval: previous?.refreshInterval ?? 2 } : { enabled: true, stack_with_default: previous?.stack_with_default ?? true }) };
|
|
58
|
+
}
|
|
59
|
+
writeJSON(target, config);
|
|
60
|
+
return { provider, changed: true, settings: target, backup: original === null ? null : backup,
|
|
61
|
+
previousPreserved: !!previous, installed: !remove };
|
|
62
|
+
}
|
|
63
|
+
function previousOutput(provider, home, input, previous) {
|
|
64
|
+
if (typeof previous?.command !== 'string' || !previous.command.trim())
|
|
65
|
+
return '';
|
|
66
|
+
let executable, args;
|
|
67
|
+
if (process.platform === 'win32' && provider === 'claude') {
|
|
68
|
+
const bash = [process.env.CLAUDE_CODE_GIT_BASH_PATH, path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')].find(p => p && fs.existsSync(p));
|
|
69
|
+
if (!bash)
|
|
70
|
+
return '기존 상태줄: Git Bash 경로 확인 필요\n';
|
|
71
|
+
executable = bash;
|
|
72
|
+
args = ['-c', previous.command];
|
|
73
|
+
}
|
|
74
|
+
else if (process.platform === 'win32') {
|
|
75
|
+
executable = process.env.ComSpec || 'cmd.exe';
|
|
76
|
+
args = ['/d', '/s', '/c', previous.command];
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
executable = '/bin/sh';
|
|
80
|
+
args = ['-c', previous.command];
|
|
81
|
+
}
|
|
82
|
+
// Only the user's pre-existing command is executed; provider JSON stays on stdin.
|
|
83
|
+
const options = { input, encoding: 'utf8', windowsHide: true, timeout: 1500, maxBuffer: 65536 };
|
|
84
|
+
const result = process.platform === 'win32' && provider === 'agy'
|
|
85
|
+
? spawnSync(previous.command, { ...options, shell: process.env.ComSpec || 'cmd.exe' })
|
|
86
|
+
: spawnSync(executable, args, options);
|
|
87
|
+
return result.stdout || '';
|
|
88
|
+
}
|
|
89
|
+
export async function pipelineStatusline(providerName, input, home = os.homedir()) {
|
|
90
|
+
const provider = client(providerName), file = savedFile(home, provider);
|
|
91
|
+
const saved = fs.existsSync(file) ? readJSON(file) : null;
|
|
92
|
+
const prior = previousOutput(provider, home, input, saved?.previous);
|
|
93
|
+
if (saved?.active === false)
|
|
94
|
+
return prior;
|
|
95
|
+
const payload = JSON.parse(input);
|
|
96
|
+
const candidates = new Set([payload.workspace?.current_dir, payload.cwd].filter((p) => typeof p === 'string' && path.isAbsolute(p)));
|
|
97
|
+
const conversationId = payload.conversation_id || payload.session_id;
|
|
98
|
+
if (conversationId) {
|
|
99
|
+
const directory = path.join(bridgeHome(), 'sessions');
|
|
100
|
+
if (fs.existsSync(directory))
|
|
101
|
+
for (const name of fs.readdirSync(directory).filter(n => /^[0-9a-f-]{36}\.json$/i.test(n))) {
|
|
102
|
+
try {
|
|
103
|
+
const s = readJSON(path.join(directory, name));
|
|
104
|
+
if (s.conversationId === conversationId && s.workspaces?.length === 1)
|
|
105
|
+
candidates.add(s.workspaces[0]);
|
|
106
|
+
}
|
|
107
|
+
catch { /* a partial record cannot establish scope */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let cwd;
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
try {
|
|
113
|
+
repoKey(candidate);
|
|
114
|
+
cwd = candidate;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
catch { /* try an explicitly attached workspace */ }
|
|
118
|
+
}
|
|
119
|
+
const width = Math.max(20, Math.min(240, Number(payload.terminal_width || process.env.COLUMNS) || 100));
|
|
120
|
+
let line = 'axMap · 이 대화는 Git 저장소에 연결되지 않았습니다.';
|
|
121
|
+
if (cwd) {
|
|
122
|
+
const snapshot = await createMonitor(cwd, { probe: async () => [],
|
|
123
|
+
resolve: id => readJSON(path.join(bridgeHome(), 'requests', `${id}.json`)) })();
|
|
124
|
+
const active = snapshot.requests.find(r => ['sending', 'submitted', 'unknown'].includes(r.state));
|
|
125
|
+
const request = active || snapshot.requests[0];
|
|
126
|
+
const state = payload.tool_confirmation_pending ? '승인 필요' : { working: '작업 중', tool_use: '도구 실행', reviewing: '검토 중', idle: '대기', error: '오류', initializing: '시작 중', authenticating: '인증 중' }[payload.agent_state || ''];
|
|
127
|
+
const spin = ['◐', '◓', '◑', '◒'][Math.floor(Date.now() / 300) % 4];
|
|
128
|
+
const flow = !request ? '새 요청 대기' : request.state === 'completed' ? '접수 ✓ → 전달 ✓ → 응답 ✓ → 완료 ✓' : request.state === 'submitted'
|
|
129
|
+
? `접수 ✓ → 전달 ✓ → ${spin} 응답 대기 → 완료 미확정` : request.state === 'sending' ? `${spin} 전달 중 → 응답 대기` : request.state === 'unknown' ? '전달 확인 필요 → 완료 미확정' : `${request.state} → 완료 미확정`;
|
|
130
|
+
line = `axMap${state ? ` · ${provider} ${state}` : ''} · ${!active && request ? '최근 요청 · ' : ''}${flow}`;
|
|
131
|
+
if (snapshot.issues.length)
|
|
132
|
+
line += ' · 기록 확인 필요';
|
|
133
|
+
}
|
|
134
|
+
return prior + (prior && !prior.endsWith('\n') ? '\n' : '') + fitTerminal(terminalText(line), width) + '\n';
|
|
135
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Native CLI adapter. Reads local records only and preserves the user's old status command. */
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
|
|
9
|
+
import { createMonitor, fitTerminal, terminalText } from './monitor.mjs'
|
|
10
|
+
import type { RequestRecord } from './session-types.mjs'
|
|
11
|
+
|
|
12
|
+
type Client = 'claude' | 'agy'
|
|
13
|
+
interface StatusConfig { command?: string; [key: string]: unknown }
|
|
14
|
+
interface SavedStatus { previous?: StatusConfig; active: boolean }
|
|
15
|
+
const root = fileURLToPath(new URL('../', import.meta.url))
|
|
16
|
+
const savedFile = (home: string, provider: Client) => path.join(home, '.axmap', 'bridge', `statusline-${provider}.json`)
|
|
17
|
+
function client(value: string): Client { if (value !== 'claude' && value !== 'agy') throw new Error('claude 또는 agy를 지정하세요.'); return value }
|
|
18
|
+
function settings(home: string, provider: Client) { return provider === 'claude' ? path.join(home, '.claude', 'settings.json') : path.join(home, '.gemini', 'antigravity-cli', 'settings.json') }
|
|
19
|
+
|
|
20
|
+
export function installPipelineStatusline(providerName: string, home = os.homedir(), remove = false) {
|
|
21
|
+
const provider = client(providerName), target = settings(home, provider), file = savedFile(home, provider)
|
|
22
|
+
const original = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null
|
|
23
|
+
const config: Record<string, unknown> = original === null ? {} : JSON.parse(original)
|
|
24
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 설정을 읽지 못했습니다. 변경하지 않았습니다.')
|
|
25
|
+
const current = config.statusLine as StatusConfig | undefined
|
|
26
|
+
if (current !== undefined && (!current || typeof current !== 'object' || Array.isArray(current))) throw new Error('기존 statusLine 설정이 객체가 아닙니다. 변경하지 않았습니다.')
|
|
27
|
+
const owned = typeof current?.command === 'string' && current.command.includes(`axmap-statusline-${provider}`)
|
|
28
|
+
if (remove && !owned) return { provider, changed: false, note: '다른 상태줄 설정을 유지합니다.' }
|
|
29
|
+
const saved = fs.existsSync(file) ? readJSON<SavedStatus>(file) : null
|
|
30
|
+
if (owned && !saved) throw new Error('기존 상태줄 복원 정보를 찾지 못했습니다. 변경하지 않았습니다.')
|
|
31
|
+
const previous = owned ? saved!.previous : current
|
|
32
|
+
const script = path.join(root, 'tools', 'pipeline.mjs')
|
|
33
|
+
const wrapper = path.join(home, '.axmap', 'bridge', `axmap-statusline-${provider}${process.platform === 'win32' ? '.cmd' : '.sh'}`)
|
|
34
|
+
if (process.platform === 'win32' && [process.execPath, script, wrapper].some(p => /["\r\n%&|<>^!]/.test(p))) throw new Error('상태줄 실행 경로에 지원하지 않는 셸 문자가 있습니다.')
|
|
35
|
+
if (process.platform === 'win32' && /\s/.test(wrapper)) throw new Error('이 Windows 상태줄 실행 경로에는 공백을 사용할 수 없습니다. 기존 설정을 유지합니다.')
|
|
36
|
+
const backup = `${target}.axmap-${randomUUID()}.bak`
|
|
37
|
+
if (original !== null) fs.writeFileSync(backup, original, { mode: 0o600 })
|
|
38
|
+
writeJSON(file, { previous, active: !remove })
|
|
39
|
+
if (remove) {
|
|
40
|
+
if (previous === undefined) delete config.statusLine; else config.statusLine = previous
|
|
41
|
+
} else {
|
|
42
|
+
fs.mkdirSync(path.dirname(wrapper), { recursive: true })
|
|
43
|
+
const quote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`
|
|
44
|
+
const command = process.platform === 'win32'
|
|
45
|
+
? `@echo off\r\n"${process.execPath}" "${script}" statusline --provider ${provider}\r\n`
|
|
46
|
+
: `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} statusline --provider ${provider}\n`
|
|
47
|
+
fs.writeFileSync(wrapper, command, { mode: 0o700 })
|
|
48
|
+
config.statusLine = { ...previous, type: 'command',
|
|
49
|
+
command: provider === 'claude' ? `"${wrapper.replaceAll('\\', '/')}"` : process.platform === 'win32' ? wrapper : quote(wrapper),
|
|
50
|
+
...(provider === 'claude' ? { refreshInterval: previous?.refreshInterval ?? 2 } : { enabled: true, stack_with_default: previous?.stack_with_default ?? true }) }
|
|
51
|
+
}
|
|
52
|
+
writeJSON(target, config)
|
|
53
|
+
return { provider, changed: true, settings: target, backup: original === null ? null : backup,
|
|
54
|
+
previousPreserved: !!previous, installed: !remove }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function previousOutput(provider: Client, home: string, input: string, previous?: StatusConfig): string {
|
|
58
|
+
if (typeof previous?.command !== 'string' || !previous.command.trim()) return ''
|
|
59
|
+
let executable: string, args: string[]
|
|
60
|
+
if (process.platform === 'win32' && provider === 'claude') {
|
|
61
|
+
const bash = [process.env.CLAUDE_CODE_GIT_BASH_PATH, path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')].find(p => p && fs.existsSync(p))
|
|
62
|
+
if (!bash) return '기존 상태줄: Git Bash 경로 확인 필요\n'
|
|
63
|
+
executable = bash; args = ['-c', previous.command]
|
|
64
|
+
} else if (process.platform === 'win32') { executable = process.env.ComSpec || 'cmd.exe'; args = ['/d', '/s', '/c', previous.command] }
|
|
65
|
+
else { executable = '/bin/sh'; args = ['-c', previous.command] }
|
|
66
|
+
// Only the user's pre-existing command is executed; provider JSON stays on stdin.
|
|
67
|
+
const options = { input, encoding: 'utf8' as const, windowsHide: true, timeout: 1500, maxBuffer: 65536 }
|
|
68
|
+
const result = process.platform === 'win32' && provider === 'agy'
|
|
69
|
+
? spawnSync(previous.command, { ...options, shell: process.env.ComSpec || 'cmd.exe' })
|
|
70
|
+
: spawnSync(executable, args, options)
|
|
71
|
+
return result.stdout || ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function pipelineStatusline(providerName: string, input: string, home = os.homedir()): Promise<string> {
|
|
75
|
+
const provider = client(providerName), file = savedFile(home, provider)
|
|
76
|
+
const saved = fs.existsSync(file) ? readJSON<SavedStatus>(file) : null
|
|
77
|
+
const prior = previousOutput(provider, home, input, saved?.previous)
|
|
78
|
+
if (saved?.active === false) return prior
|
|
79
|
+
const payload = JSON.parse(input) as { cwd?: string; workspace?: { current_dir?: string }; conversation_id?: string; session_id?: string;
|
|
80
|
+
terminal_width?: number; agent_state?: string; tool_confirmation_pending?: boolean }
|
|
81
|
+
const candidates = new Set([payload.workspace?.current_dir, payload.cwd].filter((p): p is string => typeof p === 'string' && path.isAbsolute(p)))
|
|
82
|
+
const conversationId = payload.conversation_id || payload.session_id
|
|
83
|
+
if (conversationId) {
|
|
84
|
+
const directory = path.join(bridgeHome(), 'sessions')
|
|
85
|
+
if (fs.existsSync(directory)) for (const name of fs.readdirSync(directory).filter(n => /^[0-9a-f-]{36}\.json$/i.test(n))) {
|
|
86
|
+
try { const s = readJSON<{ conversationId?: string; workspaces?: string[] }>(path.join(directory, name)); if (s.conversationId === conversationId && s.workspaces?.length === 1) candidates.add(s.workspaces[0]!) } catch { /* a partial record cannot establish scope */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let cwd: string | undefined
|
|
90
|
+
for (const candidate of candidates) { try { repoKey(candidate); cwd = candidate; break } catch { /* try an explicitly attached workspace */ } }
|
|
91
|
+
const width = Math.max(20, Math.min(240, Number(payload.terminal_width || process.env.COLUMNS) || 100))
|
|
92
|
+
let line = 'axMap · 이 대화는 Git 저장소에 연결되지 않았습니다.'
|
|
93
|
+
if (cwd) {
|
|
94
|
+
const snapshot = await createMonitor(cwd, { probe: async () => [],
|
|
95
|
+
resolve: id => readJSON<RequestRecord>(path.join(bridgeHome(), 'requests', `${id}.json`)) })()
|
|
96
|
+
const active = snapshot.requests.find(r => ['sending', 'submitted', 'unknown'].includes(r.state))
|
|
97
|
+
const request = active || snapshot.requests[0]
|
|
98
|
+
const state = payload.tool_confirmation_pending ? '승인 필요' : ({ working: '작업 중', tool_use: '도구 실행', reviewing: '검토 중', idle: '대기', error: '오류', initializing: '시작 중', authenticating: '인증 중' } as Record<string, string>)[payload.agent_state || '']
|
|
99
|
+
const spin = ['◐', '◓', '◑', '◒'][Math.floor(Date.now() / 300) % 4]
|
|
100
|
+
const flow = !request ? '새 요청 대기' : request.state === 'completed' ? '접수 ✓ → 전달 ✓ → 응답 ✓ → 완료 ✓' : request.state === 'submitted'
|
|
101
|
+
? `접수 ✓ → 전달 ✓ → ${spin} 응답 대기 → 완료 미확정` : request.state === 'sending' ? `${spin} 전달 중 → 응답 대기` : request.state === 'unknown' ? '전달 확인 필요 → 완료 미확정' : `${request.state} → 완료 미확정`
|
|
102
|
+
line = `axMap${state ? ` · ${provider} ${state}` : ''} · ${!active && request ? '최근 요청 · ' : ''}${flow}`
|
|
103
|
+
if (snapshot.issues.length) line += ' · 기록 확인 필요'
|
|
104
|
+
}
|
|
105
|
+
return prior + (prior && !prior.endsWith('\n') ? '\n' : '') + fitTerminal(terminalText(line), width) + '\n'
|
|
106
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Generated from src/pipeline-web.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Read-only loopback view for clients that cannot embed MCP Apps. */
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { createMonitor } from './monitor.mjs';
|
|
6
|
+
import { pipelineView } from './pipeline-mcp.mjs';
|
|
7
|
+
export async function startPipelineWeb(cwd, port = 0) {
|
|
8
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
9
|
+
throw new Error('port must be 0..65535');
|
|
10
|
+
const collect = createMonitor(cwd);
|
|
11
|
+
const html = fs.readFileSync(new URL('../mcp/pipeline.html', import.meta.url), 'utf8')
|
|
12
|
+
.replace('<html lang="ko">', '<html lang="ko" data-axmap-local="true">')
|
|
13
|
+
.replace('<script>', '<script>window.axmapLocalRead=()=>fetch("/api/snapshot",{cache:"no-store"}).then(r=>{if(!r.ok)throw new Error("Local observation failed");return r.json()});</script><script>');
|
|
14
|
+
let collecting = null, url = '';
|
|
15
|
+
const server = http.createServer(async (req, res) => {
|
|
16
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
17
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
18
|
+
if (req.headers.host !== new URL(url).host || req.headers.origin && req.headers.origin !== url || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
19
|
+
res.writeHead(403);
|
|
20
|
+
res.end('Local origin required');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (req.method !== 'GET') {
|
|
24
|
+
res.writeHead(405);
|
|
25
|
+
res.end('Read-only');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (req.url === '/' || req.url === '/pipeline') {
|
|
29
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
30
|
+
res.end(html);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (req.url !== '/api/snapshot') {
|
|
34
|
+
res.writeHead(404);
|
|
35
|
+
res.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
// Concurrent browser refreshes share observation; none submit a model turn.
|
|
40
|
+
collecting ??= collect().finally(() => { collecting = null; });
|
|
41
|
+
const snapshot = await collecting;
|
|
42
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
43
|
+
res.end(JSON.stringify({ structuredContent: pipelineView(snapshot), isError: false }));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
res.writeHead(503);
|
|
47
|
+
res.end(JSON.stringify({ isError: true, content: [{ type: 'text', text: 'Local observation failed' }] }));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
51
|
+
const address = server.address();
|
|
52
|
+
if (!address || typeof address === 'string')
|
|
53
|
+
throw new Error('Local listener unavailable');
|
|
54
|
+
url = `http://127.0.0.1:${address.port}`;
|
|
55
|
+
return { url: `${url}/pipeline`, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections(); }) };
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Read-only loopback view for clients that cannot embed MCP Apps. */
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import { createMonitor } from './monitor.mjs'
|
|
5
|
+
import { pipelineView } from './pipeline-mcp.mjs'
|
|
6
|
+
export async function startPipelineWeb(cwd: string, port = 0) {
|
|
7
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('port must be 0..65535')
|
|
8
|
+
const collect = createMonitor(cwd)
|
|
9
|
+
const html = fs.readFileSync(new URL('../mcp/pipeline.html', import.meta.url), 'utf8')
|
|
10
|
+
.replace('<html lang="ko">', '<html lang="ko" data-axmap-local="true">')
|
|
11
|
+
.replace('<script>', '<script>window.axmapLocalRead=()=>fetch("/api/snapshot",{cache:"no-store"}).then(r=>{if(!r.ok)throw new Error("Local observation failed");return r.json()});</script><script>')
|
|
12
|
+
let collecting: ReturnType<typeof collect> | null = null, url = ''
|
|
13
|
+
const server = http.createServer(async (req, res) => {
|
|
14
|
+
res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
15
|
+
if (req.headers.host !== new URL(url).host || req.headers.origin && req.headers.origin !== url || req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end('Local origin required'); return }
|
|
16
|
+
if (req.method !== 'GET') { res.writeHead(405); res.end('Read-only'); return }
|
|
17
|
+
if (req.url === '/' || req.url === '/pipeline') { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(html); return }
|
|
18
|
+
if (req.url !== '/api/snapshot') { res.writeHead(404); res.end(); return }
|
|
19
|
+
try {
|
|
20
|
+
// Concurrent browser refreshes share observation; none submit a model turn.
|
|
21
|
+
collecting ??= collect().finally(() => { collecting = null })
|
|
22
|
+
const snapshot = await collecting
|
|
23
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ structuredContent: pipelineView(snapshot), isError: false }))
|
|
24
|
+
} catch { res.writeHead(503); res.end(JSON.stringify({ isError: true, content: [{ type: 'text', text: 'Local observation failed' }] })) }
|
|
25
|
+
})
|
|
26
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve) })
|
|
27
|
+
const address = server.address()
|
|
28
|
+
if (!address || typeof address === 'string') throw new Error('Local listener unavailable')
|
|
29
|
+
url = `http://127.0.0.1:${address.port}`
|
|
30
|
+
return { url: `${url}/pipeline`, close: () => new Promise<void>((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections() }) }
|
|
31
|
+
}
|