axmap-cli 1.6.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 +17 -0
- package/README.md +17 -0
- package/mcp/README.md +3 -0
- package/mcp/pipeline.html +34 -7
- package/mcp/server.mjs +34 -3
- package/package.json +2 -1
- package/skills/pipeline/SKILL.md +14 -0
- package/src/bridge.mjs +1 -0
- package/src/bridge.mts +1 -0
- package/src/codex-connection.mjs +151 -0
- package/src/codex-connection.mts +83 -0
- package/src/managed-session.mjs +76 -14
- package/src/managed-session.mts +57 -13
- package/src/monitor-cli.mjs +5 -3
- package/src/monitor-cli.mts +5 -3
- package/src/monitor.mjs +5 -0
- package/src/monitor.mts +4 -0
- package/src/pipeline-graph.mjs +55 -0
- package/src/pipeline-graph.mts +51 -0
- package/src/pipeline-mcp.mjs +3 -1
- package/src/pipeline-mcp.mts +3 -1
- package/src/pipeline-web.mjs +56 -0
- package/src/pipeline-web.mts +31 -0
- package/src/session-mailbox.mjs +190 -0
- package/src/session-mailbox.mts +157 -0
- package/src/session-types.mts +2 -1
- package/tools/bridge.mjs +32 -0
- package/tools/pipeline.mjs +10 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: axMap 로컬 세션 그래프와 실제 요청·응답·토큰 상태를 표시한다
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
현재 작업 저장소에 대해 `ax_pipeline`을 호출해 MCP Apps 패널을 연다. 연결된 세션, 역할, 요청/응답 연결, 실제 사용량과 이벤트를 표시한다. 토큰/진행률을 추정하거나 모델을 호출하지 않는다.
|
|
6
|
+
|
|
7
|
+
도구 또는 UI 확장이 없으면 `axmap pipeline --cwd <현재 Git 저장소 절대경로>`로 터미널 그래프를 연다. 대화 출력만 가능한 환경은 `--once` 스냅샷임을 명시한다. 다른 저장소로 임의 전환하거나 세션을 새로 만들지 않는다.
|
|
8
|
+
|
|
9
|
+
명령 호출 자체는 세션 실행·재전송·승인·설정 변경을 요청하는 것이 아니다.
|
package/README.ko.md
CHANGED
|
@@ -29,6 +29,23 @@ axmap setup
|
|
|
29
29
|
|
|
30
30
|
최신 변경부터 정리합니다.
|
|
31
31
|
|
|
32
|
+
### 1.7.0
|
|
33
|
+
|
|
34
|
+
<sub>2026.10.27</sub>
|
|
35
|
+
|
|
36
|
+
**일반 협업 알림을 작업 경계에서 모아 처리하고 기존 로컬 Codex 대화를 연결합니다.**
|
|
37
|
+
|
|
38
|
+
- 일반 알림은 `ax_message` / `axmap bridge message`로 새 모델 턴 없이 저장합니다. 수신자는 작업·테스트·실험의 안전한 경계에서 `ax_mailbox`로 묶음 회수합니다.
|
|
39
|
+
- 중단 요청·데이터 손상 위험·현재 작업 무효화를 명시적 긴급 사유로 구분합니다. 수신자가 작업 중이면 전달을 보류하며 실행 중 작업을 강제 중단하지 않습니다.
|
|
40
|
+
- 발신자·시각·이력을 보존하고 만료, 멱등 키, 같은 발신자·주제의 대기 업데이트 최신화를 지원합니다. 전달불명 메시지는 자동 재전송하지 않습니다.
|
|
41
|
+
- 로컬 제어 소켓으로 기존 Codex 대화를 연결합니다. 작업 중에는 관측만 허용하고 새 요청 전 대기 상태를 확인합니다.
|
|
42
|
+
- `axmap pipeline` 그래프와 읽기 전용 loopback 웹 화면, 새 모델 턴을 시작하지 않는 Codex 문맥 안내를 추가합니다.
|
|
43
|
+
|
|
44
|
+
받은 편지함은 격리 provider와 CLI/MCP 계약으로 검증했습니다. 실제 턴 절감량은 미측정이며 실행 중 강제 중단은 지원하지 않습니다. 수신 에이전트가 작업 경계에서 받은 편지함을 회수해야 합니다. 새 도구를 발견하려면 MCP를 다시 연결하세요. 실행용 기존 `ax_dispatch`는 유지합니다.
|
|
45
|
+
[받은 편지함 안내 →](docs/SESSION-MAILBOX.md) · [로컬 파이프라인 →](docs/LOCAL-PIPELINE.md)
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
32
49
|
### 1.6.0
|
|
33
50
|
|
|
34
51
|
<sub>2026.10.26</sub>
|
package/README.md
CHANGED
|
@@ -29,6 +29,23 @@ View pipeline state inside compatible MCP Apps hosts, or add it to the existing
|
|
|
29
29
|
|
|
30
30
|
Newest changes first.
|
|
31
31
|
|
|
32
|
+
### 1.7.0
|
|
33
|
+
|
|
34
|
+
<sub>October 27, 2026</sub>
|
|
35
|
+
|
|
36
|
+
**Batch routine collaboration messages at safe work boundaries and connect existing local Codex threads.**
|
|
37
|
+
|
|
38
|
+
- Use `ax_message` / `axmap bridge message` for routine updates without starting a model turn. Receivers collect messages together with `ax_mailbox` at task, test or experiment checkpoints.
|
|
39
|
+
- Explicit urgent reasons distinguish stop requests, data-loss risks and changes that invalidate current work. Busy recipients defer delivery; this release does not force-interrupt active work.
|
|
40
|
+
- Preserve message provenance and history while supporting expiration, idempotency keys and latest pending updates per sender/topic. Uncertain delivery is never automatically retried.
|
|
41
|
+
- Connect an existing Codex thread through its local control socket, with read-only observation of active work and idle checks before submission.
|
|
42
|
+
- Add the `axmap pipeline` graph, a read-only loopback web view and context-only Codex notices that do not start a new model turn.
|
|
43
|
+
|
|
44
|
+
Mailbox behavior is covered by isolated provider and CLI/MCP tests. Actual turn savings and active-work interruption are not claimed. Receivers must collect their mailbox at safe checkpoints; reconnect MCP clients to discover the new tools. Existing execution-oriented `ax_dispatch` remains available.
|
|
45
|
+
[Mailbox guide →](docs/SESSION-MAILBOX.md) · [Local pipeline →](docs/LOCAL-PIPELINE.md)
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
32
49
|
### 1.6.0
|
|
33
50
|
|
|
34
51
|
<sub>October 26, 2026</sub>
|
package/mcp/README.md
CHANGED
|
@@ -47,6 +47,9 @@ https://github.com/rl2aderjoon/axmap/blob/main/mcp/SETUP-FOR-AI.md
|
|
|
47
47
|
| `ax_inbox(id?)` | 다른 에이전트가 나에게 보낸 쪽지를 읽는다 |
|
|
48
48
|
| `ax_sessions()` | 같은 repo의 연결된 세션·역할·상태·사용량 |
|
|
49
49
|
| `ax_dispatch(to, prompt)` / `ax_result(id)` | 연결된 세션에 단일 작업 전달·결과 조회 |
|
|
50
|
+
| `ax_message(to, text)` | 일반 협업 알림 무호출 큐 저장. 즉시 전달은 명시적 긴급 사유 필요 |
|
|
51
|
+
| `ax_mailbox(to, take, at)` | 안전한 작업 경계에서 메시지 묶음 회수. 모델 호출 없음 |
|
|
52
|
+
| `ax_mailbox_flush(to, at)` | 필요할 때 idle 상대에게 대기 메시지를 한 요청으로 전달 |
|
|
50
53
|
| `ax_chat(left, right, prompt, maxTurns?, maxTokens?)` | 관리형 세션 두 개의 제한된 자동 대화를 백그라운드 접수 |
|
|
51
54
|
| `ax_dialogue(id)` / `ax_chat_stop(id)` | 대화·토큰 조회 / 추가 중계 중단 |
|
|
52
55
|
| `ax_send(to?, subject, body)` | 다른 에이전트에게 쪽지를 보낸다 |
|
package/mcp/pipeline.html
CHANGED
|
@@ -19,8 +19,22 @@ body[data-live="true"] .status::before { background:var(--accent); }
|
|
|
19
19
|
.scope { font-size:12px; color:var(--muted); margin:6px 0 16px; overflow-wrap:anywhere; }
|
|
20
20
|
button { margin-left:auto; font:inherit; color:var(--ink); background:var(--surface); border:1px solid var(--line); border-radius:6px; padding:5px 10px; cursor:pointer; }
|
|
21
21
|
button:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
|
22
|
-
.
|
|
23
|
-
.
|
|
22
|
+
.topology { position:relative; padding-top:6px; }
|
|
23
|
+
.router { width:max-content; max-width:100%; margin:0 auto 28px; padding:14px 24px; border:1px solid var(--accent); border-radius:8px; text-align:center; background:var(--soft); }
|
|
24
|
+
.router strong { display:block; font-size:16px; }
|
|
25
|
+
.router::after { content:''; display:block; position:absolute; height:28px; width:1px; background:var(--line); left:50%; margin-top:14px; }
|
|
26
|
+
.sessions { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:20px; }
|
|
27
|
+
.session { position:relative; display:flex; flex-direction:column; gap:7px; align-items:stretch; border:1px solid var(--line); padding:14px; border-radius:8px; min-width:0; overflow-wrap:anywhere; }
|
|
28
|
+
.session::before { content:''; position:absolute; height:20px; width:1px; background:var(--line); left:50%; top:-21px; }
|
|
29
|
+
.session.running { border-color:var(--accent); background:var(--soft); }
|
|
30
|
+
.session.running::after { content:''; position:absolute; width:6px; height:6px; border-radius:50%; background:var(--accent); left:calc(50% - 3px); top:-20px; animation:packet 1.4s linear infinite; }
|
|
31
|
+
body:not([data-live="true"]) .session.running::after { animation-play-state:paused; }
|
|
32
|
+
.session .identity { font-size:12px; color:var(--muted); }
|
|
33
|
+
.session .usage { font-variant-numeric:tabular-nums; border-top:1px solid var(--line); padding-top:7px; }
|
|
34
|
+
.session .activity { font-size:12px; color:var(--muted); }
|
|
35
|
+
.session .state { font-weight:500; }
|
|
36
|
+
.claims { padding:12px 0; color:var(--muted); overflow-wrap:anywhere; }
|
|
37
|
+
@keyframes packet { from { transform:translateY(0); } to { transform:translateY(20px); } }
|
|
24
38
|
.session strong { font-weight:600; }
|
|
25
39
|
.subtle { color:var(--muted); }
|
|
26
40
|
.job { padding:14px 0; border-bottom:1px solid var(--line); }
|
|
@@ -40,7 +54,7 @@ body:not([data-live="true"]) .step.current .mark::after { animation-play-state:p
|
|
|
40
54
|
time { color:var(--muted); margin-right:10px; font-variant-numeric:tabular-nums; }
|
|
41
55
|
footer { border-top:1px solid var(--line); padding-top:10px; margin-top:14px; color:var(--muted); font-size:12px; }
|
|
42
56
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
43
|
-
@media (prefers-reduced-motion:reduce) { .step.current .mark::after { animation:none; } }
|
|
57
|
+
@media (prefers-reduced-motion:reduce) { .step.current .mark::after,.session.running::after { animation:none; } }
|
|
44
58
|
@media(max-width:420px) { body{padding:12px;} .steps{grid-template-columns:1fr;gap:10px;} .step{flex-direction:row;text-align:left;gap:12px;} .step:not(:last-child)::after{top:31px;left:14px;height:7px;width:1px;} button{margin-left:0;} }
|
|
45
59
|
</style>
|
|
46
60
|
</head>
|
|
@@ -48,7 +62,10 @@ footer { border-top:1px solid var(--line); padding-top:10px; margin-top:14px; co
|
|
|
48
62
|
<main id="axmap-pipeline">
|
|
49
63
|
<header><h1>axMap 실행 흐름</h1><span class="status" id="status">연결 확인 중</span><button type="button" id="pause">새로고침 일시정지</button></header>
|
|
50
64
|
<div class="scope" id="scope">실제 작업 상태를 기다리는 중</div>
|
|
51
|
-
<
|
|
65
|
+
<section class="topology" aria-label="등록된 세션과 로컬 메시지 연결">
|
|
66
|
+
<div class="router"><strong>axMap · 로컬 라우터</strong><span class="subtle">기존 대화 유지 · 요청 ID 추적 · Git 왕복 없음</span></div>
|
|
67
|
+
<div class="sessions" id="sessions"></div>
|
|
68
|
+
</section>
|
|
52
69
|
<section aria-label="요청 파이프라인" id="jobs"></section>
|
|
53
70
|
<div class="issue" id="issue" role="status" aria-live="polite"></div>
|
|
54
71
|
<section id="event-section"><h2>최근 이벤트</h2><ul class="events" id="events"></ul></section>
|
|
@@ -86,8 +103,13 @@ footer { border-top:1px solid var(--line); padding-top:10px; margin-top:14px; co
|
|
|
86
103
|
if(!data||data.schemaVersion!==1||data.type!=='pipeline'||typeof data.cwd!=='string'||!Array.isArray(data.requests)||!Array.isArray(data.sessions))return;
|
|
87
104
|
if(cwd&&data.cwd!==cwd){issue.textContent='다른 저장소의 상태를 받았습니다. 현재 패널의 저장소를 유지합니다.';setLive(false,'저장소 확인 필요');return;}
|
|
88
105
|
cwd=data.cwd;scope.textContent=text(cwd);sessions.replaceChildren();jobs.replaceChildren();events.replaceChildren();
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
const ordered=[...data.sessions].sort((a,b)=>Number(b.connection==='connected')-Number(a.connection==='connected'));
|
|
107
|
+
for(const session of ordered.slice(0,6)){
|
|
108
|
+
const waiting=data.requests.filter(job=>job.to===session.id&&pending.has(job.state));
|
|
109
|
+
const line=node('article',undefined,`session${waiting.length&&session.connection==='connected'?' running':''}`);
|
|
110
|
+
line.append(node('strong',session.role||`${session.provider} 세션`),node('span',`${session.provider} · ${text(session.id).slice(0,8)} · ${session.transport||'연결 방식 미확인'}`,'identity'),node('span',`${session.pendingApproval?'사용자 승인 필요':sessionStates[session.state]||'상태 미확인'} · ${connectionStates[session.connection]||'확인 미지원'}`,'state'),node('span',session.model||'모델 미확인','identity'));
|
|
111
|
+
line.append(node('div',session.usage?.totalTokens==null?'토큰 미측정':`누적 처리 ${session.usage.totalTokens.toLocaleString()} tokens`,'usage'),node('div',waiting.length?`${waiting.length}건 응답 대기`:session.state==='idle'?'새 요청을 받을 수 있음':'대기 중인 axMap 요청 없음','activity'));
|
|
112
|
+
sessions.append(line);
|
|
91
113
|
}
|
|
92
114
|
const active=data.requests.filter(r=>pending.has(r.state));
|
|
93
115
|
const selected=[...active,...data.requests.filter(r=>!pending.has(r.state))].slice(0,4);
|
|
@@ -104,6 +126,10 @@ footer { border-top:1px solid var(--line); padding-top:10px; margin-top:14px; co
|
|
|
104
126
|
step.append(mark,node('span',current&&index===2?'응답 대기':label));steps.append(step);
|
|
105
127
|
});
|
|
106
128
|
item.append(steps,node('div',job.task,'task'));if(job.error)item.append(node('div',job.error,'issue'));jobs.append(item);
|
|
129
|
+
const measured=[];
|
|
130
|
+
if(Number.isFinite(job.latencyMs))measured.push(`왕복 ${(job.latencyMs/1000).toFixed(2)}초`);
|
|
131
|
+
measured.push(job.usage?.totalTokens==null?'요청 토큰 미측정':`요청 처리 ${job.usage.totalTokens.toLocaleString()} tokens`);
|
|
132
|
+
item.append(node('div',measured.join(' · '),'task'));
|
|
107
133
|
}
|
|
108
134
|
for(const event of (data.events||[]).slice(0,5)){
|
|
109
135
|
const row=node('li');const date=new Date(event.at);row.append(node('time',Number.isFinite(date.getTime())?date.toLocaleTimeString('en-GB',{hour12:false}):'시각 미확인'),node('span',event.label));events.append(row);
|
|
@@ -117,7 +143,7 @@ footer { border-top:1px solid var(--line); padding-top:10px; margin-top:14px; co
|
|
|
117
143
|
async function poll(){
|
|
118
144
|
if(inflight||!ready||paused||stopped||document.hidden)return;
|
|
119
145
|
inflight=true;
|
|
120
|
-
try{const scope=cwd||requestedCwd,args=scope?{cwd:scope}:{};const result=mode==='openai'?await window.openai.callTool('ax_pipeline_state',args):await request('tools/call',{name:'ax_pipeline_state',arguments:args});consume(result);}
|
|
146
|
+
try{const scope=cwd||requestedCwd,args=scope?{cwd:scope}:{};const result=mode==='local'?await window.axmapLocalRead():mode==='openai'?await window.openai.callTool('ax_pipeline_state',args):await request('tools/call',{name:'ax_pipeline_state',arguments:args});consume(result);}
|
|
121
147
|
catch(error){setLive(false,'연결 확인 필요');issue.textContent=text(error?.message||error);}
|
|
122
148
|
finally{inflight=false;schedule();}
|
|
123
149
|
}
|
|
@@ -140,6 +166,7 @@ footer { border-top:1px solid var(--line); padding-top:10px; margin-top:14px; co
|
|
|
140
166
|
window.addEventListener('pagehide',()=>{stopped=true;clearTimeout(timer);resize.disconnect()});
|
|
141
167
|
if(window.openai?.toolOutput)render(window.openai.toolOutput);
|
|
142
168
|
(async()=>{
|
|
169
|
+
if(document.documentElement.dataset.axmapLocal==='true'){mode='local';ready=true;void poll();return;}
|
|
143
170
|
try{
|
|
144
171
|
const result=await request('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'axmap-pipeline',version:'1.0.0'},appCapabilities:{availableDisplayModes:['inline']}});
|
|
145
172
|
context(result?.hostContext);notify('ui/notifications/initialized',{});
|
package/mcp/server.mjs
CHANGED
|
@@ -212,7 +212,7 @@ const TTL = process.env.AXMAP_TTL ?? '45m'
|
|
|
212
212
|
*/
|
|
213
213
|
const SESSION = process.env.AXMAP_SESSION || process.env.CLAUDE_CODE_SESSION_ID || `mcp-${process.pid}`
|
|
214
214
|
|
|
215
|
-
const BRIDGE_TOOLS = new Set(['ax_sessions', 'ax_dispatch', 'ax_result', 'ax_chat', 'ax_dialogue', 'ax_chat_stop'])
|
|
215
|
+
const BRIDGE_TOOLS = new Set(['ax_sessions', 'ax_dispatch', 'ax_result', 'ax_chat', 'ax_dialogue', 'ax_chat_stop', 'ax_message', 'ax_mailbox', 'ax_mailbox_flush'])
|
|
216
216
|
const PIPELINE_TOOLS = new Set(['ax_pipeline', 'ax_pipeline_state', 'ax_pipeline_watch'])
|
|
217
217
|
const PIPELINE_URI = 'ui://axmap/pipeline/v1.html'
|
|
218
218
|
const PIPELINE_MIME = 'text/html;profile=mcp-app'
|
|
@@ -464,9 +464,36 @@ const TOOLS = [
|
|
|
464
464
|
description: '현재 repo에 연결된 로컬 AI 세션, 역할, 상태와 측정 토큰 사용량을 확인한다. stdio는 사용자가 bridge serve로 시작한 관리형 세션이다. 등록만으로 연결 가능성이나 실행 성공을 보장하지 않는다.',
|
|
465
465
|
inputSchema: { type: 'object', properties: {} },
|
|
466
466
|
},
|
|
467
|
+
{
|
|
468
|
+
name: 'ax_message',
|
|
469
|
+
description: '에이전트 협업 메시지. 일반 진행·결과·계약 확인은 기본 boundary 큐에 저장하며 새 턴을 만들지 않는다. 긴급 중단·데이터 손상·현재 작업 무효화만 immediate와 reason을 명시한다. 긴급도 작업 중인 세션을 강제 중단하지 않으며 deferred/unknown을 전달 성공으로 취급하지 않는다. 단순 알림 ACK 금지.',
|
|
470
|
+
inputSchema: { type: 'object', properties: {
|
|
471
|
+
to: { type: 'string' }, text: { type: 'string' },
|
|
472
|
+
priority: { type: 'string', enum: ['boundary', 'immediate'], default: 'boundary' },
|
|
473
|
+
reason: { type: 'string', enum: ['stop', 'data-loss', 'work-invalidated'] },
|
|
474
|
+
topic: { type: 'string', description: '명시적으로 같은 주제인 대기 업데이트만 최신으로 대체. 발신자별 분리.' },
|
|
475
|
+
key: { type: 'string', description: '동일 메시지 재접수 방지. 같은 키에 다른 내용은 거절.' },
|
|
476
|
+
ttl_seconds: { type: 'integer', minimum: 1, maximum: 86400, default: 3600 },
|
|
477
|
+
}, required: ['to', 'text'] },
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
name: 'ax_mailbox',
|
|
481
|
+
description: '작업 시작·테스트 완료·실험 종료 등 안전한 경계에서 받은 협업 메시지를 한 번에 읽는다. 기본 조회는 읽음 처리하지 않는다. take=true와 at을 명시하면 묶음 회수하며 새 모델 턴은 없다. collected는 실행 완료나 사람 승인이 아니다. 단순 결과 알림에 회신하지 말고 필요한 결정만 모아 처리한다.',
|
|
482
|
+
inputSchema: { type: 'object', properties: {
|
|
483
|
+
to: { type: 'string' }, take: { type: 'boolean', default: false }, all: { type: 'boolean', default: false },
|
|
484
|
+
at: { type: 'string', enum: ['task-complete', 'test-complete', 'experiment-complete', 'checkpoint'] },
|
|
485
|
+
}, required: ['to'] },
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'ax_mailbox_flush',
|
|
489
|
+
description: '명시한 작업 경계에서 대기 메시지를 idle 상대에게 한 요청으로 전달한다. 수신자가 직접 ax_mailbox로 회수하는 방법을 우선한다. 빈 큐는 모델 호출 없음. 전달불명·프로세스 중단 후 자동 재전송하지 않는다.',
|
|
490
|
+
inputSchema: { type: 'object', properties: { to: { type: 'string' },
|
|
491
|
+
at: { type: 'string', enum: ['task-complete', 'test-complete', 'experiment-complete', 'checkpoint'] },
|
|
492
|
+
}, required: ['to', 'at'] },
|
|
493
|
+
},
|
|
467
494
|
{
|
|
468
495
|
name: 'ax_dispatch',
|
|
469
|
-
description: '
|
|
496
|
+
description: '명시적으로 상대의 실행 턴이 필요한 작업용 저수준 전달이다. 일반 진행·완료·계약 확인 알림에는 새 턴 없는 ax_message를 사용하고 수신자가 작업 경계에서 ax_mailbox로 묶음 회수한다. 기존 맥락을 유지하며 새 모델 프로세스를 만들지 않는다. submitted는 완료가 아니다. unknown이면 자동 재시도하지 말고 ax_result로 확인한다. 작업 중인 세션을 강제 중단하지 않는다.',
|
|
470
497
|
inputSchema: { type: 'object', properties: {
|
|
471
498
|
to: { type: 'string', description: 'ax_sessions에서 확인한 상대 세션 UUID' },
|
|
472
499
|
prompt: { type: 'string', description: '상대가 실행할 작업 요청' },
|
|
@@ -924,6 +951,10 @@ function dispatch(name, args = {}) {
|
|
|
924
951
|
|
|
925
952
|
switch (name) {
|
|
926
953
|
case 'ax_sessions': return bridge(['sessions'])
|
|
954
|
+
case 'ax_message': return bridge(['message', '--to', args.to, '--priority', args.priority ?? 'boundary',
|
|
955
|
+
...[['reason', args.reason], ['topic', args.topic], ['key', args.key], ['ttl-seconds', args.ttl_seconds]].flatMap(([k, v]) => v === undefined ? [] : [`--${k}`, String(v)])], args.text)
|
|
956
|
+
case 'ax_mailbox': return bridge(['mailbox', '--to', args.to, ...(args.take ? ['--take'] : []), ...(args.all ? ['--all'] : []), ...(args.at ? ['--at', args.at] : [])])
|
|
957
|
+
case 'ax_mailbox_flush': return bridge(['flush', '--to', args.to, '--at', args.at])
|
|
927
958
|
case 'ax_dispatch': return bridge(['send', '--to', args.to, '--wait-ms', String(args.wait_ms ?? 0)], args.prompt)
|
|
928
959
|
case 'ax_result': return bridge(['result', args.id, '--wait-ms', String(args.wait_ms ?? 0)])
|
|
929
960
|
case 'ax_chat': return bridge(['chat', '--background', '--left', args.left, '--right', args.right,
|
|
@@ -1122,7 +1153,7 @@ async function handle(req) {
|
|
|
1122
1153
|
protocolVersion: '2024-11-05',
|
|
1123
1154
|
capabilities: { tools: {}, resources: {} },
|
|
1124
1155
|
serverInfo: { name: 'axmap', version: VERSION },
|
|
1125
|
-
instructions: 'axMap works across ChatGPT, Claude and agy. Open ax_pipeline once for an inline panel when the host supports MCP Apps. Otherwise ax_pipeline_watch and ax_dispatch/ax_result with wait_ms report standard MCP progress when the client supplies progressToken.
|
|
1156
|
+
instructions: 'axMap works across ChatGPT, Claude and agy. Routine collaboration updates go through ax_message (boundary queue, no new turn). At safe task/test/experiment checkpoints collect your ax_mailbox with take=true and at; batch decisions, do not ACK informational updates. Use immediate only for stop/data-loss/work-invalidated; busy delivery is deferred, not an interrupt. ax_dispatch starts an explicit execution request, not a routine notification. Open ax_pipeline once for an inline panel when the host supports MCP Apps. Otherwise ax_pipeline_watch and ax_dispatch/ax_result with wait_ms report standard MCP progress when the client supplies progressToken. Observations never call a model. Do not resubmit unknown requests.',
|
|
1126
1157
|
}
|
|
1127
1158
|
}
|
|
1128
1159
|
if (method === 'tools/list') return { tools: TOOLS.filter(tool => clientHasApps || tool.name !== 'ax_pipeline_state').map(tool => {
|
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.7.0",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"src/",
|
|
33
33
|
"mcp/",
|
|
34
34
|
"tools/",
|
|
35
|
+
"skills/",
|
|
35
36
|
"governance/",
|
|
36
37
|
".claude/",
|
|
37
38
|
"CLAUDE.md"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pipeline
|
|
3
|
+
description: Show the current repository's axMap local session graph, request and reply routing, observed usage and events. Use for /pipeline or axMap pipeline monitoring requests.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Use the current Git repository as scope. When the workspace root is outside Git, use its explicitly configured primary repository; never choose another repository silently.
|
|
7
|
+
|
|
8
|
+
Call `ax_pipeline` if available to show its MCP Apps session graph. This is read-only and does not invoke models. If the host lacks the tool/UI, use `axmap pipeline --cwd <absolute-repository>` in an interactive terminal, or `axmap pipeline --web --cwd <absolute-repository>` for the read-only loopback graphical view. For a snapshot use `--once`, and identify it as a snapshot rather than a live view.
|
|
9
|
+
|
|
10
|
+
The display shows registered bridge sessions and observed request IDs, roles, actual usage and events. An unregistered native Codex thread is not automatically visible. Missing usage is unknown, not zero; no invented task percentages. Opening the view never starts or resumes a model session or approves a request.
|
|
11
|
+
|
|
12
|
+
For an existing, user-selected Codex conversation, `axmap bridge connect codex --thread <existing-thread-id> --socket <official-local-control-socket> --role <role>` attaches through the already-running daemon. It preserves the original conversation and requires verified idle state. The connection process must stay running. Never replace failed attachment with a new blank session.
|
|
13
|
+
|
|
14
|
+
Codex-enabled skills may appear in the slash menu or be invoked as `$pipeline`; exact `/pipeline` menu availability depends on the host. Do not claim the client reserves a custom bare command when it does not.
|
package/src/bridge.mjs
CHANGED
|
@@ -100,6 +100,7 @@ function transcript(session) {
|
|
|
100
100
|
}
|
|
101
101
|
function publicSession(s) {
|
|
102
102
|
return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
|
|
103
|
+
conversationId: s.conversationId,
|
|
103
104
|
transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
|
|
104
105
|
registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
|
|
105
106
|
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval };
|
package/src/bridge.mts
CHANGED
|
@@ -81,6 +81,7 @@ function transcript(session: SessionRecord): TranscriptEntry[] {
|
|
|
81
81
|
}
|
|
82
82
|
function publicSession(s: SessionRecord): PublicSession {
|
|
83
83
|
return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
|
|
84
|
+
conversationId: s.conversationId,
|
|
84
85
|
transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
|
|
85
86
|
registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
|
|
86
87
|
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval }
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Generated from src/codex-connection.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Attach to an existing Codex daemon through its official byte proxy.
|
|
3
|
+
* The proxy carries WebSocket frames, not JSONL. No daemon/model is started. */
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { PassThrough, Writable } from 'node:stream';
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const MAX = 4 * 1024 * 1024;
|
|
9
|
+
export function websocketFrame(payload, opcode = 1) {
|
|
10
|
+
if (payload.length > MAX)
|
|
11
|
+
throw new Error('Codex message exceeds 4MiB');
|
|
12
|
+
const mask = randomBytes(4), header = Buffer.alloc(payload.length < 126 ? 2 : payload.length < 65536 ? 4 : 10);
|
|
13
|
+
header[0] = 0x80 | opcode;
|
|
14
|
+
if (payload.length < 126)
|
|
15
|
+
header[1] = 0x80 | payload.length;
|
|
16
|
+
else if (payload.length < 65536) {
|
|
17
|
+
header[1] = 254;
|
|
18
|
+
header.writeUInt16BE(payload.length, 2);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
header[1] = 255;
|
|
22
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
23
|
+
}
|
|
24
|
+
const body = Buffer.from(payload);
|
|
25
|
+
for (let i = 0; i < body.length; i++)
|
|
26
|
+
body[i] = body[i] ^ mask[i % 4];
|
|
27
|
+
return Buffer.concat([header, mask, body]);
|
|
28
|
+
}
|
|
29
|
+
export function connectCodexProxy({ executable, prefix = [], socketPath, cwd, timeoutMs = 10000 }) {
|
|
30
|
+
if (!path.isAbsolute(socketPath) || /[\r\n\0]/.test(socketPath))
|
|
31
|
+
throw new Error('An absolute local Codex socket path is required');
|
|
32
|
+
const process = spawn(executable, [...prefix, 'app-server', 'proxy', '--sock', socketPath], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
33
|
+
const output = new PassThrough(), key = randomBytes(16).toString('base64');
|
|
34
|
+
const accept = createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
35
|
+
let buffer = Buffer.alloc(0), upgraded = false, closed = false, fragment = [], size = 0, input = '';
|
|
36
|
+
const queued = [];
|
|
37
|
+
const fail = (message) => { if (closed)
|
|
38
|
+
return; closed = true; clearTimeout(timer); output.destroy(new Error(message)); process.kill(); };
|
|
39
|
+
const timer = setTimeout(() => fail('Codex local socket handshake timed out'), timeoutMs);
|
|
40
|
+
const send = (text) => process.stdin.write(websocketFrame(Buffer.from(text)));
|
|
41
|
+
const stdin = new Writable({ write(chunk, _encoding, callback) {
|
|
42
|
+
input += chunk.toString();
|
|
43
|
+
if (Buffer.byteLength(input) > MAX) {
|
|
44
|
+
callback(new Error('Codex input exceeds 4MiB'));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
while (input.includes('\n')) {
|
|
48
|
+
const at = input.indexOf('\n'), line = input.slice(0, at);
|
|
49
|
+
input = input.slice(at + 1);
|
|
50
|
+
if (!line.trim())
|
|
51
|
+
continue;
|
|
52
|
+
if (upgraded)
|
|
53
|
+
send(line);
|
|
54
|
+
else
|
|
55
|
+
queued.push(line);
|
|
56
|
+
}
|
|
57
|
+
callback();
|
|
58
|
+
} });
|
|
59
|
+
process.stdin.on('error', e => fail(e.message));
|
|
60
|
+
process.stdout.on('data', chunk => {
|
|
61
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
62
|
+
if (buffer.length > MAX + 16384) {
|
|
63
|
+
fail('Codex frame buffer exceeds limit');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!upgraded) {
|
|
67
|
+
const end = buffer.indexOf('\r\n\r\n');
|
|
68
|
+
if (end < 0) {
|
|
69
|
+
if (buffer.length > 16384)
|
|
70
|
+
fail('Invalid Codex upgrade response');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const lines = buffer.subarray(0, end).toString('ascii').split('\r\n');
|
|
74
|
+
const headers = new Map(lines.slice(1).map(line => { const at = line.indexOf(':'); return [line.slice(0, at).toLowerCase(), line.slice(at + 1).trim()]; }));
|
|
75
|
+
if (!/^HTTP\/1\.1 101\b/.test(lines[0] || '') || headers.get('sec-websocket-accept') !== accept || headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
76
|
+
fail('Codex socket did not confirm the WebSocket handshake');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
buffer = buffer.subarray(end + 4);
|
|
80
|
+
upgraded = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
for (const text of queued.splice(0))
|
|
83
|
+
send(text);
|
|
84
|
+
}
|
|
85
|
+
while (buffer.length >= 2 && !closed) {
|
|
86
|
+
const first = buffer[0], second = buffer[1], opcode = first & 15, fin = !!(first & 128);
|
|
87
|
+
let count = second & 127, offset = 2;
|
|
88
|
+
if (first & 112 || second & 128) {
|
|
89
|
+
fail('Invalid Codex WebSocket flags');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (count === 126) {
|
|
93
|
+
if (buffer.length < 4)
|
|
94
|
+
return;
|
|
95
|
+
count = buffer.readUInt16BE(2);
|
|
96
|
+
offset = 4;
|
|
97
|
+
}
|
|
98
|
+
if (count === 127) {
|
|
99
|
+
if (buffer.length < 10)
|
|
100
|
+
return;
|
|
101
|
+
const big = buffer.readBigUInt64BE(2);
|
|
102
|
+
if (big > BigInt(MAX)) {
|
|
103
|
+
fail('Codex frame exceeds limit');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
;
|
|
107
|
+
count = Number(big);
|
|
108
|
+
offset = 10;
|
|
109
|
+
}
|
|
110
|
+
if (count > MAX || (opcode >= 8 && (!fin || count > 125))) {
|
|
111
|
+
fail('Invalid Codex control frame');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (buffer.length < offset + count)
|
|
115
|
+
return;
|
|
116
|
+
const body = Buffer.from(buffer.subarray(offset, offset + count));
|
|
117
|
+
buffer = buffer.subarray(offset + count);
|
|
118
|
+
if (opcode === 8) {
|
|
119
|
+
closed = true;
|
|
120
|
+
output.end();
|
|
121
|
+
process.kill();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (opcode === 9) {
|
|
125
|
+
process.stdin.write(websocketFrame(body, 10));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (opcode === 10)
|
|
129
|
+
continue;
|
|
130
|
+
if ((opcode !== 1 && opcode !== 0) || (opcode === 0 && !fragment.length) || (opcode === 1 && fragment.length)) {
|
|
131
|
+
fail('Invalid Codex text sequence');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
fragment.push(body);
|
|
135
|
+
size += body.length;
|
|
136
|
+
if (size > MAX) {
|
|
137
|
+
fail('Codex fragmented message exceeds limit');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (fin) {
|
|
141
|
+
output.write(Buffer.concat(fragment).toString('utf8') + '\n');
|
|
142
|
+
fragment = [];
|
|
143
|
+
size = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
process.once('exit', () => { clearTimeout(timer); output.end(); });
|
|
148
|
+
process.stdin.write(`GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
|
|
149
|
+
return { pid: process.pid, stdin, stdout: output, stderr: process.stderr,
|
|
150
|
+
once: process.once.bind(process), kill: process.kill.bind(process) };
|
|
151
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Attach to an existing Codex daemon through its official byte proxy.
|
|
2
|
+
* The proxy carries WebSocket frames, not JSONL. No daemon/model is started. */
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { PassThrough, Writable } from 'node:stream'
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
export type ProviderProcess = Pick<ChildProcessWithoutNullStreams, 'pid' | 'once' | 'kill'> & {
|
|
10
|
+
stdin: Writable; stdout: PassThrough | ChildProcessWithoutNullStreams['stdout']; stderr: ChildProcessWithoutNullStreams['stderr']
|
|
11
|
+
}
|
|
12
|
+
const MAX = 4 * 1024 * 1024
|
|
13
|
+
export function websocketFrame(payload: Buffer, opcode = 1): Buffer {
|
|
14
|
+
if (payload.length > MAX) throw new Error('Codex message exceeds 4MiB')
|
|
15
|
+
const mask = randomBytes(4), header = Buffer.alloc(payload.length < 126 ? 2 : payload.length < 65536 ? 4 : 10)
|
|
16
|
+
header[0] = 0x80 | opcode
|
|
17
|
+
if (payload.length < 126) header[1] = 0x80 | payload.length
|
|
18
|
+
else if (payload.length < 65536) { header[1] = 254; header.writeUInt16BE(payload.length, 2) }
|
|
19
|
+
else { header[1] = 255; header.writeBigUInt64BE(BigInt(payload.length), 2) }
|
|
20
|
+
const body = Buffer.from(payload)
|
|
21
|
+
for (let i = 0; i < body.length; i++) body[i] = body[i]! ^ mask[i % 4]!
|
|
22
|
+
return Buffer.concat([header, mask, body])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function connectCodexProxy({ executable, prefix = [], socketPath, cwd, timeoutMs = 10000 }:
|
|
26
|
+
{ executable: string; prefix?: string[]; socketPath: string; cwd: string; timeoutMs?: number }): ProviderProcess {
|
|
27
|
+
if (!path.isAbsolute(socketPath) || /[\r\n\0]/.test(socketPath)) throw new Error('An absolute local Codex socket path is required')
|
|
28
|
+
const process = spawn(executable, [...prefix, 'app-server', 'proxy', '--sock', socketPath], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
29
|
+
const output = new PassThrough(), key = randomBytes(16).toString('base64')
|
|
30
|
+
const accept = createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64')
|
|
31
|
+
let buffer: Buffer = Buffer.alloc(0), upgraded = false, closed = false, fragment: Buffer[] = [], size = 0, input = ''
|
|
32
|
+
const queued: string[] = []
|
|
33
|
+
const fail = (message: string) => { if (closed) return; closed = true; clearTimeout(timer); output.destroy(new Error(message)); process.kill() }
|
|
34
|
+
const timer = setTimeout(() => fail('Codex local socket handshake timed out'), timeoutMs)
|
|
35
|
+
const send = (text: string) => process.stdin.write(websocketFrame(Buffer.from(text)))
|
|
36
|
+
const stdin = new Writable({ write(chunk, _encoding, callback) {
|
|
37
|
+
input += chunk.toString()
|
|
38
|
+
if (Buffer.byteLength(input) > MAX) { callback(new Error('Codex input exceeds 4MiB')); return }
|
|
39
|
+
while (input.includes('\n')) {
|
|
40
|
+
const at = input.indexOf('\n'), line = input.slice(0, at); input = input.slice(at + 1)
|
|
41
|
+
if (!line.trim()) continue
|
|
42
|
+
if (upgraded) send(line); else queued.push(line)
|
|
43
|
+
}
|
|
44
|
+
callback()
|
|
45
|
+
} })
|
|
46
|
+
process.stdin.on('error', e => fail(e.message))
|
|
47
|
+
process.stdout.on('data', chunk => {
|
|
48
|
+
buffer = Buffer.concat([buffer, chunk])
|
|
49
|
+
if (buffer.length > MAX + 16384) { fail('Codex frame buffer exceeds limit'); return }
|
|
50
|
+
if (!upgraded) {
|
|
51
|
+
const end = buffer.indexOf('\r\n\r\n')
|
|
52
|
+
if (end < 0) { if (buffer.length > 16384) fail('Invalid Codex upgrade response'); return }
|
|
53
|
+
const lines = buffer.subarray(0, end).toString('ascii').split('\r\n')
|
|
54
|
+
const headers = new Map(lines.slice(1).map(line => { const at = line.indexOf(':'); return [line.slice(0, at).toLowerCase(), line.slice(at + 1).trim()] }))
|
|
55
|
+
if (!/^HTTP\/1\.1 101\b/.test(lines[0] || '') || headers.get('sec-websocket-accept') !== accept || headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
56
|
+
fail('Codex socket did not confirm the WebSocket handshake'); return
|
|
57
|
+
}
|
|
58
|
+
buffer = buffer.subarray(end + 4); upgraded = true; clearTimeout(timer)
|
|
59
|
+
for (const text of queued.splice(0)) send(text)
|
|
60
|
+
}
|
|
61
|
+
while (buffer.length >= 2 && !closed) {
|
|
62
|
+
const first = buffer[0]!, second = buffer[1]!, opcode = first & 15, fin = !!(first & 128)
|
|
63
|
+
let count = second & 127, offset = 2
|
|
64
|
+
if (first & 112 || second & 128) { fail('Invalid Codex WebSocket flags'); return }
|
|
65
|
+
if (count === 126) { if (buffer.length < 4) return; count = buffer.readUInt16BE(2); offset = 4 }
|
|
66
|
+
if (count === 127) { if (buffer.length < 10) return; const big = buffer.readBigUInt64BE(2); if (big > BigInt(MAX)) { fail('Codex frame exceeds limit'); return }; count = Number(big); offset = 10 }
|
|
67
|
+
if (count > MAX || (opcode >= 8 && (!fin || count > 125))) { fail('Invalid Codex control frame'); return }
|
|
68
|
+
if (buffer.length < offset + count) return
|
|
69
|
+
const body = Buffer.from(buffer.subarray(offset, offset + count)); buffer = buffer.subarray(offset + count)
|
|
70
|
+
if (opcode === 8) { closed = true; output.end(); process.kill(); return }
|
|
71
|
+
if (opcode === 9) { process.stdin.write(websocketFrame(body, 10)); continue }
|
|
72
|
+
if (opcode === 10) continue
|
|
73
|
+
if ((opcode !== 1 && opcode !== 0) || (opcode === 0 && !fragment.length) || (opcode === 1 && fragment.length)) { fail('Invalid Codex text sequence'); return }
|
|
74
|
+
fragment.push(body); size += body.length
|
|
75
|
+
if (size > MAX) { fail('Codex fragmented message exceeds limit'); return }
|
|
76
|
+
if (fin) { output.write(Buffer.concat(fragment).toString('utf8') + '\n'); fragment = []; size = 0 }
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
process.once('exit', () => { clearTimeout(timer); output.end() })
|
|
80
|
+
process.stdin.write(`GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`)
|
|
81
|
+
return { pid: process.pid, stdin, stdout: output, stderr: process.stderr,
|
|
82
|
+
once: process.once.bind(process), kill: process.kill.bind(process) }
|
|
83
|
+
}
|