@linzumi/cli 0.0.103-beta → 0.0.105-beta

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +329 -11
  3. package/package.json +1 -1
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@0.0.103-beta --version
61
+ npx -y @linzumi/cli@0.0.105-beta --version
62
62
  linzumi --version
63
63
  ```
64
64
 
package/dist/index.js CHANGED
@@ -10637,6 +10637,28 @@ async function handleKandanChatEvent(args, state, runnerIdentity, payloadContext
10637
10637
  }
10638
10638
  startPortForwardWatchIfEnabled(args, state, payloadContext);
10639
10639
  if (event.threadId !== state.kandanThreadId) {
10640
+ if (args.options.onUnroutedThreadMessage !== void 0) {
10641
+ try {
10642
+ const outcome = await args.options.onUnroutedThreadMessage(event);
10643
+ if (outcome === "spawned") {
10644
+ args.log("kandan.message_spawn_on_miss", {
10645
+ seq: event.seq,
10646
+ actor_slug: event.actorSlug ?? null,
10647
+ actor_user_id: event.actorUserId ?? null,
10648
+ thread_id: event.threadId ?? null,
10649
+ bound_thread_id: state.kandanThreadId ?? null,
10650
+ body_preview: runnerConsoleBodyPreview(event.body)
10651
+ });
10652
+ return;
10653
+ }
10654
+ } catch (error) {
10655
+ args.log("kandan.message_spawn_on_miss_failed", {
10656
+ seq: event.seq,
10657
+ thread_id: event.threadId ?? null,
10658
+ message: error instanceof Error ? error.message : String(error)
10659
+ });
10660
+ }
10661
+ }
10640
10662
  args.log("kandan.message_ignored", {
10641
10663
  seq: event.seq,
10642
10664
  actor_slug: event.actorSlug ?? null,
@@ -10750,7 +10772,9 @@ async function startThreadMessageTurn(args, state, payloadContext, message) {
10750
10772
  actorSlug: message.actorSlug,
10751
10773
  actorUserId: message.actorUserId,
10752
10774
  body: message.body,
10753
- attachments: [],
10775
+ // Forward the user's attachments (files/images) just like the normal routed
10776
+ // path; a replayed follow-up with files must not lose them.
10777
+ attachments: message.attachments ?? [],
10754
10778
  receivedAtMs: Date.now()
10755
10779
  };
10756
10780
  enqueuePendingKandanMessage(state.queue, queued);
@@ -19948,7 +19972,7 @@ var linzumiCliVersion, linzumiCliVersionText;
19948
19972
  var init_version = __esm({
19949
19973
  "src/version.ts"() {
19950
19974
  "use strict";
19951
- linzumiCliVersion = "0.0.103-beta";
19975
+ linzumiCliVersion = "0.0.105-beta";
19952
19976
  linzumiCliVersionText = `linzumi ${linzumiCliVersion}`;
19953
19977
  }
19954
19978
  });
@@ -24749,6 +24773,10 @@ async function openLocalCodexRunner(options, log2, cleanup, close) {
24749
24773
  clearForwardPortAttribution(port);
24750
24774
  return capabilitiesPayload();
24751
24775
  },
24776
+ // Catch unrouted thread.message events even when no dynamic worker
24777
+ // session is attached (the durable thread's worker fully died). Late-
24778
+ // bound to dodge the const's temporal dead zone (defined below).
24779
+ onUnroutedThreadMessage: (event) => handleUnroutedThreadMessage(event),
24752
24780
  channelSession: options.channelSession
24753
24781
  },
24754
24782
  log: log2
@@ -24879,6 +24907,34 @@ async function openLocalCodexRunner(options, log2, cleanup, close) {
24879
24907
  );
24880
24908
  threadRunnerProcesses.clear();
24881
24909
  });
24910
+ const reapStaleThreadRunnerProcesses = () => {
24911
+ for (const [kandanThreadId, entry] of Array.from(
24912
+ threadRunnerProcesses.entries()
24913
+ )) {
24914
+ if (threadRunnerEntryLiveness(entry) !== "dead") {
24915
+ continue;
24916
+ }
24917
+ if (threadRunnerProcesses.get(kandanThreadId) !== entry) {
24918
+ continue;
24919
+ }
24920
+ threadRunnerProcesses.delete(kandanThreadId);
24921
+ log2("runner.thread_process_stale_entry_reaped", {
24922
+ kandanThreadId,
24923
+ pid: entry.kind === "running" ? entry.handle.processPid ?? null : null,
24924
+ prior_kind: entry.kind
24925
+ });
24926
+ void closeThreadRunnerEntry(entry).catch(() => void 0);
24927
+ }
24928
+ };
24929
+ if (shouldUseThreadProcesses(options)) {
24930
+ const reaperIntervalMs = options.threadRunnerReaperIntervalMs ?? THREAD_RUNNER_REAPER_INTERVAL_MS;
24931
+ const reaperInterval = setInterval(
24932
+ reapStaleThreadRunnerProcesses,
24933
+ reaperIntervalMs
24934
+ );
24935
+ reaperInterval.unref?.();
24936
+ cleanup.actions.push(() => clearInterval(reaperInterval));
24937
+ }
24882
24938
  const threadModelProviderBindings = /* @__PURE__ */ new Map();
24883
24939
  const threadRebuildSerializer = createThreadRebuildSerializer();
24884
24940
  const evictStaleDynamicChannelSession = async (codexThreadId, expectClient) => {
@@ -25081,6 +25137,10 @@ async function openLocalCodexRunner(options, log2, cleanup, close) {
25081
25137
  fast: control.fast ?? options.fast,
25082
25138
  launchTui: false,
25083
25139
  rebuildOnDeadCodexWorker,
25140
+ // Late-bound (defined after startThreadRunnerProcess); the arrow keeps
25141
+ // it out of the const's temporal dead zone while still resolving the
25142
+ // live handler when an unrouted chat event actually arrives.
25143
+ onUnroutedThreadMessage: (event) => handleUnroutedThreadMessage(event),
25084
25144
  pipelinePersistDir: commanderOutboxPersistDir(),
25085
25145
  enablePortForwardWatch: true,
25086
25146
  initialForwardPorts: allowedForwardPorts,
@@ -25277,15 +25337,29 @@ async function openLocalCodexRunner(options, log2, cleanup, close) {
25277
25337
  }
25278
25338
  const existing = threadRunnerProcesses.get(kandanThreadId);
25279
25339
  if (existing !== void 0) {
25280
- return {
25281
- instanceId,
25282
- controlType: control.type,
25283
- ok: true,
25284
- delegated: true,
25285
- threadProcess: "already_started",
25340
+ const liveness = threadRunnerEntryLiveness(existing);
25341
+ if (liveness !== "dead") {
25342
+ return {
25343
+ instanceId,
25344
+ controlType: control.type,
25345
+ ok: true,
25346
+ delegated: true,
25347
+ threadProcess: "already_started",
25348
+ kandanThreadId,
25349
+ cwd
25350
+ };
25351
+ }
25352
+ log2("runner.thread_process_stale_entry_evicted", {
25286
25353
  kandanThreadId,
25287
- cwd
25288
- };
25354
+ codex_thread_id: control.type === "reconnect_thread" ? stringValue(control.codexThreadId) ?? null : null,
25355
+ prior_kind: existing.kind,
25356
+ pid: existing.kind === "running" ? existing.handle.processPid ?? null : null,
25357
+ control_type: control.type
25358
+ });
25359
+ if (threadRunnerProcesses.get(kandanThreadId) === existing) {
25360
+ threadRunnerProcesses.delete(kandanThreadId);
25361
+ }
25362
+ void closeThreadRunnerEntry(existing).catch(() => void 0);
25289
25363
  }
25290
25364
  const spawnThreadRunner = options.spawnThreadRunner ?? spawnLocalThreadRunnerProcess;
25291
25365
  const readyTimeoutMs = threadRunnerReadyTimeoutMs(options);
@@ -25445,6 +25519,187 @@ async function openLocalCodexRunner(options, log2, cleanup, close) {
25445
25519
  throw error;
25446
25520
  }
25447
25521
  };
25522
+ const spawnOnMissInFlight = /* @__PURE__ */ new Map();
25523
+ const replaySpawnOnMissMessageOnLiveSession = async (event, record, reconnectControl) => {
25524
+ try {
25525
+ log2("runner.spawn_on_miss_replay_on_sibling", {
25526
+ kandanThreadId: event.threadId ?? null,
25527
+ codex_thread_id: record.codexThreadId,
25528
+ seq: event.seq
25529
+ });
25530
+ const liveSession = await withCodexStartRequestTimeout(
25531
+ "thread/attach",
25532
+ resolveLiveDynamicChannelSession(record.codexThreadId, {
25533
+ waitForSiblingSpawn: true
25534
+ })
25535
+ );
25536
+ if (liveSession === void 0) {
25537
+ throw new Error(
25538
+ `spawn-on-miss could not resolve a live session for codex thread ${record.codexThreadId}`
25539
+ );
25540
+ }
25541
+ await liveSession.startThreadMessageTurn({
25542
+ seq: event.seq,
25543
+ body: event.body,
25544
+ actorSlug: event.actorSlug,
25545
+ actorUserId: event.actorUserId,
25546
+ // Forward the user's attachments; a replayed follow-up with files/images
25547
+ // must not be processed as if they were never sent.
25548
+ attachments: event.attachments,
25549
+ boundStartTimeout: true
25550
+ });
25551
+ } catch (error) {
25552
+ log2("runner.spawn_on_miss_failed", {
25553
+ kandanThreadId: event.threadId ?? null,
25554
+ codex_thread_id: record.codexThreadId,
25555
+ seq: event.seq,
25556
+ message: error instanceof Error ? error.message : String(error)
25557
+ });
25558
+ await publishSpawnOnMissMessageState(
25559
+ reconnectControl,
25560
+ "failed",
25561
+ `could not resume the thread's coding session: ${error instanceof Error ? error.message : String(error)}`,
25562
+ record.codexThreadId
25563
+ );
25564
+ }
25565
+ };
25566
+ const handleUnroutedThreadMessage = async (event) => {
25567
+ if (!shouldUseThreadProcesses(options)) {
25568
+ return "ignored";
25569
+ }
25570
+ const kandanThreadId = event.threadId;
25571
+ if (kandanThreadId === void 0) {
25572
+ return "ignored";
25573
+ }
25574
+ const existing = threadRunnerProcesses.get(kandanThreadId);
25575
+ const existingLiveness = existing === void 0 ? void 0 : threadRunnerEntryLiveness(existing);
25576
+ if (existingLiveness === "live") {
25577
+ return "ignored";
25578
+ }
25579
+ const record = threadSessionRegistry.list().find((entry) => entry.kandanThreadId === kandanThreadId);
25580
+ if (record === void 0) {
25581
+ return "ignored";
25582
+ }
25583
+ const baseControl = rehydrationReconnectControl(record);
25584
+ const providerBinding = threadModelProviderBindings.get(kandanThreadId);
25585
+ const reconnectControl = {
25586
+ ...baseControl,
25587
+ sourceSeq: event.seq,
25588
+ workDescription: event.body,
25589
+ ...providerBinding?.modelProvider === void 0 ? {} : { modelProvider: providerBinding.modelProvider },
25590
+ ...providerBinding?.llmProxy === void 0 ? {} : { llmProxy: providerBinding.llmProxy }
25591
+ };
25592
+ const cwd = resolveAllowedCwd(record.cwd, allowedCwds.value);
25593
+ if (!cwd.ok) {
25594
+ log2("runner.spawn_on_miss_cwd_rejected", {
25595
+ kandanThreadId,
25596
+ cwd: record.cwd,
25597
+ reason: cwd.reason,
25598
+ seq: event.seq
25599
+ });
25600
+ await publishSpawnOnMissMessageState(
25601
+ reconnectControl,
25602
+ "failed",
25603
+ `could not resume the thread's coding session: working directory ${record.cwd} is not an allowed root (${cwd.reason})`,
25604
+ record.codexThreadId
25605
+ );
25606
+ return "spawned";
25607
+ }
25608
+ const resolvedCwd = cwd.cwd;
25609
+ const inFlightSpawn = spawnOnMissInFlight.get(kandanThreadId);
25610
+ if (inFlightSpawn !== void 0) {
25611
+ log2("runner.spawn_on_miss_resuming", {
25612
+ kandanThreadId,
25613
+ codex_thread_id: record.codexThreadId,
25614
+ provider_session_key: record.codexThreadId,
25615
+ seq: event.seq,
25616
+ decision: "await_in_flight_spawn"
25617
+ });
25618
+ const outcome = await inFlightSpawn;
25619
+ if (outcome === "no_session") {
25620
+ await publishSpawnOnMissMessageState(
25621
+ reconnectControl,
25622
+ "failed",
25623
+ `could not resume the thread's coding session: no live session after spawn`,
25624
+ record.codexThreadId
25625
+ );
25626
+ return "spawned";
25627
+ }
25628
+ await replaySpawnOnMissMessageOnLiveSession(
25629
+ event,
25630
+ record,
25631
+ reconnectControl
25632
+ );
25633
+ return "spawned";
25634
+ }
25635
+ log2("runner.spawn_on_miss_resuming", {
25636
+ kandanThreadId,
25637
+ codex_thread_id: record.codexThreadId,
25638
+ provider_session_key: record.codexThreadId,
25639
+ seq: event.seq,
25640
+ decision: "spawned_resume"
25641
+ });
25642
+ let spawnSettle = () => void 0;
25643
+ const spawnPromise = new Promise((resolve12) => {
25644
+ spawnSettle = resolve12;
25645
+ });
25646
+ spawnOnMissInFlight.set(kandanThreadId, spawnPromise);
25647
+ try {
25648
+ const startResult = await startThreadRunnerProcess(
25649
+ reconnectControl,
25650
+ resolvedCwd
25651
+ );
25652
+ if (threadStartDelegatedToSibling(startResult)) {
25653
+ spawnSettle("spawned");
25654
+ await replaySpawnOnMissMessageOnLiveSession(
25655
+ event,
25656
+ record,
25657
+ reconnectControl
25658
+ );
25659
+ return "spawned";
25660
+ }
25661
+ spawnSettle("spawned");
25662
+ return "spawned";
25663
+ } catch (error) {
25664
+ spawnSettle("no_session");
25665
+ log2("runner.spawn_on_miss_failed", {
25666
+ kandanThreadId,
25667
+ codex_thread_id: record.codexThreadId,
25668
+ seq: event.seq,
25669
+ message: error instanceof Error ? error.message : String(error)
25670
+ });
25671
+ await publishSpawnOnMissMessageState(
25672
+ reconnectControl,
25673
+ "failed",
25674
+ `could not resume the thread's coding session: ${error instanceof Error ? error.message : String(error)}`,
25675
+ record.codexThreadId
25676
+ );
25677
+ return "spawned";
25678
+ } finally {
25679
+ spawnSettle("spawned");
25680
+ if (spawnOnMissInFlight.get(kandanThreadId) === spawnPromise) {
25681
+ spawnOnMissInFlight.delete(kandanThreadId);
25682
+ }
25683
+ }
25684
+ };
25685
+ const publishSpawnOnMissMessageState = async (control, status, reason, codexThreadId) => {
25686
+ const payload = startInstanceMessageStatePayload(control, status, reason, {
25687
+ instanceId,
25688
+ codexThreadId
25689
+ });
25690
+ if (payload === void 0) {
25691
+ return;
25692
+ }
25693
+ try {
25694
+ await kandan.push(topic, "message_state", payload);
25695
+ } catch (error) {
25696
+ log2("runner.spawn_on_miss_state_push_failed", {
25697
+ thread_id: optionalThreadControlField(control, "threadId") ?? null,
25698
+ status,
25699
+ message: error instanceof Error ? error.message : String(error)
25700
+ });
25701
+ }
25702
+ };
25448
25703
  const heartbeatPayload = () => ({
25449
25704
  instanceId,
25450
25705
  clientId,
@@ -25827,6 +26082,32 @@ async function openLocalCodexRunner(options, log2, cleanup, close) {
25827
26082
  });
25828
26083
  }
