axmap-cli 1.6.0 → 1.8.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.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: axMap 세션·선점·받은 편지함을 터미널 카드로 보여 줍니다
3
+ ---
4
+
5
+ 현재 Git 저장소를 대상으로 별도의 대화형 터미널에서 `axmap view --cwd <현재 저장소 절대경로>`를 실행하도록 안내한다. 이 명령은 터미널에서 직접 갱신되며 `q`로 닫는다. 도구가 대화형 터미널을 제공하지 않는다면 `axmap view --cwd <현재 저장소 절대경로> --once`의 결과를 스냅샷으로 보여 주고 실시간 화면이라고 주장하지 않는다. 저장소가 모호하면 다른 저장소를 임의로 선택하지 않는다.
6
+
7
+ 관측은 작업을 새로 시작하거나 메시지를 회수·전송하지 않는다. 실제 등록·관측된 상태만 보여 주며 미확인 값을 추정하지 않는다.
8
+
9
+ 개발 중인 axMap 소스 체크아웃에서는 설치본이 구버전일 수 있다. `axmap view --help`를 확인하고 해당 명령이 없으면 소스 체크아웃의 `node bin/axmap.mjs view --cwd <현재 저장소 절대경로>`를 사용한다.
@@ -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
@@ -23,12 +23,31 @@ axmap setup
23
23
 
24
24
  지원되는 MCP Apps 호스트에서는 대화 안에서 파이프라인을 확인하고, Claude Code·agy에서는 기존 상태줄에 진행을 추가할 수 있습니다. 호스트별 지원이 다르며 ChatGPT·Claude 앱의 실제 임베디드 화면은 아직 검증하지 않았습니다. [클라이언트 설정과 제한](docs/PIPELINE-CLIENTS.md). 선택적으로 별도 터미널의 `axmap monitor`도 제공합니다([사용법](docs/MONITOR.md)).
25
25
 
26
+ 터미널에서 `axmap view --cwd <저장소>`를 실행하면 관측된 로컬 세션·선점·대기 메시지·최근 이벤트를 카드로 볼 수 있습니다. 화면은 자동 갱신되며 `q` 또는 Esc로 닫습니다. `--once`는 한 번만 출력하고 `--json`은 구조화된 결과를 제공합니다. 등록되지 않은 모든 에이전트나 GitLab 활동까지 표시하는 것은 아닙니다. [화면 사용법](docs/VIEW.md).
27
+
26
28
  ---
27
29
 
28
30
  ## 변경 기록
29
31
 
30
32
  최신 변경부터 정리합니다.
31
33
 
34
+ ### 1.7.0
35
+
36
+ <sub>2026.10.27</sub>
37
+
38
+ **일반 협업 알림을 작업 경계에서 모아 처리하고 기존 로컬 Codex 대화를 연결합니다.**
39
+
40
+ - 일반 알림은 `ax_message` / `axmap bridge message`로 새 모델 턴 없이 저장합니다. 수신자는 작업·테스트·실험의 안전한 경계에서 `ax_mailbox`로 묶음 회수합니다.
41
+ - 중단 요청·데이터 손상 위험·현재 작업 무효화를 명시적 긴급 사유로 구분합니다. 수신자가 작업 중이면 전달을 보류하며 실행 중 작업을 강제 중단하지 않습니다.
42
+ - 발신자·시각·이력을 보존하고 만료, 멱등 키, 같은 발신자·주제의 대기 업데이트 최신화를 지원합니다. 전달불명 메시지는 자동 재전송하지 않습니다.
43
+ - 로컬 제어 소켓으로 기존 Codex 대화를 연결합니다. 작업 중에는 관측만 허용하고 새 요청 전 대기 상태를 확인합니다.
44
+ - `axmap pipeline` 그래프와 읽기 전용 loopback 웹 화면, 새 모델 턴을 시작하지 않는 Codex 문맥 안내를 추가합니다.
45
+
46
+ 받은 편지함은 격리 provider와 CLI/MCP 계약으로 검증했습니다. 실제 턴 절감량은 미측정이며 실행 중 강제 중단은 지원하지 않습니다. 수신 에이전트가 작업 경계에서 받은 편지함을 회수해야 합니다. 새 도구를 발견하려면 MCP를 다시 연결하세요. 실행용 기존 `ax_dispatch`는 유지합니다.
47
+ [받은 편지함 안내 →](docs/SESSION-MAILBOX.md) · [로컬 파이프라인 →](docs/LOCAL-PIPELINE.md)
48
+
49
+ ---
50
+
32
51
  ### 1.6.0
