@linzumi/cli 1.0.99 → 1.0.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,7 @@ Install the CLI or run it with `npx`:
58
58
  ```bash
59
59
  npm install -g @linzumi/cli@latest
60
60
  npx -y @linzumi/cli@latest signup
61
- npx -y @linzumi/cli@1.0.99 --version
61
+ npx -y @linzumi/cli@1.0.100 --version
62
62
  linzumi --version
63
63
  ```
64
64
 
package/dist/index.js CHANGED
@@ -1470,7 +1470,7 @@ To kick the agent off:
1470
1470
  `,L=Buffer.byteLength(P,"utf8");if(L>s)return l("runner_diagnostics.spool_entry_dropped",{reason:"entry_too_large",entry_bytes:L,max_entry_bytes:s}),!1;Xve(n,{recursive:!0}),m(u)+L>i&&f(),_(L);let D=w()?`
1471
1471
  `:"";return Gve(u,`${D}${P}`,"utf8"),!0}catch(R){return l("runner_diagnostics.spool_failed",{error:R instanceof Error?R.message:String(R)}),!1}},v=k=>{let R=[],P=eSe(k,"utf8");for(let L of P.split(`
1472
1472
  `))if(L.trim()!=="")try{let D=JSON.parse(L);D!==null&&typeof D=="object"&&R.push({parsed:D,raw:L})}catch{}return R};return{append:S,drain:async k=>{if(c)return{uploadedBatches:0,uploadedEntries:0,remainingBatches:p().length,skipped:!0};c=!0;let R=0,P=0;try{f(),_(0);let L=a()-o;for(let D of p()){let j;try{j=v(D)}catch(Z){l("runner_diagnostics.spool_batch_unreadable",{file:D,error:Z instanceof Error?Z.message:String(Z)}),y(D,"unreadable");continue}let q=j.filter(Z=>{let fe=ySe(Z.parsed);return fe===void 0||fe>=L});if(q.length===0){y(D,"empty_or_expired");continue}let E=XT(D,".jsonl"),B=q.map(Z=>Z.raw).join(`