25829
26084
  if (control.type === "start_instance") {
26085
+ if (shouldSkipReplayedStartInstanceForKnownSession(
26086
+ control,
26087
+ (kandanThreadId) => threadSessionRegistry.list().some((record) => record.kandanThreadId === kandanThreadId)
26088
+ )) {
26089
+ skippedStaleStartInstances += 1;
26090
+ log2("kandan.start_instance_skipped_known_session", {
26091
+ threadId: controlThreadId(control) ?? null,
26092
+ controlSeq: control.control_seq ?? null,
26093
+ issuedAt: controlIssuedAt(control) ?? null,
26094
+ reason: "thread_already_has_known_session",
26095
+ skippedStaleStartInstances
26096
+ });
26097
+ return kandan.push(topic, "codex_response", {
26098
+ instanceId,
26099
+ controlType: control.type,
26100
+ threadId: controlThreadId(control) ?? null,
26101
+ ok: false,
26102
+ skipped: true,
26103
+ error: "start_instance_thread_already_has_session",
26104
+ issuedAt: controlIssuedAt(control) ?? null
26105
+ }).catch((error) => {
26106
+ log2("kandan.control_response_push_failed", {
26107
+ message: error instanceof Error ? error.message : String(error)
26108
+ });
26109
+ });
26110
+ }
25830
26111
  const staleAgeMs = staleStartInstanceAgeMs(control, Date.now());
25831
26112
  if (staleAgeMs !== void 0) {
25832
26113
  skippedStaleStartInstances += 1;
@@ -26003,6 +26284,19 @@ function staleStartInstanceAgeMs(control, nowMs) {
26003
26284
  const ageMs = nowMs - issuedAtMs;
26004
26285
  return ageMs > staleStartInstanceWindowMs ? ageMs : void 0;
26005
26286
  }
26287
+ function shouldSkipReplayedStartInstanceForKnownSession(control, hasKnownThreadSession) {
26288
+ if (control.type !== "start_instance") {
26289
+ return false;
26290
+ }
26291
+ if (!controlReplayed(control)) {
26292
+ return false;
26293
+ }
26294
+ const kandanThreadId = controlThreadId(control);
26295
+ if (kandanThreadId === void 0) {
26296
+ return false;
26297
+ }
26298
+ return hasKnownThreadSession(kandanThreadId);
26299
+ }
26006
26300
  function shouldSkipReplayedStartTurn(appliedControlSeq, controlSeq) {
26007
26301
  return appliedControlSeq !== void 0 && controlSeq <= appliedControlSeq;
26008
26302
  }
@@ -29657,6 +29951,29 @@ function threadRunnerOptions(args) {
29657
29951
  channelSession: void 0
29658
29952
  };
29659
29953
  }
29954
+ function processIsAlive2(pid) {
29955
+ if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) {
29956
+ return true;
29957
+ }
29958
+ try {
29959
+ process.kill(pid, 0);
29960
+ return true;
29961
+ } catch (error) {
29962
+ const code = error?.code;
29963
+ return code === "EPERM";
29964
+ }
29965
+ }
29966
+ function threadRunnerEntryLiveness(entry) {
29967
+ if (entry.kind === "starting") {
29968
+ return "starting";
29969
+ }
29970
+ const handle = entry.handle;
29971
+ const ipcClosed = handle.codex.isClosed?.() === true;
29972
+ if (ipcClosed) {
29973
+ return "dead";
29974
+ }
29975
+ return processIsAlive2(handle.processPid) ? "live" : "dead";
29976
+ }
29660
29977
  async function closeThreadRunnerEntry(entry) {
29661
29978
  switch (entry.kind) {
29662
29979
  case "running":
@@ -31372,7 +31689,7 @@ async function suggestRunnerTasks(control, options, allowedCwds) {
31372
31689
  };
31373
31690
  }
31374
31691
  }
