@linzumi/cli 1.0.17 → 1.0.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
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": {
@@ -18,7 +18,15 @@
18
18
  "codex:history-table": "node scripts/codex_history_table.mjs",
19
19
  "test": "vitest run --config vitest.config.mjs",
20
20
  "prepack": "node scripts/build.mjs",
21
- "pack:dry-run": "npm pack --dry-run"
21
+ "pack:dry-run": "npm pack --dry-run",
22
+ "record:agent-fixtures": "tsx scripts/record-agent-replay-fixtures.ts",
23
+ "normalize:agent-fixture": "tsx scripts/normalize-agent-replay-fixture.ts",
24
+ "vendor:codex-app-server-protocol": "tsx scripts/vendor-codex-app-server-protocol.ts",
25
+ "replay:agent-fixture": "tsx scripts/replay-agent-fixture.ts",
26
+ "replay:agent-backend": "tsx scripts/replay-agent-backend.ts",
27
+ "vendor:codex-app-server-protocol:check": "tsx scripts/vendor-codex-app-server-protocol.ts --check",
28
+ "replay:agent-backend-browser": "tsx scripts/replay-agent-backend-browser.ts",
29
+ "promote:gstack-claude-smoke-fixture": "tsx scripts/promote-gstack-claude-smoke-fixture.ts"
22
30
  },
23
31
  "keywords": [
24
32
  "linzumi",
@@ -50,6 +58,7 @@
50
58
  "esbuild": "^0.27.2",
51
59
  "fast-check": "^3.23.2",
52
60
  "tsx": "^4.21.0",
53
- "vitest": "^1.6.1"
61
+ "vitest": "^1.6.1",
62
+ "typescript": "^5.9.3"
54
63
  }