1473
- `),z=q.map(Z=>Z.parsed.spooled_at).filter(Z=>typeof Z=="string"),W={bundle_name:"runner-diagnostics-spool",exported_at:new Date(a()).toISOString(),idempotency_key:E,diagnostics:{kind:"runner_diagnostic_log",spool_batch:!0,idempotency_key:E,entry_count:q.length,...z.length===0?{}:{first_spooled_at:z[0],last_spooled_at:z[z.length-1]}},telemetry_ndjson:B};try{await k(W)}catch(Z){l("runner_diagnostics.spool_upload_failed",{file:D,remaining_batches:p().length,error:Z instanceof Error?Z.message:String(Z)});break}R+=1,P+=q.length;try{s9(D)}catch(Z){l("runner_diagnostics.spool_delete_failed",{file:D,error:Z instanceof Error?Z.message:String(Z)})}}}catch(L){l("runner_diagnostics.spool_drain_failed",{error:L instanceof Error?L.message:String(L)})}finally{c=!1}return{uploadedBatches:R,uploadedEntries:P,remainingBatches:p().length,skipped:!1}},pendingBatchFiles:p}}var sSe,aSe,dSe,uSe,cSe,a9,pSe,fSe,u9=F(()=>{"use strict";Zy();sSe=32*1024*1024,aSe=10080*60*1e3,dSe=4*1024*1024,uSe=1024*1024,cSe="spool.jsonl",a9=/^batch-(\d{1,16})-[0-9a-f-]+\.jsonl$/;pSe=/\b([A-Z][A-Z0-9_]{1,})=("[^"\n]*"|'[^'\n]*'|[^\s"'`;,)\]}]+)/g,fSe=/(?:\/(?:[A-Za-z0-9._@~+-]|%[0-9A-Fa-f]{2})+){2,}/g});var jt,eP,ea=F(()=>{"use strict";jt="1.0.99",eP=`linzumi ${jt}`});import{closeSync as l9,existsSync as tP,mkdirSync as xSe,openSync as p9,readFileSync as f9,renameSync as _Se,unlinkSync as md,writeFileSync as bSe,writeSync as m9}from"node:fs";import{dirname as h9,join as wSe}from"node:path";function rP(e){return e instanceof hs||e instanceof Error&&e.name===y9&&"lockPath"in e&&"heldBy"in e}function oP(e,n=Vn(),t){let r=t===void 0?encodeURIComponent(e):ad(t);return wSe(h9(n),"runners",`${r}.lock`)}function s0(e){let n=oP(e.machineId,e.configPath,e.linzumiUrl),t=e.isPidAlive??k9,r=e.now??(()=>new Date),o=e.killPid??OSe,i=r().toISOString(),s={version:1,machineId:e.machineId,runnerId:e.runnerId,pid:e.pid??process.pid,cwd:e.cwd,workspace:e.workspace,...e.linzumiUrl===void 0?{}:{linzumiUrl:Fn(e.linzumiUrl)},...e.launchSource===void 0?{}:{launchSource:e.launchSource},startedAt:i,heartbeatAt:i,cliVersion:e.cliVersion??jt,...e.gracefulUpdateDrain===!0?{gracefulUpdateDrain:!0}:{}};CSe(e.machineId,e.configPath,e.linzumiUrl,t,r,o,e.onTakeover),TSe(n,s,t,r,o,e.onTakeover,e.beforeReadExistingLock,e.beforeReplaceStaleLock);let a=()=>x9(n,s,r),d=setInterval(a,g9);return d.unref(),{path:n,record:s,heartbeat:a,release:()=>{clearInterval(d),v9(n,s)}}}function x9(e,n,t){try{let r=d0(e);if(r===void 0||r.machineId!==n.machineId||r.runnerId!==n.runnerId||r.pid!==n.pid||r.startedAt!==n.startedAt)return;let o={...r,heartbeatAt:t().toISOString()},i=`${e}.tmp`;bSe(i,`${JSON.stringify(o,null,2)}
1473
+ `),z=q.map(Z=>Z.parsed.spooled_at).filter(Z=>typeof Z=="string"),W={bundle_name:"runner-diagnostics-spool",exported_at:new Date(a()).toISOString(),idempotency_key:E,diagnostics:{kind:"runner_diagnostic_log",spool_batch:!0,idempotency_key:E,entry_count:q.length,...z.length===0?{}:{first_spooled_at:z[0],last_spooled_at:z[z.length-1]}},telemetry_ndjson:B};try{await k(W)}catch(Z){l("runner_diagnostics.spool_upload_failed",{file:D,remaining_batches:p().length,error:Z instanceof Error?Z.message:String(Z)});break}R+=1,P+=q.length;try{s9(D)}catch(Z){l("runner_diagnostics.spool_delete_failed",{file:D,error:Z instanceof Error?Z.message:String(Z)})}}}catch(L){l("runner_diagnostics.spool_drain_failed",{error:L instanceof Error?L.message:String(L)})}finally{c=!1}return{uploadedBatches:R,uploadedEntries:P,remainingBatches:p().length,skipped:!1}},pendingBatchFiles:p}}var sSe,aSe,dSe,uSe,cSe,a9,pSe,fSe,u9=F(()=>{"use strict";Zy();sSe=32*1024*1024,aSe=10080*60*1e3,dSe=4*1024*1024,uSe=1024*1024,cSe="spool.jsonl",a9=/^batch-(\d{1,16})-[0-9a-f-]+\.jsonl$/;pSe=/\b([A-Z][A-Z0-9_]{1,})=("[^"\n]*"|'[^'\n]*'|[^\s"'`;,)\]}]+)/g,fSe=/(?:\/(?:[A-Za-z0-9._@~+-]|%[0-9A-Fa-f]{2})+){2,}/g});var jt,eP,ea=F(()=>{"use strict";jt="1.0.100",eP=`linzumi ${jt}`});import{closeSync as l9,existsSync as tP,mkdirSync as xSe,openSync as p9,readFileSync as f9,renameSync as _Se,unlinkSync as md,writeFileSync as bSe,writeSync as m9}from"node:fs";import{dirname as h9,join as wSe}from"node:path";function rP(e){return e instanceof hs||e instanceof Error&&e.name===y9&&"lockPath"in e&&"heldBy"in e}function oP(e,n=Vn(),t){let r=t===void 0?encodeURIComponent(e):ad(t);return wSe(h9(n),"runners",`${r}.lock`)}function s0(e){let n=oP(e.machineId,e.configPath,e.linzumiUrl),t=e.isPidAlive??k9,r=e.now??(()=>new Date),o=e.killPid??OSe,i=r().toISOString(),s={version:1,machineId:e.machineId,runnerId:e.runnerId,pid:e.pid??process.pid,cwd:e.cwd,workspace:e.workspace,...e.linzumiUrl===void 0?{}:{linzumiUrl:Fn(e.linzumiUrl)},...e.launchSource===void 0?{}:{launchSource:e.launchSource},startedAt:i,heartbeatAt:i,cliVersion:e.cliVersion??jt,...e.gracefulUpdateDrain===!0?{gracefulUpdateDrain:!0}:{}};CSe(e.machineId,e.configPath,e.linzumiUrl,t,r,o,e.onTakeover),TSe(n,s,t,r,o,e.onTakeover,e.beforeReadExistingLock,e.beforeReplaceStaleLock);let a=()=>x9(n,s,r),d=setInterval(a,g9);return d.unref(),{path:n,record:s,heartbeat:a,release:()=>{clearInterval(d),v9(n,s)}}}function x9(e,n,t){try{let r=d0(e);if(r===void 0||r.machineId!==n.machineId||r.runnerId!==n.runnerId||r.pid!==n.pid||r.startedAt!==n.startedAt)return;let o={...r,heartbeatAt:t().toISOString()},i=`${e}.tmp`;bSe(i,`${JSON.stringify(o,null,2)}
1474
1474
  `,"utf8"),_Se(i,e)}catch{}}function hd(e,n,t){if(!n(e.pid))return{state:"dead"};let r=e.heartbeatAt??e.startedAt,o=Date.parse(r);return Number.isNaN(o)||t().getTime()-o>vSe?{state:"wedged",reason:e.heartbeatAt===void 0?"missing_heartbeat":"stale_heartbeat"}:{state:"live"}}function _9(e,n,t,r){return t!==a0||e.launchSource!==a0?!1:r.state!=="live"?!0:SSe(n,e.cliVersion)>0}function SSe(e,n){let t=c9(e),r=c9(n);for(let o=0;o<3;o++){let i=t.core[o],s=r.core[o];if(i===void 0||s===void 0)throw new Error(`compareCliVersions: unexpected undefined at core index ${o}`);if(i!==s)return i>s?1:-1}return t.prerelease.length===0&&r.prerelease.length===0?0:t.prerelease.length===0?1:r.prerelease.length===0?-1:kSe(t.prerelease,r.prerelease)}function c9(e){let n=e.trim().replace(/^v/i,"").split("+",1)[0]??"",t=n.indexOf("-"),r=t===-1?n:n.slice(0,t),o=t===-1?"":n.slice(t+1),i=r.split("."),s=[nP(i[0]),nP(i[1]),nP(i[2])],a=o===""?[]:o.split(".").filter(d=>d!=="");return{core:s,prerelease:a}}function nP(e){let n=Number.parseInt(e??"",10);return Number.isInteger(n)&&n>=0?n:0}function kSe(e,n){let t=Math.max(e.length,n.length);for(let r=0;r<t;r++){let o=e[r],i=n[r];if(o===void 0)return-1;if(i===void 0)return 1;let s=Number.parseInt(o,10),a=Number.parseInt(i,10),d=s.toString()===o,u=a.toString()===i;if(d&&u){if(s!==a)return s>a?1:-1}else{if(d!==u)return d?-1:1;if(o!==i)return o>i?1:-1}}return 0}function b9(e,n,t,r,o){try{r(n.pid)}catch(i){let s=i!==null&&typeof i=="object"&&"code"in i?i.code:void 0;if(s!=="ESRCH")throw s==="EPERM"||s==="EACCES"?new Error(`Cannot take over the runner lock at ${e}: its holder process (pid ${n.pid}) is owned by another user and cannot be stopped. Stop that process, or remove the lock file, then retry.`):i}o?.({lockPath:e,holderPid:n.pid,holderRunnerId:n.runnerId,reason:t})}function CSe(e,n,t,r,o,i,s){if(t===void 0||yr(t)!==yr(Bt))return;let a=oP(e,n),d=d0(a);if(d!==void 0){if(hd(d,r,o).state==="live")throw new hs(a,d);w9(a,r,()=>{let u=tP(a)?iP(a):void 0;if(u===void 0)return;let c=hd(u,r,o);if(c.state==="live")throw new hs(a,u);c.state==="wedged"&&b9(a,u,c.reason,i,s),md(a)})}}function TSe(e,n,t,r,o,i,s,a){if(i0(e,n))return;s?.();let d=d0(e);if(d===void 0){if(i0(e,n))return;throw new Error(`another Linzumi runner lock appeared while starting: ${e}`)}if(hd(d,t,r).state==="live")throw new hs(e,d);a?.(),w9(e,t,()=>{let u=tP(e)?iP(e):void 0;if(u!==void 0){let c=hd(u,t,r);if(c.state==="live")throw new hs(e,u);c.state==="wedged"&&b9(e,u,c.reason,o,i),md(e)}if(!i0(e,n))throw new Error(`another Linzumi runner lock appeared while starting: ${e}`)})}function i0(e,n){xSe(h9(e),{recursive:!0});try{let t=p9(e,"wx");try{m9(t,`${JSON.stringify(n,null,2)}
1475
1475
  `)}finally{l9(t)}return!0}catch(t){if(zc(t,"EEXIST"))return!1;throw t}}function w9(e,n,t){let r=`${e}.replace`;for(;;)try{let o=p9(r,"wx");try{m9(o,`${process.pid}
1476
1476
  `),t()}finally{l9(o),md(r)}return}catch(o){if(zc(o,"EEXIST")){let i=PSe(r);if(i===void 0)continue;if(n(i))throw new Error(["another Linzumi runner is already replacing a stale runner lock",`lock: ${e}`,"Wait for that startup to finish, then retry."].join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "1.0.99",
3
+ "version": "1.0.100",
4
4
  "description": "Linzumi CLI \u2014 point a Codex agent at the real code on your laptop, with your team watching and steering from shared threads.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,688 @@
1
+ // Spec: kandan/plans/2026-07-10-compute-nodes-rescript-commander-zero-ts-program.md
2
+ // (M3 cluster 4, transport-phoenix).
3
+ // Relationship: Builds the TS-side PARITY ORACLE for the transport cluster
4
+ // of the ReScript commander port, the sibling of
5
+ // build-protocol-parity-oracle.mjs (cluster 1) /
6
+ // build-config-identity-parity-oracle.mjs (cluster 2) /
7
+ // build-auth-parity-oracle.mjs (cluster 3). Two modes in one bundle:
8
+ //
9
+ // BATCH (default): a JSON array of pure-transform cases on stdin, a JSON
10
+ // array of results on stdout - phoenixWebsocketUrl / protocols, the
11
+ // frame codec, the typed push-error factory table with its
12
+ // connection-lost/wait classification, the exported reconnect-backoff
13
+ // config, and redactForCliLog (the structured log redactor the ported
14
+ // transport's audit snippet mirrors).
15
+ //
16
+ // FLOW (--flow): a long-lived JSON-lines scenario driver around the REAL
17
+ // connectPhoenixClient (phoenix.ts) - the TS leg of the transport
18
+ // differential. Commands arrive one JSON object per stdin line; the
19
+ // driver emits {"obs": ...} observation lines (controls dispatched, push
20
+ // outcomes with phase/reason, disconnect/reconnect firings, operator
21
+ // notices, cursor/epoch store calls) in the client's own event order and
22
+ // {"id", ...} completion lines per command. The commander-harness
23
+ // conformance suite (TransportPhoenixParityConformance.res) runs the
24
+ // SAME command list against the ReScript transport in-process and
25
+ // asserts byte-identical normalized transcripts - the transport-seam
26
+ // differential against the M2.2 scripted kandan server.
27
+ //
28
+ // Cursor stores in flow mode are the REAL runner.ts file stores
29
+ // (createControlCursorFileStore / createRunnerControlEpochStore), so the
30
+ // cross-impl cursor-file legs compare the exact bytes the TS commander
31
+ // would write and read.
32
+ //
33
+ // Determinism: wall-clock-shaped values never enter observations verbatim
34
+ // (session durations become presence flags; timeout-budget digits are
35
+ // normalized by the harness on both legs). The oracle only ever sees the
36
+ // conformance suite's synthetic tokens.
37
+ //
38
+ // The output is a local test artifact (.differential/ is gitignored, never
39
+ // published) and stays unminified for debuggability.
40
+ import { copyFile, mkdir } from 'node:fs/promises';
41
+ import { join, dirname } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { build } from 'esbuild';
44
+
45
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
46
+
47
+ const driverSource = `
48
+ import { createInterface } from 'node:readline';
49
+ import { readFileSync } from 'node:fs';
50
+ import {
51
+ connectPhoenixClient,
52
+ decodeFrame,
53
+ encodeFrame,
54
+ isPhoenixConnectionLostError,
55
+ isPhoenixConnectionWaitError,
56
+ isPhoenixPushError,
57
+ phoenixPushErrors,
58
+ phoenixReconnectBackoffConfig,
59
+ phoenixWebsocketProtocols,
60
+ phoenixWebsocketUrl,
61
+ type PhoenixClient,
62
+ type PhoenixControlCursorStore,
63
+ type PhoenixControlEpochStore,
64
+ type PhoenixPushErrorPhase,
65
+ } from './src/phoenix';
66
+ import {
67
+ createControlCursorFileStore,
68
+ createRunnerControlEpochStore,
69
+ } from './src/runner';
70
+ import { redactForCliLog } from './src/runnerLogger';
71
+ import type { JsonObject, JsonValue } from './src/protocol';
72
+
73
+ // --- Shared helpers -------------------------------------------------------------
74
+
75
+ function outcome(run: () => unknown): unknown {
76
+ try {
77
+ const value = run();
78
+ return value === undefined ? { defined: false } : { defined: true, value };
79
+ } catch (error) {
80
+ return {
81
+ threw: true,
82
+ message: error instanceof Error ? error.message : String(error),
83
+ };
84
+ }
85
+ }
86
+
87
+ // --- BATCH mode ------------------------------------------------------------------
88
+
89
+ type PushErrorRow = {
90
+ readonly name: string;
91
+ readonly phase: string;
92
+ readonly reason: string;
93
+ readonly message: string;
94
+ readonly isPushError: boolean;
95
+ readonly isLost: boolean;
96
+ readonly isWait: boolean;
97
+ };
98
+
99
+ function pushErrorTable(): PushErrorRow[] {
100
+ const entries: ReadonlyArray<readonly [string, Error]> = [
101
+ ['socketClosed', phoenixPushErrors.socketClosed()],
102
+ ['reconnectFailed', phoenixPushErrors.reconnectFailed()],
103
+ ['socketNotOpen', phoenixPushErrors.socketNotOpen()],
104
+ ['clientClosedWait', phoenixPushErrors.clientClosed('wait_for_connection')],
105
+ ['clientClosedReply', phoenixPushErrors.clientClosed('reply')],
106
+ ['notConnected', phoenixPushErrors.notConnected()],
107
+ [
108
+ 'connectionWaitTimeout',
109
+ phoenixPushErrors.connectionWaitTimeout(1234, 'scripted_event'),
110
+ ],
111
+ ['replyTimeout', phoenixPushErrors.replyTimeout(1234, 'scripted_event')],
112
+ ];
113
+ return entries.map(([name, error]) => {
114
+ const typed = error as Error & { phase: string; reason: string };
115
+ return {
116
+ name,
117
+ phase: typed.phase,
118
+ reason: typed.reason,
119
+ message: typed.message,
120
+ isPushError: isPhoenixPushError(error),
121
+ isLost: isPhoenixConnectionLostError(error),
122
+ isWait: isPhoenixConnectionWaitError(error),
123
+ };
124
+ });
125
+ }
126
+
127
+ function handleBatchCase(testCase: {
128
+ readonly op: string;
129
+ readonly baseUrl?: string;
130
+ readonly token?: string;
131
+ readonly text?: string;
132
+ readonly frame?: JsonValue;
133
+ readonly payload?: JsonObject;
134
+ }): unknown {
135
+ switch (testCase.op) {
136
+ case 'websocketUrl':
137
+ return outcome(() => phoenixWebsocketUrl(testCase.baseUrl ?? ''));
138
+ case 'websocketProtocols':
139
+ return outcome(() => phoenixWebsocketProtocols(testCase.token ?? ''));
140
+ case 'decodeFrame':
141
+ return outcome(() => decodeFrame(testCase.text ?? ''));
142
+ case 'encodeFrame':
143
+ return outcome(() =>
144
+ encodeFrame(testCase.frame as Parameters<typeof encodeFrame>[0])
145
+ );
146
+ case 'pushErrorTable':
147
+ return pushErrorTable();
148
+ case 'backoffConfig':
149
+ return phoenixReconnectBackoffConfig;
150
+ case 'redact':
151
+ return outcome(() => redactForCliLog(testCase.payload ?? {}));
152
+ default:
153
+ return { threw: true, message: 'unknown op ' + String(testCase.op) };
154
+ }
155
+ }
156
+
157
+ async function runBatch(): Promise<void> {
158
+ let stdin = '';
159
+ process.stdin.setEncoding('utf8');
160
+ for await (const chunk of process.stdin) {
161
+ stdin += chunk;
162
+ }
163
+ const cases = JSON.parse(stdin) as unknown[];
164
+ process.stdout.write(
165
+ JSON.stringify(cases.map((testCase) => handleBatchCase(testCase as never)))
166
+ );
167
+ }
168
+
169
+ // --- FLOW mode --------------------------------------------------------------------
170
+
171
+ type Obs = Record<string, unknown>;
172
+
173
+ const observations: Obs[] = [];
174
+ const waiters: Array<() => void> = [];
175
+ let controlCount = 0;
176
+ let reconnectCount = 0;
177
+ let disconnectCount = 0;
178
+ let noticeCount = 0;
179
+
180
+ function emit(client: string, obs: Obs): void {
181
+ const entry = { client, ...obs };
182
+ observations.push(entry);
183
+ process.stdout.write(JSON.stringify({ obs: entry }) + '\\n');
184
+ for (const waiter of [...waiters]) {
185
+ waiter();
186
+ }
187
+ }
188
+
189
+ function reply(id: number, payload: Obs): void {
190
+ process.stdout.write(JSON.stringify({ id, ...payload }) + '\\n');
191
+ }
192
+
193
+ function awaitCondition(check: () => boolean): Promise<void> {
194
+ return new Promise((resolve) => {
195
+ const settle = () => {
196
+ if (check()) {
197
+ const index = waiters.indexOf(settle);
198
+ if (index !== -1) {
199
+ waiters.splice(index, 1);
200
+ }
201
+ resolve();
202
+ }
203
+ };
204
+ waiters.push(settle);
205
+ settle();
206
+ });
207
+ }
208
+
209
+ function pushFailureObs(error: unknown): Obs {
210
+ if (isPhoenixPushError(error)) {
211
+ const typed = error as Error & { phase: string; reason: string };
212
+ return {
213
+ class: 'transport',
214
+ phase: typed.phase,
215
+ reason: typed.reason,
216
+ message: typed.message,
217
+ lost: isPhoenixConnectionLostError(error),
218
+ wait: isPhoenixConnectionWaitError(error),
219
+ };
220
+ }
221
+ return {
222
+ class: 'failed',
223
+ message: error instanceof Error ? error.message : String(error),
224
+ };
225
+ }
226
+
227
+ type FlowConnectCommand = {
228
+ readonly id: number;
229
+ readonly op: 'connect';
230
+ readonly client?: string;
231
+ readonly url: string;
232
+ readonly token: string;
233
+ readonly pushTimeoutMs?: number;
234
+ readonly cursorStore?:
235
+ | { readonly kind: 'memory'; readonly initial?: Record<string, number> }
236
+ | { readonly kind: 'file'; readonly path: string };
237
+ readonly epochStore?:
238
+ | { readonly kind: 'memory'; readonly epoch?: string }
239
+ | {
240
+ readonly kind: 'file';
241
+ readonly cursorPath: string;
242
+ readonly startTurnPath: string;
243
+ };
244
+ readonly consumerThrowsOn?: string;
245
+ readonly ackMode?: 'immediate' | 'never';
246
+ };
247
+
248
+ const clients = new Map<string, PhoenixClient>();
249
+ const manualAcks = new Map<string, () => void>();
250
+
251
+ function clientName(command: { readonly client?: unknown }): string {
252
+ return typeof command.client === 'string' ? command.client : 'main';
253
+ }
254
+
255
+ function clientOf(command: { readonly client?: unknown }): PhoenixClient {
256
+ const named = clients.get(clientName(command));
257
+ if (named === undefined) {
258
+ throw new Error('flow client not connected: ' + clientName(command));
259
+ }
260
+ return named;
261
+ }
262
+
263
+ function instrumentCursorStore(
264
+ name: string,
265
+ store: PhoenixControlCursorStore & { reset?: (topic: string, seq: number) => void }
266
+ ): PhoenixControlCursorStore {
267
+ return {
268
+ initial: (topic) => {
269
+ const value = store.initial(topic);
270
+ emit(name, { kind: 'store', op: 'initial', topic, value: value ?? null });
271
+ return value;
272
+ },
273
+ onAdvance: (topic, seq) => {
274
+ emit(name, { kind: 'store', op: 'onAdvance', topic, seq });
275
+ store.onAdvance(topic, seq);
276
+ },
277
+ reset: (topic, seq) => {
278
+ emit(name, { kind: 'store', op: 'reset', topic, seq });
279
+ store.reset?.(topic, seq);
280
+ },
281
+ };
282
+ }
283
+
284
+ function instrumentEpochStore(
285
+ name: string,
286
+ store: PhoenixControlEpochStore
287
+ ): PhoenixControlEpochStore {
288
+ return {
289
+ current: (topic) => {
290
+ const value = store.current(topic);
291
+ emit(name, { kind: 'epoch', op: 'current', topic, value: value ?? null });
292
+ return value;
293
+ },
294
+ adopt: (topic, epoch) => {
295
+ const changed = store.adopt(topic, epoch);
296
+ emit(name, { kind: 'epoch', op: 'adopt', topic, epoch, changed });
297
+ return changed;
298
+ },
299
+ };
300
+ }
301
+
302
+ const flowFileStores: Array<{ flush: () => void }> = [];
303
+
304
+ async function handleConnect(command: FlowConnectCommand): Promise<void> {
305
+ const name = clientName(command);
306
+ let cursorStore: PhoenixControlCursorStore | undefined;
307
+ if (command.cursorStore?.kind === 'memory') {
308
+ const cursors = new Map<string, number>(
309
+ Object.entries(command.cursorStore.initial ?? {})
310
+ );
311
+ cursorStore = instrumentCursorStore(name, {
312
+ initial: (topic) => cursors.get(topic),
313
+ onAdvance: (topic, seq) => {
314
+ const existing = cursors.get(topic);
315
+ if (existing === undefined || seq > existing) {
316
+ cursors.set(topic, seq);
317
+ }
318
+ },
319
+ reset: (topic, seq) => {
320
+ cursors.set(topic, seq);
321
+ },
322
+ });
323
+ } else if (command.cursorStore?.kind === 'file') {
324
+ const fileStore = createControlCursorFileStore(
325
+ command.cursorStore.path,
326
+ (event: string) => {
327
+ emit(name, { kind: 'storelog', event });
328
+ }
329
+ );
330
+ flowFileStores.push(fileStore);
331
+ cursorStore = instrumentCursorStore(name, fileStore);
332
+ }
333
+
334
+ let epochStore: PhoenixControlEpochStore | undefined;
335
+ if (command.epochStore?.kind === 'memory') {
336
+ let epoch = command.epochStore.epoch;
337
+ epochStore = instrumentEpochStore(name, {
338
+ current: () => epoch,
339
+ adopt: (_topic, next) => {
340
+ const changed = epoch !== undefined && epoch !== next;
341
+ epoch = next;
342
+ return changed;
343
+ },
344
+ });
345
+ } else if (command.epochStore?.kind === 'file') {
346
+ const log = (event: string) => {
347
+ emit(name, { kind: 'storelog', event });
348
+ };
349
+ const cursorFileStore = createControlCursorFileStore(
350
+ command.epochStore.cursorPath,
351
+ log
352
+ );
353
+ const startTurnFileStore = createControlCursorFileStore(
354
+ command.epochStore.startTurnPath,
355
+ log,
356
+ { discardSeqsOnFirstEpochAdoption: true }
357
+ );
358
+ flowFileStores.push(cursorFileStore, startTurnFileStore);
359
+ cursorStore = instrumentCursorStore(name, cursorFileStore);
360
+ epochStore = instrumentEpochStore(
361
+ name,
362
+ createRunnerControlEpochStore(cursorFileStore, startTurnFileStore, log, {
363
+ runnerId: 'scripted-transport',
364
+ kandanUrl: command.url,
365
+ })
366
+ );
367
+ }
368
+
369
+ let client: PhoenixClient;
370
+ try {
371
+ client = await connectPhoenixClient(command.url, command.token, undefined, {
372
+ ...(command.pushTimeoutMs === undefined
373
+ ? {}
374
+ : { pushTimeoutMs: command.pushTimeoutMs }),
375
+ ...(cursorStore === undefined ? {} : { controlCursorStore: cursorStore }),
376
+ ...(epochStore === undefined ? {} : { controlEpochStore: epochStore }),
377
+ onOperatorNotice: (message) => {
378
+ noticeCount += 1;
379
+ emit(name, { kind: 'notice', message });
380
+ },
381
+ });
382
+ } catch (error) {
383
+ emit(name, { kind: 'connect_error', ...pushFailureObs(error) });
384
+ reply(command.id, { ok: false });
385
+ return;
386
+ }
387
+ clients.set(name, client);
388
+
389
+ client.onControl((control, ack) => {
390
+ controlCount += 1;
391
+ emit(name, { kind: 'control', payload: control });
392
+ if (
393
+ command.consumerThrowsOn !== undefined &&
394
+ control.type === command.consumerThrowsOn
395
+ ) {
396
+ throw new Error('scripted consumer failure: ' + String(control.type));
397
+ }
398
+ if (command.ackMode === 'never') {
399
+ const seq = control.control_seq;
400
+ if (typeof seq === 'number') {
401
+ manualAcks.set(name + ':' + String(seq), ack);
402
+ }
403
+ return;
404
+ }
405
+ ack();
406
+ });
407
+ client.onEvent((topic, event, payload) => {
408
+ emit(name, { kind: 'event', topic, event, payload });
409
+ });
410
+ client.onReconnect((rejoinReplies) => {
411
+ reconnectCount += 1;
412
+ const replies: Record<string, unknown> = {};
413
+ for (const [topic, response] of rejoinReplies) {
414
+ replies[topic] = response;
415
+ }
416
+ emit(name, { kind: 'reconnect', replies });
417
+ });
418
+ client.onDisconnect?.((info) => {
419
+ disconnectCount += 1;
420
+ emit(name, {
421
+ kind: 'disconnect',
422
+ closeCode: info.closeCode ?? null,
423
+ closeReason: info.closeReason ?? null,
424
+ hadDuration: info.previousSessionDurationMs !== undefined,
425
+ reconnectCount: info.reconnectCount,
426
+ });
427
+ });
428
+ emit(name, { kind: 'connect_ok' });
429
+ reply(command.id, { ok: true });
430
+ }
431
+
432
+ type FlowCommand = FlowConnectCommand | (Record<string, unknown> & { id: number; op: string });
433
+
434
+ async function handleFlowCommand(command: FlowCommand): Promise<void> {
435
+ switch (command.op) {
436
+ case 'connect':
437
+ await handleConnect(command as FlowConnectCommand);
438
+ return;
439
+ case 'join': {
440
+ const name = clientName(command);
441
+ const topic = String(command.topic);
442
+ const payload = command.payload as JsonObject;
443
+ const rejoin = command.rejoin as JsonObject | undefined;
444
+ const registrationId = command.registrationId as string | undefined;
445
+ try {
446
+ const response = await clientOf(command).join(topic, payload, {
447
+ ...(rejoin === undefined ? {} : { rejoinPayload: () => rejoin }),
448
+ ...(registrationId === undefined ? {} : { registrationId }),
449
+ });
450
+ emit(name, { kind: 'join_ok', topic, response });
451
+ } catch (error) {
452
+ emit(name, { kind: 'join_error', topic, ...pushFailureObs(error) });
453
+ }
454
+ reply(command.id, { done: true });
455
+ return;
456
+ }
457
+ case 'push': {
458
+ const name = clientName(command);
459
+ try {
460
+ const result = await clientOf(command).push(
461
+ String(command.topic),
462
+ String(command.event),
463
+ command.payload as JsonObject
464
+ );
465
+ emit(name, { kind: 'push_ok', event: command.event, reply: result });
466
+ } catch (error) {
467
+ emit(name, { kind: 'push_error', event: command.event, ...pushFailureObs(error) });
468
+ }
469
+ reply(command.id, { done: true });
470
+ return;
471
+ }
472
+ case 'pushDetached': {
473
+ // Fire without awaiting: the outcome lands as an observation whenever
474
+ // it settles (the queued-push-across-outage legs).
475
+ const name = clientName(command);
476
+ void clientOf(command)
477
+ .push(
478
+ String(command.topic),
479
+ String(command.event),
480
+ command.payload as JsonObject
481
+ )
482
+ .then(
483
+ (result) => {
484
+ emit(name, { kind: 'push_ok', event: command.event, reply: result });
485
+ },
486
+ (error: unknown) => {
487
+ emit(name, { kind: 'push_error', event: command.event, ...pushFailureObs(error) });
488
+ }
489
+ );
490
+ reply(command.id, { done: true });
491
+ return;
492
+ }
493
+ case 'pushIfConnected': {
494
+ const name = clientName(command);
495
+ try {
496
+ const result = await clientOf(command).pushIfConnected(
497
+ String(command.topic),
498
+ String(command.event),
499
+ command.payload as JsonObject
500
+ );
501
+ emit(name, { kind: 'push_ok', event: command.event, reply: result });
502
+ } catch (error) {
503
+ emit(name, { kind: 'push_error', event: command.event, ...pushFailureObs(error) });
504
+ }
505
+ reply(command.id, { done: true });
506
+ return;
507
+ }
508
+ case 'unregisterJoin':
509
+ clientOf(command).unregisterJoin?.(
510
+ String(command.topic),
511
+ String(command.registrationId)
512
+ );
513
+ reply(command.id, { done: true });
514
+ return;
515
+ case 'ackControl': {
516
+ const name = clientName(command);
517
+ const seq = Number(command.seq);
518
+ const ack = manualAcks.get(name + ':' + String(seq));
519
+ manualAcks.delete(name + ':' + String(seq));
520
+ ack?.();
521
+ emit(name, { kind: 'manual_ack', seq });
522
+ reply(command.id, { done: true });
523
+ return;
524
+ }
525
+ case 'forceReconnect': {
526
+ const name = clientName(command);
527
+ const initiated = clientOf(command).forceReconnect?.(
528
+ command.reason as string | undefined
529
+ );
530
+ emit(name, { kind: 'force_reconnect_result', initiated: initiated ?? false });
531
+ reply(command.id, { done: true });
532
+ return;
533
+ }
534
+ case 'hardReset': {
535
+ const name = clientName(command);
536
+ const initiated = clientOf(command).hardReset?.(
537
+ command.reason as string | undefined
538
+ );
539
+ emit(name, { kind: 'hard_reset_result', initiated: initiated ?? false });
540
+ reply(command.id, { done: true });
541
+ return;
542
+ }
543
+ case 'awaitControls':
544
+ await awaitCondition(() => controlCount >= Number(command.count));
545
+ reply(command.id, { done: true });
546
+ return;
547
+ case 'awaitReconnects':
548
+ await awaitCondition(() => reconnectCount >= Number(command.count));
549
+ reply(command.id, { done: true });
550
+ return;
551
+ case 'awaitDisconnects':
552
+ await awaitCondition(() => disconnectCount >= Number(command.count));
553
+ reply(command.id, { done: true });
554
+ return;
555
+ case 'awaitNotices':
556
+ await awaitCondition(() => noticeCount >= Number(command.count));
557
+ reply(command.id, { done: true });
558
+ return;
559
+ case 'awaitAudits': {
560
+ // Audit events land in the LINZUMI_CLI_AUDIT_LOG file (phoenix.ts
561
+ // writes writeCliAuditEvent directly); poll its line count so
562
+ // choreography can sequence on the drain-progress contract.
563
+ const auditFile = process.env.LINZUMI_CLI_AUDIT_LOG ?? '';
564
+ const target = Number(command.count);
565
+ const countLines = () => {
566
+ try {
567
+ return readFileSync(auditFile, 'utf8')
568
+ .split('\\n')
569
+ .filter((line) => line !== '').length;
570
+ } catch {
571
+ return 0;
572
+ }
573
+ };
574
+ while (countLines() < target) {
575
+ await new Promise((resolve) => setTimeout(resolve, 5));
576
+ }
577
+ reply(command.id, { done: true });
578
+ return;
579
+ }
580
+ case 'awaitObs': {
581
+ const kind = String(command.kind);
582
+ const count = Number(command.count);
583
+ await awaitCondition(
584
+ () => observations.filter((entry) => entry.kind === kind).length >= count
585
+ );
586
+ reply(command.id, { done: true });
587
+ return;
588
+ }
589
+ case 'close':
590
+ clients.get(clientName(command))?.close();
591
+ reply(command.id, { done: true });
592
+ return;
593
+ case 'finish': {
594
+ for (const store of flowFileStores) {
595
+ store.flush();
596
+ }
597
+ const auditFile = process.env.LINZUMI_CLI_AUDIT_LOG;
598
+ let audit: unknown[] = [];
599
+ if (auditFile !== undefined && auditFile !== '') {
600
+ try {
601
+ audit = readFileSync(auditFile, 'utf8')
602
+ .split('\\n')
603
+ .filter((line) => line !== '')
604
+ .map((line) => {
605
+ const parsed = JSON.parse(line) as Record<string, unknown>;
606
+ // Drop wall-clock + impl-varying members (ts, stack); keep the
607
+ // stable identity of each audit event.
608
+ delete parsed.ts;
609
+ delete parsed.stack;
610
+ return parsed;
611
+ });
612
+ } catch {
613
+ audit = [];
614
+ }
615
+ }
616
+ reply(command.id, { done: true, audit });
617
+ process.exit(0);
618
+ return;
619
+ }
620
+ default:
621
+ reply(command.id, { error: 'unknown op ' + String(command.op) });
622
+ }
623
+ }
624
+
625
+ async function runFlow(): Promise<void> {
626
+ const lines = createInterface({ input: process.stdin, terminal: false });
627
+ // Commands run strictly sequentially: each line's handler completes
628
+ // (including its await conditions) before the next line is consumed -
629
+ // the same milestone-sequenced choreography the differential runner uses.
630
+ const queue: string[] = [];
631
+ let draining = false;
632
+ const drain = async () => {
633
+ if (draining) {
634
+ return;
635
+ }
636
+ draining = true;
637
+ while (queue.length > 0) {
638
+ const line = queue.shift()!;
639
+ if (line.trim() === '') {
640
+ continue;
641
+ }
642
+ const command = JSON.parse(line) as FlowCommand;
643
+ await handleFlowCommand(command);
644
+ }
645
+ draining = false;
646
+ };
647
+ lines.on('line', (line) => {
648
+ queue.push(line);
649
+ void drain();
650
+ });
651
+ }
652
+
653
+ if (process.argv.includes('--flow')) {
654
+ void runFlow();
655
+ } else {
656
+ void runBatch();
657
+ }
658
+ `;
659
+
660
+ await mkdir(join(packageRoot, '.differential'), { recursive: true });
661
+
662
+ // runner.ts's graph resolves assets/ relative to the bundle at import time
663
+ // (same requirement as build-differential-entry.mjs).
664
+ await mkdir(join(packageRoot, '.differential/assets'), { recursive: true });
665
+ await copyFile(
666
+ join(packageRoot, 'src/assets/linzumi-logo.svg'),
667
+ join(packageRoot, '.differential/assets/linzumi-logo.svg')
668
+ );
669
+
670
+ await build({
671
+ stdin: {
672
+ contents: driverSource,
673
+ resolveDir: packageRoot,
674
+ sourcefile: 'transport-parity-driver.ts',
675
+ loader: 'ts',
676
+ },
677
+ bundle: true,
678
+ platform: 'node',
679
+ target: 'node20',
680
+ format: 'esm',
681
+ outfile: join(packageRoot, '.differential/transport-parity-oracle.mjs'),
682
+ minify: false,
683
+ sourcemap: false,
684
+ legalComments: 'none',
685
+ // runner.ts pulls the wider commander graph; the same runtime externals
686
+ // as the differential entry build stay external.
687
+ external: ['ws', 'undici', 'blessed', '@anthropic-ai/claude-agent-sdk'],
688
+ });