33
52
 
34
53
  <sub>2026.10.26</sub>
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # axMap
2
2
 
3
+ Run `axmap view --cwd <repository>` to see observed local sessions, claims, pending mailbox messages and recent events in a terminal dashboard. The view refreshes interactively; press `q` or Esc to exit. Use `--once` for a snapshot or `--json` for structured output. See [the view guide](docs/VIEW.md). The display reflects available observations, not a complete view of every agent or GitLab activity.
4
+
3
5
  **Several AI agents. One repository. Clear ownership before every edit.**
4
6
 
5
7
  axMap is a collaboration CLI that claims files and folders before work begins and rejects overlapping claims.
@@ -29,6 +31,23 @@ View pipeline state inside compatible MCP Apps hosts, or add it to the existing
29
31
 
30
32
  Newest changes first.
31
33
 
34
+ ### 1.7.0
35
+
36
+ <sub>October 27, 2026</sub>
37
+
38
+ **Batch routine collaboration messages at safe work boundaries and connect existing local Codex threads.**
39
+
40
+ - 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.
41
+ - 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.
42
+ - Preserve message provenance and history while supporting expiration, idempotency keys and latest pending updates per sender/topic. Uncertain delivery is never automatically retried.
43
+ - Connect an existing Codex thread through its local control socket, with read-only observation of active work and idle checks before submission.
44
+ - Add the `axmap pipeline` graph, a read-only loopback web view and context-only Codex notices that do not start a new model turn.
45
+
46
+ 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.
47
+ [Mailbox guide →](docs/SESSION-MAILBOX.md) · [Local pipeline →](docs/LOCAL-PIPELINE.md)
48
+
49
+ ---
50
+
32
51
  ### 1.6.0
33
52
 
34
53
  <sub>October 26, 2026</sub>