31375
- var THREAD_RUNNER_READY_TIMEOUT_MS, onboardingDiscoveryAgentStartKeys, onboardingConversationImportKeys, onboardingConversationDiscoveryReportLimit, staleStartInstanceWindowMs, connectionStaleThresholdMs, connectionUnrecoverableAfterMs, controlCursorWriteDebounceMs, controlCursorEpochKey, codexTurnTerminalNotificationMethods, claudeSessionStoreSequenceHighWater, claudeCodeLlmProxySessionHeader, onboardingConversationTitleFirstMessages, onboardingConversationTitleLastMessages, onboardingConversationTitleBodyMaxLength, onboardingConversationImportMessageMaxLength, onboardingConversationImportCandidateLimit, onboardingConversationImportProgressInterval;
31692
+ var THREAD_RUNNER_READY_TIMEOUT_MS, THREAD_RUNNER_REAPER_INTERVAL_MS, onboardingDiscoveryAgentStartKeys, onboardingConversationImportKeys, onboardingConversationDiscoveryReportLimit, staleStartInstanceWindowMs, connectionStaleThresholdMs, connectionUnrecoverableAfterMs, controlCursorWriteDebounceMs, controlCursorEpochKey, codexTurnTerminalNotificationMethods, claudeSessionStoreSequenceHighWater, claudeCodeLlmProxySessionHeader, onboardingConversationTitleFirstMessages, onboardingConversationTitleLastMessages, onboardingConversationTitleBodyMaxLength, onboardingConversationImportMessageMaxLength, onboardingConversationImportCandidateLimit, onboardingConversationImportProgressInterval;
31376
31693
  var init_runner = __esm({
31377
31694
  "src/runner.ts"() {
31378
31695
  "use strict";
@@ -31423,6 +31740,7 @@ var init_runner = __esm({
31423
31740
  init_userFacingErrors();
31424
31741
  init_remoteCodexSandboxRunner();
31425
31742
  THREAD_RUNNER_READY_TIMEOUT_MS = 3e4;
31743
+ THREAD_RUNNER_REAPER_INTERVAL_MS = 3e4;
31426
31744
  onboardingDiscoveryAgentStartKeys = /* @__PURE__ */ new Set();
31427
31745
  onboardingConversationImportKeys = /* @__PURE__ */ new Set();
31428
31746
  onboardingConversationDiscoveryReportLimit = 100;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "0.0.103-beta",
3
+ "version": "0.0.105-beta",
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": {