55
64
  }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Enter->typing latency analyzer for a runner log (the measurement infra
3
+ // behind the 2026-07-02 follow-up-latency RCA; see
4
+ // test/enterToTypingHeadOfLine.test.ts for the fix it motivated).
5
+ //
6
+ // For every real-thread follow-up dispatch it pairs `kandan.message_queued`
7
+ // with the next `codex.turn_starting` on the same thread and classifies the
8
+ // window:
9
+ // - silent : NO other runner activity for the thread in between.
10
+ // Pre-fix this was the head-of-line block on the
11
+ // `queued` message_state server ack; post-fix it
12
+ // should be ~0ms.
13
+ // - drain_blocked/* : a logged `kandan.queue_drain_blocked` gate
14
+ // (turn_not_idle = user sent mid-turn, etc.). These
15
+ // are legitimate waits, not latency bugs.
16
+ // - cold_spawn : a `runner.thread_process_starting` fired in the
17
+ // window (worker cold respawn).
18
+ //
19
+ // Usage:
20
+ // node scripts/analyze_enter_to_typing_latency.mjs [path-to-runner-log]
21
+ // Default log path: ~/.linzumi/logs/linzumi-runner.log
22
+ //
23
+ // Ran against a production runner log on 2026-07-02 this reported, over
24
+ // 5439 follow-up dispatches (UUID-threaded; includes local vitest threads
25
+ // with synthetic UUIDs, which are all in the fast bucket): 204 slower than
26
+ // 500ms, of which 158 were `silent` (p50 1343ms / p90 3119ms / p99 11928ms),
27
+ // 46 were drain_blocked/turn_not_idle, and 0 were cold spawns - the evidence
28
+ // that the dominant Enter->typing latency was the awaited status-paint ack,
29
+ // not worker respawn.
30
+ import { createReadStream } from 'node:fs';
31
+ import { createInterface } from 'node:readline';
32
+ import { homedir } from 'node:os';
33
+ import path from 'node:path';
34
+
35
+ const logPath =
36
+ process.argv[2] ??
37
+ path.join(homedir(), '.linzumi', 'logs', 'linzumi-runner.log');
38
+ const slowThresholdMs = Number(process.env.SLOW_THRESHOLD_MS ?? 500);
39
+
40
+ // Real Linzumi thread ids are UUIDs; test threads use readable slugs.
41
+ const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
42
+
43
+ function percentile(sorted, q) {
44
+ if (sorted.length === 0) {
45
+ return null;
46
+ }
47
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
48
+ }
49
+
50
+ const threads = new Map();
51
+ const rows = [];
52
+
53
+ const rl = createInterface({
54
+ input: createReadStream(logPath),
55
+ crlfDelay: Infinity,
56
+ });
57
+
58
+ for await (const line of rl) {
59
+ if (line.length > 20_000) {
60
+ continue;
61
+ }
62
+ let entry;
63
+ try {
64
+ entry = JSON.parse(line);
65
+ } catch {
66
+ continue;
67
+ }
68
+ const threadId =
69
+ entry.thread_id ??
70
+ entry.kandanThreadId ??
71
+ entry.linzumi_thread_id ??
72
+ entry.threadId ??
73
+ entry.kandan_thread_id;
74
+ if (typeof threadId !== 'string' || !uuidRe.test(threadId)) {
75
+ continue;
76
+ }
77
+ const atMs = Date.parse(entry.ts);
78
+ if (Number.isNaN(atMs)) {
79
+ // A malformed/absent timestamp would otherwise mint a NaN-latency row
80
+ // that silently falls out of both the slow and fast buckets, making the
81
+ // printed totals diverge from the dispatch count.
82
+ continue;
83
+ }
84
+ let state = threads.get(threadId);
85
+ if (state === undefined) {
86
+ state = { pending: null };
87
+ threads.set(threadId, state);
88
+ }
89
+ const event = entry.event;
90
+ if (event === 'kandan.message_queued') {
91
+ state.pending = { atMs, seq: entry.seq, blocked: [], spawn: false };
92
+ } else if (state.pending !== null) {
93
+ if (event === 'codex.turn_starting') {
94
+ rows.push({
95
+ threadId,
96
+ ts: entry.ts,
97
+ ms: atMs - state.pending.atMs,
98
+ blocked: state.pending.blocked,
99
+ spawn: state.pending.spawn,
100
+ });
101
+ state.pending = null;
102
+ } else if (event === 'kandan.queue_drain_blocked') {
103
+ state.pending.blocked.push(entry.reason);
104
+ } else if (event === 'runner.thread_process_starting') {
105
+ state.pending.spawn = true;
106
+ }
107
+ }
108
+ }
109
+
110
+ const slow = rows.filter((row) => row.ms > slowThresholdMs);
111
+ const silent = slow.filter((row) => row.blocked.length === 0 && !row.spawn);
112
+ const spawns = slow.filter((row) => row.spawn);
113
+ const blockedCounts = new Map();
114
+ for (const row of slow) {
115
+ for (const reason of new Set(row.blocked)) {
116
+ blockedCounts.set(reason, (blockedCounts.get(reason) ?? 0) + 1);
117
+ }
118
+ }
119
+
120
+ const silentSorted = silent.map((row) => row.ms).sort((a, b) => a - b);
121
+ const fast = rows.filter((row) => row.ms <= slowThresholdMs);
122
+ const fastSorted = fast.map((row) => row.ms).sort((a, b) => a - b);
123
+
124
+ console.log(`log: ${logPath}`);
125
+ console.log(
126
+ `follow-up dispatches=${rows.length} slow(>${slowThresholdMs}ms)=${slow.length}`
127
+ );
128
+ console.log(
129
+ ` silent (head-of-line on paint ack pre-fix)=${silent.length}` +
130
+ (silentSorted.length > 0
131
+ ? ` p50=${percentile(silentSorted, 0.5)}ms p90=${percentile(silentSorted, 0.9)}ms p99=${percentile(silentSorted, 0.99)}ms max=${silentSorted[silentSorted.length - 1]}ms`
132
+ : '')
133
+ );
134
+ for (const [reason, count] of blockedCounts) {
135
+ console.log(` drain_blocked/${reason}=${count}`);
136
+ }
137
+ console.log(` cold_spawn=${spawns.length}`);
138
+ console.log(
139
+ `fast(<=${slowThresholdMs}ms)=${fast.length}` +
140
+ (fastSorted.length > 0 ? ` p50=${percentile(fastSorted, 0.5)}ms` : '')
141
+ );
142
+ if (silent.length > 0) {
143
+ console.log('\nslowest silent windows:');
144
+ for (const row of [...silent].sort((a, b) => b.ms - a.ms).slice(0, 10)) {
145
+ console.log(` ${row.ts} thread=${row.threadId.slice(0, 8)} ${row.ms}ms`);
146
+ }
147
+ }
@@ -0,0 +1,281 @@
1
+ /*
2
+ - Date: 2026-06-29
3
+ Spec: plans/2026-06-29-claude-code-raw-replay-harness-plan.md
4
+ Relationship: Promotion-time normalizer for real raw agent replay captures.
5
+ Capture files stay exact in /tmp; this script produces deterministic fixtures
6
+ by normalizing machine-local paths and volatile run/session identifiers.
7
+ */
8
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
9
+ import { basename, dirname, extname, join, resolve } from 'node:path';
10
+
11
+ export type AgentReplayNormalizeOptions = {
12
+ readonly normalizeProviderIds: boolean;
13
+ };
14
+
15
+ type NormalizerState = {
16
+ readonly options: AgentReplayNormalizeOptions;
17
+ readonly mappings: Map<string, string>;
18
+ nextRunId: number;
19
+ nextSessionId: number;
20
+ nextMessageId: number;
21
+ };
22
+
23
+ type CliOptions =
24
+ | {
25
+ readonly type: 'single';
26
+ readonly input: string;
27
+ readonly output: string;
28
+ readonly normalizeProviderIds: boolean;
29
+ }
30
+ | {
31
+ readonly type: 'directory';
32
+ readonly inputDir: string;
33
+ readonly outputDir: string;
34
+ readonly normalizeProviderIds: boolean;
35
+ };
36
+
37
+ const defaultOptions: AgentReplayNormalizeOptions = {
38
+ normalizeProviderIds: true,
39
+ };
40
+
41
+ const homePathPattern = /\/home\/([A-Za-z0-9._-]+)/gu;
42
+ const tmpRunPathPattern =
43
+ /\/tmp\/linzumi-(codex|claude)-fixture-[A-Za-z0-9]+/gu;
44
+ const tmpReplayPathPattern = /\/tmp\/linzumi-agent-replay-[A-Za-z0-9._-]+/gu;
45
+ const codexSessionFilePattern =
46
+ /rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f-]{36}\.jsonl/giu;
47
+ const uuidPattern =
48
+ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu;
49
+ const codexMessageIdPattern = /\bmsg_[A-Za-z0-9_-]{16,}\b/gu;
50
+ const claudeSignaturePattern = /("signature"\s*:\s*")[^"]+(")/gu;
51
+
52
+ export function normalizeAgentReplayText(
53
+ text: string,
54
+ options: AgentReplayNormalizeOptions = defaultOptions
55
+ ): string {
56
+ const state: NormalizerState = {
57
+ options,
58
+ mappings: new Map(),
59
+ nextRunId: 1,
60
+ nextSessionId: 1,
61
+ nextMessageId: 1,
62
+ };
63
+
64
+ return text
65
+ .split(/\r?\n/u)
66
+ .map((line) => normalizeNdjsonLine(line, state))
67
+ .join('\n');
68
+ }
69
+
70
+ function normalizeNdjsonLine(line: string, state: NormalizerState): string {
71
+ if (line.trim() === '') {
72
+ return line;
73
+ }
74
+
75
+ try {
76
+ const parsed = JSON.parse(line) as unknown;
77
+ return JSON.stringify(normalizeJsonValue(parsed, state));
78
+ } catch (_error) {
79
+ return normalizeString(line, state);
80
+ }
81
+ }
82
+
83
+ function normalizeJsonValue(value: unknown, state: NormalizerState): unknown {
84
+ if (Array.isArray(value)) {
85
+ return value.map((item) => normalizeJsonValue(item, state));
86
+ }
87
+
88
+ if (value !== null && typeof value === 'object') {
89
+ const normalizedEntries = Object.entries(value).map(([key, child]) => {
90
+ if (key === 'rawText' && typeof child === 'string') {
91
+ return [key, normalizeRawText(child, state)] as const;
92
+ }
93
+
94
+ if (key === 'signature' && typeof child === 'string') {
95
+ return [key, '[fixture-signature]'] as const;
96
+ }
97
+
98
+ return [key, normalizeJsonValue(child, state)] as const;
99
+ });
100
+
101
+ return Object.fromEntries(normalizedEntries);
102
+ }
103
+
104
+ return typeof value === 'string' ? normalizeString(value, state) : value;
105
+ }
106
+
107
+ function normalizeRawText(value: string, state: NormalizerState): string {
108
+ try {
109
+ const parsed = JSON.parse(value) as unknown;
110
+ return JSON.stringify(normalizeJsonValue(parsed, state));
111
+ } catch (_error) {
112
+ return normalizeString(value, state);
113
+ }
114
+ }
115
+
116
+ function normalizeString(value: string, state: NormalizerState): string {
117
+ let normalized = value
118
+ .replace(tmpRunPathPattern, (_match, provider: string) =>
119
+ mappedValue(
120
+ state,
121
+ _match,
122
+ () => `/tmp/linzumi-agent-fixture/${provider}-run-${state.nextRunId++}`
123
+ )
124
+ )
125
+ .replace(tmpReplayPathPattern, '/tmp/linzumi-agent-replay')
126
+ .replace(homePathPattern, '/home/fixture-user')
127
+ .replace(codexSessionFilePattern, (match) =>
128
+ mappedValue(
129
+ state,
130
+ match,
131
+ () =>
132
+ `rollout-2026-06-29T00-00-00-fixture-session-${state.nextSessionId++}.jsonl`
133
+ )
134
+ )
135
+ .replace(claudeSignaturePattern, '$1[fixture-signature]$2');
136
+
137
+ switch (state.options.normalizeProviderIds) {
138
+ case true:
139
+ normalized = normalized
140
+ .replace(uuidPattern, (match) =>
141
+ mappedValue(
142
+ state,
143
+ match,
144
+ () =>
145
+ `00000000-0000-4000-8000-${String(state.nextSessionId++).padStart(12, '0')}`
146
+ )
147
+ )
148
+ .replace(codexMessageIdPattern, (match) =>
149
+ mappedValue(
150
+ state,
151
+ match,
152
+ () =>
153
+ `msg_fixture_${String(state.nextMessageId++).padStart(4, '0')}`
154
+ )
155
+ );
156
+ break;
157
+ case false:
158
+ break;
159
+ }
160
+
161
+ return normalized;
162
+ }
163
+
164
+ function mappedValue(
165
+ state: NormalizerState,
166
+ input: string,
167
+ create: () => string
168
+ ): string {
169
+ const existing = state.mappings.get(input);
170
+
171
+ if (existing !== undefined) {
172
+ return existing;
173
+ }
174
+
175
+ const next = create();
176
+ state.mappings.set(input, next);
177
+ return next;
178
+ }
179
+
180
+ async function main(): Promise<void> {
181
+ const args = process.argv.slice(2);
182
+
183
+ if (args.includes('--help')) {
184
+ printHelp();
185
+ return;
186
+ }
187
+
188
+ const options = parseCliOptions(args);
189
+
190
+ switch (options.type) {
191
+ case 'single':
192
+ await normalizeFile(options.input, options.output, {
193
+ normalizeProviderIds: options.normalizeProviderIds,
194
+ });
195
+ process.stdout.write(`wrote ${options.output}\n`);
196
+ break;
197
+ case 'directory': {
198
+ await mkdir(options.outputDir, { recursive: true });
199
+ const entries = await readdir(options.inputDir, { withFileTypes: true });
200
+ const ndjsonFiles = entries.filter(
201
+ (entry) => entry.isFile() && extname(entry.name) === '.ndjson'
202
+ );
203
+
204
+ for (const entry of ndjsonFiles) {
205
+ const input = join(options.inputDir, entry.name);
206
+ const output = join(options.outputDir, entry.name);
207
+ await normalizeFile(input, output, {
208
+ normalizeProviderIds: options.normalizeProviderIds,
209
+ });
210
+ process.stdout.write(`wrote ${output}\n`);
211
+ }
212
+ break;
213
+ }
214
+ }
215
+ }
216
+
217
+ async function normalizeFile(
218
+ input: string,
219
+ output: string,
220
+ options: AgentReplayNormalizeOptions
221
+ ): Promise<void> {
222
+ const source = await readFile(input, 'utf8');
223
+ const normalized = normalizeAgentReplayText(source, options);
224
+ await mkdir(dirname(output), { recursive: true });
225
+ await writeFile(output, normalized, 'utf8');
226
+ }
227
+
228
+ function parseCliOptions(args: readonly string[]): CliOptions {
229
+ const normalizeProviderIds = !args.includes('--keep-provider-ids');
230
+ const input = argValue(args, '--input');
231
+ const output = argValue(args, '--output');
232
+ const inputDir = argValue(args, '--input-dir');
233
+ const outputDir = argValue(args, '--output-dir');
234
+
235
+ if (input !== undefined && output !== undefined) {
236
+ return {
237
+ type: 'single',
238
+ input: resolve(input),
239
+ output: resolve(output),
240
+ normalizeProviderIds,
241
+ };
242
+ }
243
+
244
+ if (inputDir !== undefined && outputDir !== undefined) {
245
+ return {
246
+ type: 'directory',
247
+ inputDir: resolve(inputDir),
248
+ outputDir: resolve(outputDir),
249
+ normalizeProviderIds,
250
+ };
251
+ }
252
+
253
+ throw new Error(
254
+ 'provide either --input/--output or --input-dir/--output-dir'
255
+ );
256
+ }
257
+
258
+ function argValue(args: readonly string[], name: string): string | undefined {
259
+ const index = args.indexOf(name);
260
+
261
+ if (index === -1) {
262
+ return undefined;
263
+ }
264
+
265
+ return args[index + 1];
266
+ }
267
+
268
+ function printHelp(): void {
269
+ process.stdout.write(
270
+ `Usage: pnpm run normalize:agent-fixture -- [options]\n\nOptions:\n --input <file> --output <file> Normalize one NDJSON fixture\n --input-dir <dir> --output-dir <dir> Normalize every .ndjson file in a directory\n --keep-provider-ids Preserve provider session/message ids\n\nExamples:\n pnpm run normalize:agent-fixture -- --input-dir /tmp/linzumi-agent-replay-probe --output-dir test/fixtures/agent-raw-replay/promoted\n`
271
+ );
272
+ }
273
+
274
+ if (basename(process.argv[1] ?? '') === 'normalize-agent-replay-fixture.ts') {
275
+ main().catch((error) => {
276
+ process.stderr.write(
277
+ `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`
278
+ );
279
+ process.exitCode = 1;
280
+ });
281
+ }