package/bin/axmap.mjs CHANGED
@@ -2625,6 +2625,7 @@ const RUNNERS = {
2625
2625
  run: ['tools/run.mjs', '사용자가 여는 AI CLI를 연결한다'],
2626
2626
  bridge: ['tools/bridge.mjs', '열린 로컬 AI 세션에 작업을 전달한다'],
2627
2627
  monitor: ['tools/monitor.mjs', '세션과 요청 파이프라인을 실시간으로 표시한다'],
2628
+ view: ['tools/view.mjs', '세션·선점·받은 편지함을 터미널 카드로 표시한다'],
2628
2629
  pipeline: ['tools/pipeline.mjs', '대화 안 파이프라인 표시와 CLI 상태줄을 연결한다'],
2629
2630
  gate: ['governance/gate.mjs', '이 MR 이 정족수를 채웠는가'],
2630
2631
  vote: ['governance/vote.mjs', '표를 던진다'],
@@ -2837,6 +2838,7 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2837
2838
  axmap bus 에이전트 사이 쪽지함 (post · list · read · reply)
2838
2839
  axmap bridge 로컬 세션·자동 대화 (serve · sessions · send · chat · dialogue)
2839
2840
  axmap monitor 에이전트·요청 파이프라인·최근 이벤트 실시간 화면
2841
+ axmap view 세션·선점·받은 편지함을 카드로 실시간 표시
2840
2842
  axmap pipeline 대화 안 표시·Claude/agy 상태줄 연결 (--help)
2841
2843
  axmap run agy [인수...] 연결된 Antigravity CLI를 직접 연다
2842
2844
  axmap mr-target 이 브랜치가 저 브랜치로 갈 수 있는가
@@ -2881,6 +2883,7 @@ switch (cmd) {
2881
2883
  case 'setup':
2882
2884
  case 'bridge':
2883
2885
  case 'monitor':
2886
+ case 'view':
2884
2887
  case 'pipeline':
2885
2888
  case 'run':
2886
2889
  case 'gate':
package/docs/VIEW.md ADDED
@@ -0,0 +1,19 @@
1
+ # 터미널에서 axMap 보기
2
+
3
+ 설치한 패키지에서 다음 명령으로 실행한다.
4
+
5
+ ```powershell
6
+ axmap view --cwd C:\work\my-repo
7
+ ```
8
+
9
+ 소스 체크아웃에서 확인할 때는 `node bin/axmap.mjs view --cwd <repo>`를 사용한다.
10
+
11
+ 연결된 세션을 우선해 카드로 보여 주고 현재 선점, 등록된 세션의 대기 중인 로컬 편지와 최근 관측 이벤트를 표시한다. 실시간 갱신 중 `q`, Esc 또는 Ctrl+C로 화면을 닫는다. 진행 중인 작업은 계속된다. 창 크기에 따라 카드가 한 줄 또는 여러 줄로 배치된다.
12
+
13
+ `--once`는 한 번만 출력하며, `--json`은 같은 관측 자료를 JSON으로 출력한다. `--interval 1000`은 수집 간격이고 `--no-color` 또는 `NO_COLOR`는 색을 끈다. 파이프나 리디렉션에서는 스냅샷만 출력한다. 터미널 글꼴은 사용자의 터미널 설정을 따른다.
14
+
15
+ 수신자별 편지함을 읽기만 한다. 읽음 처리, 만료 상태 저장, 메시지 전송, 모델 요청 및 승인 조작은 하지 않는다. 만료 시각이 지난 대기 메시지는 화면 집계에서 제외하고 파일은 수정하지 않는다. 편지함을 읽을 수 없으면 빈 편지함으로 단정하지 않고 오류를 보여 준다. 기존 모니터와 마찬가지로 요청의 관측 결과 확인 과정은 기존 요청 기록을 갱신할 수 있다. 출력에 메시지 주제 또는 내용이 포함될 수 있으니 공유할 때 범위를 확인한다.
16
+
17
+ 포트폴리오 그림은 설명용 애니메이션이며 이 명령은 실제 관측값을 보여 준다. 확인하지 못한 작업률·토큰·연결 상태를 임의로 만들지 않는다. 등록하지 않은 AI 대화는 표시되지 않는다.
18
+
19
+ Claude Code에서 프로젝트 명령을 설치한 환경은 `/axmap-view`로 안내를 부를 수 있다. Codex 등 다른 호스트는 `skills/axmap-view/SKILL.md`를 해당 호스트의 방법으로 설치해야 하며, 모든 터미널에서 `/axmap-view` 자체가 실행되는 것은 아니다. 확실한 실행 방법은 `axmap view`다.
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
- .sessions { display:flex; flex-wrap:wrap; gap:8px 18px; font-size:12px; }
23
- .session { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
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
- <div class="sessions" id="sessions"></div>
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
- for(const session of data.sessions.slice(0,6)){
90
- const line=node('div',undefined,'session');line.append(node('strong',`${session.provider} · ${text(session.id).slice(0,8)}`),node('span',`${session.pendingApproval?'사용자 승인 필요':sessionStates[session.state]||'상태 미확인'} · ${connectionStates[session.connection]||'확인 미지원'}`,'subtle'));sessions.append(line);
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: '사용자가 허용한 작업을 연결된 상대 세션에 전달한다. 기존 맥락을 유지하며 새 모델 프로세스를 만들지 않는다. submitted는 완료가 아니다. unknown이면 자동 재시도하지 말고 ax_result로 확인한다. Codex/agy 관리형 stdio, 별도로 실행한 Windows agy console, 실험용 PTY/agentapi 연결을 지원한다.',
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. These observations never call a model. Do not resubmit unknown requests.',
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.6.0",
4
+ "version": "1.8.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -32,7 +32,9 @@
32
32
  "src/",
33
33
  "mcp/",
34
34
  "tools/",
35
+ "skills/",
35
36
  "governance/",
37
+ "docs/VIEW.md",
36
38
  ".claude/",
37
39
  "CLAUDE.md"
38
40
  ],
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: axmap-view
3
+ description: Show the current Git repository's axMap sessions, active claims, pending mailbox and recent events in a terminal view.
4
+ ---
5
+
6
+ Use the current Git repository or the user's explicitly designated repository. Run `axmap view --cwd <absolute-repository>` in an interactive terminal; `q`, Esc or Ctrl+C closes the view. If the host cannot provide an interactive terminal, run `axmap view --cwd <absolute-repository> --once` and identify it as a snapshot. Slash-menu availability depends on the host and installed skill; do not imply that a bare slash command works in every terminal.
7
+
8
+ The display reads local evidence only. It does not start an AI session, submit work, send or collect mail, or imply that an unobserved session is idle. Never invent progress percentages or token counts.
@@ -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
+ }