@linzumi/cli 0.0.42-beta → 0.0.44-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 +417 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,7 +63,7 @@ Install the CLI or run it with `npx`:
63
63
 
64
64
  ```bash
65
65
  npm install -g @linzumi/cli@latest
66
- npx -y @linzumi/cli@0.0.42-beta --version
66
+ npx -y @linzumi/cli@0.0.44-beta --version
67
67
  linzumi --version
68
68
  ```
69
69
 
package/dist/index.js CHANGED
@@ -2461,6 +2461,8 @@ function shellCommandTokens(command) {
2461
2461
  var codexTypingHeartbeatMs = 5000;
2462
2462
  var defaultStreamFlushIntervalMs = 150;
2463
2463
  var maxForwardedTurnIds = 64;
2464
+ var maxClaimedKandanMessageKeys = 1024;
2465
+ var claimedKandanMessageKeys = new Set;
2464
2466
  async function attachChannelSession(args) {
2465
2467
  const session = args.options.channelSession;
2466
2468
  const chatTopic = `chat:${session.workspaceSlug}:${session.channelSlug}`;
@@ -2609,6 +2611,7 @@ async function attachChannelSession(args) {
2609
2611
  clearPendingStreamFlushTimers(state);
2610
2612
  rejectPendingApprovalRequests(state, new Error("runner closed"));
2611
2613
  await stopCodexTyping(args, state);
2614
+ releaseKandanMessageClaims(state);
2612
2615
  }
2613
2616
  };
2614
2617
  }
@@ -2650,6 +2653,7 @@ function initialChannelSessionState(cursor, rootSeq, kandanThreadId, codexThread
2650
2653
  forwardedTurnIds: new Set,
2651
2654
  forwardingTurnIds: new Set,
2652
2655
  retryableTurnIds: new Set,
2656
+ claimedKandanMessageKeys: new Set,
2653
2657
  localTuiTurnIds: new Set,
2654
2658
  mirroredTuiInputProjections: createBoundedCache(maxForwardedTurnIds),
2655
2659
  pendingTuiInputMirrors: new Map,
@@ -2667,6 +2671,7 @@ function initialChannelSessionState(cursor, rootSeq, kandanThreadId, codexThread
2667
2671
  pendingApprovalRequests: new Map,
2668
2672
  approvalPromptChain: Promise.resolve(),
2669
2673
  pendingPortForwardRequests: new Map,
2674
+ portForwardPreviousProcessingStates: new Map,
2670
2675
  queuedPortForwardCandidates: new Map,
2671
2676
  approvedForwardPorts: new Set,
2672
2677
  approvedForwardTargets: new Map,
@@ -2991,6 +2996,9 @@ async function resolvePendingPortForwardRequest(args, state, payloadContext, con
2991
2996
  state.pendingPortForwardRequests.delete(control.requestId);
2992
2997
  if (control.decision === "deny") {
2993
2998
  state.dismissedForwardTargets.set(request.port, approvedTargetFromRequest(request));
2999
+ await publishForwardPortResolvedEvent(args, request, {
3000
+ decision: "deny"
3001
+ });
2994
3002
  await publishMessageStateForPortForwardResult(args, state, request, "failed");
2995
3003
  args.log("port_forward.request_denied", {
2996
3004
  request_id: control.requestId,
@@ -3010,7 +3018,10 @@ async function resolvePendingPortForwardRequest(args, state, payloadContext, con
3010
3018
  processName: processIdentity?.appName ?? null,
3011
3019
  processIconKey: processIdentity?.iconKey ?? null
3012
3020
  });
3013
- await publishForwardPortResolvedEvent(args, request, capabilities);
3021
+ await publishForwardPortResolvedEvent(args, request, {
3022
+ decision: "approve",
3023
+ capabilities
3024
+ });
3014
3025
  await publishMessageStateForPortForwardResult(args, state, request, "processed");
3015
3026
  await publishPortForwardReadyMessage(args, state, payloadContext, request);
3016
3027
  args.log("port_forward.request_approved", {
@@ -3095,12 +3106,14 @@ async function publishPortForwardPrompt(args, state, payloadContext, candidate)
3095
3106
  candidate
3096
3107
  });
3097
3108
  state.pendingPortForwardRequests.set(requestId, request);
3109
+ if (state.activeProcessingState !== undefined) {
3110
+ state.portForwardPreviousProcessingStates.set(requestId, state.activeProcessingState);
3111
+ }
3098
3112
  const processIdentity = guessCanonicalProcessFromCommand(candidate.command);
3099
3113
  const processIconPath = processIdentity?.iconKey === undefined ? undefined : `/web/process-icons/${processIdentity.iconKey}.png`;
3100
3114
  const processName = processIdentity?.appName ?? label;
3101
- await publishForwardPortRequestedEvent(args, request);
3102
- await publishMessageState(args, state.kandanThreadId, sourceSeq, {
3103
- status: "processing",
3115
+ state.activeProcessingState = {
3116
+ seq: sourceSeq,
3104
3117
  reason: "awaiting approval",
3105
3118
  approval: {
3106
3119
  requestId,
@@ -3129,7 +3142,13 @@ async function publishPortForwardPrompt(args, state, payloadContext, candidate)
3129
3142
  allowedActorSlug: args.options.channelSession.listenUser,
3130
3143
  allowedActorUserId: args.options.channelSession.listenUser.toLowerCase() === (payloadContext.runnerIdentity.actorUsername ?? "").toLowerCase() ? payloadContext.runnerIdentity.actorUserId : undefined
3131
3144
  }
3145
+ };
3146
+ await publishMessageState(args, state.kandanThreadId, sourceSeq, {
3147
+ status: "processing",
3148
+ reason: "awaiting approval",
3149
+ approval: state.activeProcessingState.approval
3132
3150
  });
3151
+ await publishForwardPortRequestedEvent(args, state, request, processIdentity);
3133
3152
  args.log("port_forward.request_pending", {
3134
3153
  request_id: requestId,
3135
3154
  port: candidate.port,
@@ -3170,6 +3189,10 @@ async function expireLostPendingPortForwardRequest(args, state, payloadContext,
3170
3189
  return;
3171
3190
  }
3172
3191
  state.pendingPortForwardRequests.delete(request.requestId);
3192
+ await publishForwardPortResolvedEvent(args, request, {
3193
+ decision: "expired",
3194
+ reason: "listener_exited"
3195
+ });
3173
3196
  await publishMessageStateForPortForwardResult(args, state, request, "failed", "port_forward_listener_exited");
3174
3197
  args.log("port_forward.pending_request_expired", {
3175
3198
  request_id: request.requestId,
@@ -3216,17 +3239,23 @@ async function publishPortForwardReadyMessage(args, state, payloadContext, reque
3216
3239
  }
3217
3240
  }, args.log);
3218
3241
  }
3219
- async function publishForwardPortRequestedEvent(args, request) {
3242
+ async function publishForwardPortRequestedEvent(args, state, request, processIdentity) {
3220
3243
  await pushOptional(args.kandan, args.topic, "forward_port_requested", {
3221
3244
  instanceId: args.instanceId,
3222
3245
  requestId: request.requestId,
3246
+ sourceSeq: request.sourceSeq,
3223
3247
  port: request.port,
3224
3248
  pid: request.pid,
3225
3249
  command: request.command,
3250
+ codexThreadId: state.codexThreadId ?? null,
3251
+ kandanThreadId: state.kandanThreadId ?? null,
3252
+ channelSlug: args.options.channelSession.channelSlug ?? null,
3253
+ ...processIdentity?.appName === undefined ? {} : { processName: processIdentity.appName },
3254
+ ...processIdentity?.iconKey === undefined ? {} : { processIconKey: processIdentity.iconKey },
3226
3255
  ...request.cwd === undefined ? {} : { cwd: request.cwd }
3227
3256
  }, args.log);
3228
3257
  }
3229
- async function publishForwardPortResolvedEvent(args, request, capabilities) {
3258
+ async function publishForwardPortResolvedEvent(args, request, result) {
3230
3259
  await pushOptional(args.kandan, args.topic, "forward_port_resolved", {
3231
3260
  instanceId: args.instanceId,
3232
3261
  requestId: request.requestId,
@@ -3234,18 +3263,52 @@ async function publishForwardPortResolvedEvent(args, request, capabilities) {
3234
3263
  pid: request.pid,
3235
3264
  command: request.command,
3236
3265
  ...request.cwd === undefined ? {} : { cwd: request.cwd },
3237
- decision: "approve",
3238
- ...capabilities === undefined ? {} : { capabilities }
3266
+ sourceSeq: request.sourceSeq,
3267
+ decision: result.decision,
3268
+ ...result.reason === undefined ? {} : { reason: result.reason },
3269
+ ...result.capabilities === undefined ? {} : { capabilities: result.capabilities }
3239
3270
  }, args.log);
3240
3271
  }
3241
3272
  async function publishMessageStateForPortForwardResult(args, state, request, status, failedReason = "port_forward_denied") {
3242
3273
  if (state.kandanThreadId === undefined) {
3243
3274
  return;
3244
3275
  }
3276
+ const previousProcessingState = state.portForwardPreviousProcessingStates.get(request.requestId);
3277
+ state.portForwardPreviousProcessingStates.delete(request.requestId);
3245
3278
  const activeProcessingState = state.activeProcessingState;
3246
3279
  if (activeProcessingState !== undefined && activeProcessingState.seq === request.sourceSeq) {
3247
- await publishMessageState(args, state.kandanThreadId, request.sourceSeq, processingMessageStateFromActive(activeProcessingState), undefined, undefined, state.codexThreadId);
3248
- return;
3280
+ const resolvingActiveApproval = activeProcessingState.reason === "awaiting approval" && activeProcessingState.approval.requestId === request.requestId;
3281
+ if (resolvingActiveApproval) {
3282
+ state.activeProcessingState = undefined;
3283
+ if (previousProcessingState !== undefined && previousProcessingState.seq === request.sourceSeq) {
3284
+ state.activeProcessingState = previousProcessingState;
3285
+ await publishMessageState(args, state.kandanThreadId, request.sourceSeq, processingMessageStateFromActive(previousProcessingState), undefined, undefined, state.codexThreadId);
3286
+ return;
3287
+ }
3288
+ switch (state.turn.status) {
3289
+ case "active":
3290
+ case "completing":
3291
+ if (state.turn.queuedSeq === request.sourceSeq) {
3292
+ state.activeProcessingState = {
3293
+ seq: request.sourceSeq,
3294
+ reason: "running terminal command"
3295
+ };
3296
+ await publishMessageState(args, state.kandanThreadId, request.sourceSeq, {
3297
+ status: "processing",
3298
+ reason: "running terminal command"
3299
+ });
3300
+ return;
3301
+ }
3302
+ break;
3303
+ case "idle":
3304
+ case "starting":
3305
+ break;
3306
+ }
3307
+ }
3308
+ if (!resolvingActiveApproval) {
3309
+ await publishMessageState(args, state.kandanThreadId, request.sourceSeq, processingMessageStateFromActive(activeProcessingState), undefined, undefined, state.codexThreadId);
3310
+ return;
3311
+ }
3249
3312
  }
3250
3313
  switch (status) {
3251
3314
  case "processed":
@@ -3376,6 +3439,15 @@ async function handleKandanChatEvent(args, state, runnerIdentity, payloadContext
3376
3439
  });
3377
3440
  return;
3378
3441
  }
3442
+ if (!claimKandanMessage(args, state, event)) {
3443
+ args.log("kandan.message_ignored", {
3444
+ seq: event.seq,
3445
+ actor_slug: event.actorSlug ?? null,
3446
+ actor_user_id: event.actorUserId ?? null,
3447
+ reason: "duplicate_message_claim"
3448
+ });
3449
+ return;
3450
+ }
3379
3451
  enqueuePendingKandanMessage(state.queue, {
3380
3452
  seq: event.seq,
3381
3453
  actorSlug: event.actorSlug,
@@ -3392,6 +3464,44 @@ async function handleKandanChatEvent(args, state, runnerIdentity, payloadContext
3392
3464
  await publishKandanMessageState(args, event, { status: "queued" });
3393
3465
  await drainKandanMessageQueue(args, state, payloadContext);
3394
3466
  }
3467
+ function kandanMessageClaimKey(args, threadId, seq) {
3468
+ const session = args.options.channelSession;
3469
+ return [
3470
+ args.options.runnerId,
3471
+ args.instanceId,
3472
+ session.workspaceSlug,
3473
+ session.channelSlug,
3474
+ threadId,
3475
+ String(seq)
3476
+ ].join(":");
3477
+ }
3478
+ function claimKandanMessage(args, state, event) {
3479
+ const threadId = event.threadId;
3480
+ if (threadId === undefined) {
3481
+ return true;
3482
+ }
3483
+ const key = kandanMessageClaimKey(args, threadId, event.seq);
3484
+ if (claimedKandanMessageKeys.has(key)) {
3485
+ return false;
3486
+ }
3487
+ claimedKandanMessageKeys.add(key);
3488
+ state.claimedKandanMessageKeys.add(key);
3489
+ if (state.claimedKandanMessageKeys.size > maxClaimedKandanMessageKeys) {
3490
+ const overflow = state.claimedKandanMessageKeys.size - maxClaimedKandanMessageKeys;
3491
+ const expiredKeys = Array.from(state.claimedKandanMessageKeys).slice(0, overflow);
3492
+ for (const expiredKey of expiredKeys) {
3493
+ state.claimedKandanMessageKeys.delete(expiredKey);
3494
+ claimedKandanMessageKeys.delete(expiredKey);
3495
+ }
3496
+ }
3497
+ return true;
3498
+ }
3499
+ function releaseKandanMessageClaims(state) {
3500
+ for (const key of state.claimedKandanMessageKeys) {
3501
+ claimedKandanMessageKeys.delete(key);
3502
+ }
3503
+ state.claimedKandanMessageKeys.clear();
3504
+ }
3395
3505
  async function startThreadMessageTurn(args, state, payloadContext, message) {
3396
3506
  if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
3397
3507
  throw new Error("cannot start a local Codex turn before thread binding");
@@ -3554,7 +3664,16 @@ async function drainKandanMessageQueue(args, state, payloadContext) {
3554
3664
  new_codex_thread_id: newCodexThreadId
3555
3665
  });
3556
3666
  await postCodexThreadReboundMessage(args, state, payloadContext, oldCodexThreadId, newCodexThreadId);
3557
- state.pendingReconnectContextInjection = await fetchReconnectContextInjection(args, state);
3667
+ try {
3668
+ state.pendingReconnectContextInjection = await fetchReconnectContextInjection(args, state);
3669
+ } catch (contextError) {
3670
+ state.pendingReconnectContextInjection = undefined;
3671
+ args.log("codex.thread_reconnect_context_failed", {
3672
+ kandan_thread_id: state.kandanThreadId,
3673
+ codex_thread_id: newCodexThreadId,
3674
+ message: contextError instanceof Error ? contextError.message : String(contextError)
3675
+ });
3676
+ }
3558
3677
  requeuePendingKandanMessageFront(state.queue, next);
3559
3678
  state.turn = { status: "idle" };
3560
3679
  await drainKandanMessageQueue(args, state, payloadContext);
@@ -4913,6 +5032,9 @@ async function refreshActiveProcessingState(args, state, turnId, reason) {
4913
5032
  if (state.activeProcessingState?.seq === seq && state.activeProcessingState.reason === reason) {
4914
5033
  return;
4915
5034
  }
5035
+ if (state.activeProcessingState?.seq === seq && state.activeProcessingState.reason === "awaiting approval") {
5036
+ return;
5037
+ }
4916
5038
  state.activeProcessingState = { seq, reason };
4917
5039
  await publishMessageState(args, state.kandanThreadId, seq, {
4918
5040
  status: "processing",
@@ -4924,6 +5046,14 @@ async function refreshActiveProcessingHeartbeat(args, state) {
4924
5046
  if (activeProcessingState === undefined || state.kandanThreadId === undefined) {
4925
5047
  return;
4926
5048
  }
5049
+ if (activeProcessingState.reason === "awaiting approval") {
5050
+ switch (activeProcessingState.approval.kind) {
5051
+ case "local_runner_port_forward":
5052
+ return;
5053
+ default:
5054
+ break;
5055
+ }
5056
+ }
4927
5057
  await publishMessageState(args, state.kandanThreadId, activeProcessingState.seq, processingMessageStateFromActive(activeProcessingState), undefined, undefined, state.codexThreadId);
4928
5058
  }
4929
5059
  function clearActiveProcessingState(state, seq) {
@@ -8133,7 +8263,7 @@ function realpathOrResolved(pathValue) {
8133
8263
  }
8134
8264
 
8135
8265
  // src/version.ts
8136
- var linzumiCliVersion = "0.0.42-beta";
8266
+ var linzumiCliVersion = "0.0.44-beta";
8137
8267
  var linzumiCliVersionText = `linzumi ${linzumiCliVersion}`;
8138
8268
 
8139
8269
  // src/runnerLock.ts
@@ -8502,6 +8632,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8502
8632
  const allowedForwardPorts = options.allowedForwardPorts ?? [];
8503
8633
  const liveForwardPorts = new Set(allowedForwardPorts);
8504
8634
  const managedForwardPorts = new Set;
8635
+ const kandanControlPort = explicitUrlPort(options.kandanUrl);
8636
+ const suppressedForwardPorts = () => suppressedForwardPortsForRunner(kandanControlPort, managedForwardPorts);
8505
8637
  const forwardPortAttributions = new Map;
8506
8638
  const setForwardPortAttribution = (port, attribution) => {
8507
8639
  forwardPortAttributions.set(port, {
@@ -8509,7 +8641,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8509
8641
  codexThreadId: attribution.codexThreadId ?? null,
8510
8642
  channelSlug: attribution.channelSlug ?? null,
8511
8643
  processName: attribution.processName ?? null,
8512
- processIconKey: attribution.processIconKey ?? null
8644
+ processIconKey: attribution.processIconKey ?? null,
8645
+ localEditor: attribution.localEditor === true
8513
8646
  });
8514
8647
  };
8515
8648
  const clearForwardPortAttribution = (port) => {
@@ -8523,7 +8656,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8523
8656
  codexThreadId: attribution?.codexThreadId ?? null,
8524
8657
  channelSlug: attribution?.channelSlug ?? null,
8525
8658
  processName: attribution?.processName ?? null,
8526
- processIconKey: attribution?.processIconKey ?? null
8659
+ processIconKey: attribution?.processIconKey ?? null,
8660
+ localEditor: attribution?.localEditor === true
8527
8661
  };
8528
8662
  });
8529
8663
  const allowedCwds = { value: [...options.allowedCwds] };
@@ -8534,11 +8668,35 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8534
8668
  value: { status: "disabled" }
8535
8669
  };
8536
8670
  const localEditorGeneration = { value: 0 };
8671
+ const localEditorForwardState = {
8672
+ watcher: undefined,
8673
+ approvedPorts: new Set,
8674
+ approvedTargets: new Map,
8675
+ dismissedTargets: new Map,
8676
+ pendingRequests: new Map,
8677
+ queuedCandidates: new Map
8678
+ };
8537
8679
  cleanup.actions.push(() => {
8538
8680
  if (localEditorState.value.status === "running") {
8539
8681
  localEditorState.value.process.kill("SIGINT");
8540
8682
  localEditorState.value.collaboration?.process.kill("SIGINT");
8541
8683
  }
8684
+ localEditorForwardState.watcher?.close();
8685
+ });
8686
+ const pendingEditorForwardPortRequests = () => Array.from(localEditorForwardState.pendingRequests.values()).sort((left, right) => left.port - right.port).map((request) => {
8687
+ const processIdentity = guessCanonicalProcessFromCommand(request.command);
8688
+ const label = processIdentity?.appName ?? portForwardPromptLabel(approvedTargetFromRequest(request));
8689
+ return {
8690
+ requestId: request.requestId,
8691
+ port: request.port,
8692
+ pid: request.pid,
8693
+ command: request.command,
8694
+ ...request.cwd === undefined ? {} : { cwd: request.cwd },
8695
+ summary: `Make ${label} on port ${request.port} accessible on Linzumi?`,
8696
+ reason: portForwardPromptReason(approvedTargetFromRequest(request)),
8697
+ ...processIdentity?.appName === undefined ? {} : { processName: processIdentity.appName },
8698
+ ...processIdentity?.iconKey === undefined ? {} : { processIconKey: processIdentity.iconKey }
8699
+ };
8542
8700
  });
8543
8701
  const capabilitiesPayload = () => ({
8544
8702
  codexAppServer: true,
@@ -8550,6 +8708,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8550
8708
  portForwarding: liveForwardPorts.size > 0,
8551
8709
  allowedPorts: Array.from(liveForwardPorts).sort((left, right) => left - right),
8552
8710
  forwardedPortAttributions: buildForwardPortAttributionPayload(),
8711
+ pendingEditorForwardPortRequests: pendingEditorForwardPortRequests(),
8553
8712
  toolStatus: options.dependencyStatus === undefined ? null : dependencyStatusPayload(options.dependencyStatus),
8554
8713
  editorRuntime: options.dependencyStatus?.editorRuntime === undefined ? null : dependencyStatusPayload(options.dependencyStatus).editorRuntime,
8555
8714
  ...localEditorCapabilities(options.editorRuntime, allowedCwds.value, localEditorState.value)
@@ -8561,6 +8720,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8561
8720
  const joinPayload = () => ({
8562
8721
  clientName: "kandan-local-codex-runner",
8563
8722
  clientId,
8723
+ runnerPid: process.pid,
8564
8724
  version: linzumiCliVersion,
8565
8725
  workspace: runnerWorkspaceSlug(options) ?? null,
8566
8726
  channel: options.channelSession?.channelSlug ?? null,
@@ -8598,12 +8758,204 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8598
8758
  });
8599
8759
  });
8600
8760
  };
8761
+ const pushEditorPortForwardEvent = (event, payload) => {
8762
+ kandan.push(topic, event, payload).catch((error) => {
8763
+ log(`kandan.${event}_push_failed`, {
8764
+ message: error instanceof Error ? error.message : String(error)
8765
+ });
8766
+ });
8767
+ };
8768
+ const revokeEditorForwardPort = (port, reason) => {
8769
+ const target = localEditorForwardState.approvedTargets.get(port);
8770
+ localEditorForwardState.approvedPorts.delete(port);
8771
+ localEditorForwardState.approvedTargets.delete(port);
8772
+ liveForwardPorts.delete(port);
8773
+ clearForwardPortAttribution(port);
8774
+ pushEditorPortForwardEvent("forward_port_revoked", {
8775
+ instanceId,
8776
+ port,
8777
+ ...target === undefined ? {} : {
8778
+ pid: target.pid,
8779
+ command: target.command,
8780
+ ...target.cwd === undefined ? {} : { cwd: target.cwd }
8781
+ },
8782
+ reason,
8783
+ capabilities: revocationCapabilities(capabilitiesPayload(), port)
8784
+ });
8785
+ };
8786
+ const clearEditorPortForwarding = (reason) => {
8787
+ localEditorForwardState.watcher?.close();
8788
+ localEditorForwardState.watcher = undefined;
8789
+ localEditorForwardState.pendingRequests.clear();
8790
+ localEditorForwardState.queuedCandidates.clear();
8791
+ const revokedPorts = Array.from(localEditorForwardState.approvedPorts);
8792
+ for (const port of revokedPorts) {
8793
+ revokeEditorForwardPort(port, reason);
8794
+ }
8795
+ localEditorForwardState.dismissedTargets.clear();
8796
+ return revokedPorts;
8797
+ };
8798
+ const drainQueuedEditorPortForwardPrompt = () => {
8799
+ if (localEditorForwardState.pendingRequests.size > 0 || localEditorForwardState.queuedCandidates.size === 0) {
8800
+ return;
8801
+ }
8802
+ const next = Array.from(localEditorForwardState.queuedCandidates.values()).sort((left, right) => left.port - right.port)[0];
8803
+ if (next === undefined) {
8804
+ return;
8805
+ }
8806
+ localEditorForwardState.queuedCandidates.delete(next.port);
8807
+ publishEditorPortForwardPrompt(next);
8808
+ if (localEditorForwardState.pendingRequests.size === 0) {
8809
+ drainQueuedEditorPortForwardPrompt();
8810
+ }
8811
+ };
8812
+ const publishEditorPortForwardPrompt = (candidate) => {
8813
+ const review = reviewPortForwardCandidate({
8814
+ candidate,
8815
+ threadBound: true,
8816
+ suppressedPorts: new Set(suppressedForwardPorts()),
8817
+ approvedPorts: localEditorForwardState.approvedPorts,
8818
+ approvedTargets: localEditorForwardState.approvedTargets,
8819
+ dismissedTargets: localEditorForwardState.dismissedTargets,
8820
+ pendingRequests: Array.from(localEditorForwardState.pendingRequests.values())
8821
+ });
8822
+ switch (review.type) {
8823
+ case "skip":
8824
+ return;
8825
+ case "remember_approved_target":
8826
+ localEditorForwardState.approvedTargets.set(review.target.port, review.target);
8827
+ return;
8828
+ case "revoke_and_prompt":
8829
+ if (localEditorForwardState.pendingRequests.size > 0) {
8830
+ localEditorForwardState.queuedCandidates.set(candidate.port, candidate);
8831
+ return;
8832
+ }
8833
+ revokeEditorForwardPort(review.revoked.port, review.reason);
8834
+ break;
8835
+ case "prompt":
8836
+ if (localEditorForwardState.pendingRequests.size > 0) {
8837
+ localEditorForwardState.queuedCandidates.set(candidate.port, candidate);
8838
+ return;
8839
+ }
8840
+ break;
8841
+ }
8842
+ const requestId = `editor-port-forward-${randomUUID3()}`;
8843
+ const request = pendingRequestFromCandidate({
8844
+ requestId,
8845
+ sourceSeq: 0,
8846
+ candidate
8847
+ });
8848
+ localEditorForwardState.pendingRequests.set(request.port, request);
8849
+ const processIdentity = guessCanonicalProcessFromCommand(candidate.command);
8850
+ pushEditorPortForwardEvent("forward_port_requested", {
8851
+ instanceId,
8852
+ requestId,
8853
+ source: "local_editor",
8854
+ port: request.port,
8855
+ pid: request.pid,
8856
+ command: request.command,
8857
+ ...request.cwd === undefined ? {} : { cwd: request.cwd },
8858
+ ...processIdentity?.appName === undefined ? {} : { processName: processIdentity.appName },
8859
+ ...processIdentity?.iconKey === undefined ? {} : { processIconKey: processIdentity.iconKey },
8860
+ capabilities: capabilitiesPayload()
8861
+ });
8862
+ };
8863
+ const expireLostEditorPortForwardCandidate = (candidate) => {
8864
+ const queuedCandidate = localEditorForwardState.queuedCandidates.get(candidate.port);
8865
+ if (queuedCandidate !== undefined && sameForwardCandidate(queuedCandidate, candidate)) {
8866
+ localEditorForwardState.queuedCandidates.delete(candidate.port);
8867
+ return;
8868
+ }
8869
+ const pendingRequest = localEditorForwardState.pendingRequests.get(candidate.port);
8870
+ if (pendingRequest !== undefined && sameForwardCandidate(approvedTargetFromRequest(pendingRequest), candidate)) {
8871
+ localEditorForwardState.pendingRequests.delete(candidate.port);
8872
+ pushEditorPortForwardEvent("forward_port_revoked", {
8873
+ instanceId,
8874
+ port: candidate.port,
8875
+ pid: candidate.pid,
8876
+ command: candidate.command,
8877
+ ...candidate.cwd === undefined ? {} : { cwd: candidate.cwd },
8878
+ reason: "listener_exited",
8879
+ capabilities: revocationCapabilities(capabilitiesPayload(), candidate.port)
8880
+ });
8881
+ drainQueuedEditorPortForwardPrompt();
8882
+ return;
8883
+ }
8884
+ if (localEditorForwardState.approvedPorts.has(candidate.port)) {
8885
+ revokeEditorForwardPort(candidate.port, "listener_exited");
8886
+ }
8887
+ };
8888
+ const resolveEditorPortForwardRequest = (control) => {
8889
+ const request = Array.from(localEditorForwardState.pendingRequests.values()).find((request2) => request2.requestId === control.requestId);
8890
+ if (request === undefined) {
8891
+ return;
8892
+ }
8893
+ localEditorForwardState.pendingRequests.delete(request.port);
8894
+ if (control.decision === "deny") {
8895
+ localEditorForwardState.dismissedTargets.set(request.port, approvedTargetFromRequest(request));
8896
+ pushEditorPortForwardEvent("forward_port_resolved", {
8897
+ instanceId,
8898
+ requestId: request.requestId,
8899
+ source: "local_editor",
8900
+ port: request.port,
8901
+ pid: request.pid,
8902
+ command: request.command,
8903
+ ...request.cwd === undefined ? {} : { cwd: request.cwd },
8904
+ decision: "deny",
8905
+ capabilities: capabilitiesPayload()
8906
+ });
8907
+ drainQueuedEditorPortForwardPrompt();
8908
+ return { instanceId, ok: true };
8909
+ }
8910
+ localEditorForwardState.approvedPorts.add(request.port);
8911
+ localEditorForwardState.approvedTargets.set(request.port, approvedTargetFromRequest(request));
8912
+ liveForwardPorts.add(request.port);
8913
+ const processIdentity = guessCanonicalProcessFromCommand(request.command);
8914
+ setForwardPortAttribution(request.port, {
8915
+ localEditor: true,
8916
+ processName: processIdentity?.appName ?? null,
8917
+ processIconKey: processIdentity?.iconKey ?? null
8918
+ });
8919
+ pushEditorPortForwardEvent("forward_port_resolved", {
8920
+ instanceId,
8921
+ requestId: request.requestId,
8922
+ source: "local_editor",
8923
+ port: request.port,
8924
+ pid: request.pid,
8925
+ command: request.command,
8926
+ ...request.cwd === undefined ? {} : { cwd: request.cwd },
8927
+ decision: "approve",
8928
+ capabilities: capabilitiesPayload()
8929
+ });
8930
+ drainQueuedEditorPortForwardPrompt();
8931
+ return { instanceId, ok: true, port: request.port };
8932
+ };
8933
+ const startLocalEditorPortForwardWatcher = (state) => {
8934
+ if (state.process.pid === undefined) {
8935
+ log("port_forward.local_editor_watch_skipped", {
8936
+ reason: "editor_pid_missing"
8937
+ });
8938
+ return;
8939
+ }
8940
+ localEditorForwardState.watcher?.close();
8941
+ localEditorForwardState.watcher = startPortForwardWatcher({
8942
+ rootPid: state.process.pid,
8943
+ onCandidate: publishEditorPortForwardPrompt,
8944
+ onCandidateLost: expireLostEditorPortForwardCandidate,
8945
+ onError: (error) => {
8946
+ log("port_forward.local_editor_watch_failed", {
8947
+ message: error.message
8948
+ });
8949
+ }
8950
+ });
8951
+ };
8601
8952
  const watchLocalEditorExit = (state, generation, initialStatusPushed) => {
8602
8953
  const handleExit = () => {
8603
8954
  if (localEditorGeneration.value !== generation || localEditorState.value.status !== "running" || localEditorState.value.process !== state.process) {
8604
8955
  return;
8605
8956
  }
8606
8957
  localEditorState.value = { status: "disabled" };
8958
+ const revokedEditorPorts = clearEditorPortForwarding("editor_exited");
8607
8959
  liveForwardPorts.delete(state.port);
8608
8960
  managedForwardPorts.delete(state.port);
8609
8961
  if (state.collaboration !== undefined) {
@@ -8616,7 +8968,11 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8616
8968
  cwd: state.cwd,
8617
8969
  capabilities: {
8618
8970
  ...capabilitiesPayload(),
8619
- revokedPorts: state.collaboration === undefined ? [state.port] : [state.port, state.collaboration.serverPort]
8971
+ revokedPorts: state.collaboration === undefined ? [state.port, ...revokedEditorPorts] : [
8972
+ state.port,
8973
+ state.collaboration.serverPort,
8974
+ ...revokedEditorPorts
8975
+ ]
8620
8976
  }
8621
8977
  });
8622
8978
  };
@@ -8674,7 +9030,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8674
9030
  launchTui: options.launchTui,
8675
9031
  enablePortForwardWatch: true,
8676
9032
  initialForwardPorts: allowedForwardPorts,
8677
- suppressedForwardPorts: () => Array.from(managedForwardPorts),
9033
+ suppressedForwardPorts,
8678
9034
  onForwardPortApproved: (port, attribution) => {
8679
9035
  liveForwardPorts.add(port);
8680
9036
  setForwardPortAttribution(port, attribution);
@@ -8729,7 +9085,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8729
9085
  launchTui: false,
8730
9086
  enablePortForwardWatch: true,
8731
9087
  initialForwardPorts: allowedForwardPorts,
8732
- suppressedForwardPorts: () => Array.from(managedForwardPorts),
9088
+ suppressedForwardPorts,
8733
9089
  onForwardPortApproved: (port, attribution) => {
8734
9090
  liveForwardPorts.add(port);
8735
9091
  setForwardPortAttribution(port, attribution);
@@ -8900,6 +9256,11 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8900
9256
  if (result.ok) {
8901
9257
  localEditorGeneration.value += 1;
8902
9258
  const editorGeneration = localEditorGeneration.value;
9259
+ const restartedEditorRevokedPorts = localEditorState.value.status === "running" ? [
9260
+ localEditorState.value.port,
9261
+ ...localEditorState.value.collaboration === undefined ? [] : [localEditorState.value.collaboration.serverPort],
9262
+ ...clearEditorPortForwarding("editor_restarted")
9263
+ ] : [];
8903
9264
  if (localEditorState.value.status === "running") {
8904
9265
  liveForwardPorts.delete(localEditorState.value.port);
8905
9266
  managedForwardPorts.delete(localEditorState.value.port);
@@ -8919,10 +9280,14 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8919
9280
  instanceId,
8920
9281
  requestId: control.requestId,
8921
9282
  ok: true,
8922
- capabilities: capabilitiesPayload(),
9283
+ capabilities: {
9284
+ ...capabilitiesPayload(),
9285
+ ...restartedEditorRevokedPorts.length === 0 ? {} : { revokedPorts: restartedEditorRevokedPorts }
9286
+ },
8923
9287
  ...result.event
8924
9288
  });
8925
9289
  if (result.state.status === "running") {
9290
+ startLocalEditorPortForwardWatcher(result.state);
8926
9291
  watchLocalEditorExit(result.state, editorGeneration, initialStatusPushed);
8927
9292
  }
8928
9293
  return initialStatusPushed;
@@ -8955,6 +9320,17 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8955
9320
  pushHeartbeat();
8956
9321
  return;
8957
9322
  }
9323
+ if (isResolvePortForwardRequestControl(control)) {
9324
+ const response = resolveEditorPortForwardRequest(control);
9325
+ if (response !== undefined) {
9326
+ kandan.push(topic, "codex_response", response).catch((error) => {
9327
+ log("kandan.control_response_push_failed", {
9328
+ message: error instanceof Error ? error.message : String(error)
9329
+ });
9330
+ });
9331
+ return;
9332
+ }
9333
+ }
8958
9334
  if (isSetPortForwardEnabledControl(control)) {
8959
9335
  switch (control.enabled) {
8960
9336
  case true:
@@ -8964,6 +9340,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8964
9340
  liveForwardPorts.delete(control.port);
8965
9341
  managedForwardPorts.delete(control.port);
8966
9342
  clearForwardPortAttribution(control.port);
9343
+ localEditorForwardState.approvedPorts.delete(control.port);
9344
+ localEditorForwardState.approvedTargets.delete(control.port);
8967
9345
  kandan.push(topic, "forward_port_revoked", {
8968
9346
  instanceId,
8969
9347
  port: control.port,
@@ -9155,6 +9533,24 @@ function codexTuiArgs(codexUrl, codexThreadId, session, fast) {
9155
9533
  const overrides = codexTuiConfigArgs(session, fast);
9156
9534
  return codexThreadId === undefined ? ["--remote", codexUrl, ...overrides] : ["resume", "--remote", codexUrl, ...overrides, codexThreadId];
9157
9535
  }
9536
+ function suppressedForwardPortsForRunner(kandanControlPort, managedForwardPorts) {
9537
+ const suppressedPorts = new Set(managedForwardPorts);
9538
+ if (kandanControlPort !== undefined) {
9539
+ suppressedPorts.add(kandanControlPort);
9540
+ }
9541
+ return Array.from(suppressedPorts).sort((left, right) => left - right);
9542
+ }
9543
+ function explicitUrlPort(url) {
9544
+ const parsed = new URL(url);
9545
+ if (parsed.port === "") {
9546
+ return;
9547
+ }
9548
+ const port = Number.parseInt(parsed.port, 10);
9549
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
9550
+ throw new Error(`invalid Kandan URL port: ${url}`);
9551
+ }
9552
+ return port;
9553
+ }
9158
9554
  function codexTuiConfigArgs(session, fast) {
9159
9555
  const modelArgs = session?.model === undefined ? [] : ["--model", session.model];
9160
9556
  const reasoningArgs = session?.reasoningEffort === undefined ? [] : ["-c", `model_reasoning_effort="${session.reasoningEffort}"`];
@@ -9571,6 +9967,9 @@ function isUpdateRunnerConfigControl(control) {
9571
9967
  function isSetPortForwardEnabledControl(control) {
9572
9968
  return control.type === "set_port_forward_enabled" && Number.isInteger(control.port) && control.port > 0 && control.port <= 65535;
9573
9969
  }
9970
+ function isResolvePortForwardRequestControl(control) {
9971
+ return control.type === "resolve_port_forward_request" && typeof control.requestId === "string" && (control.decision === "approve" || control.decision === "deny");
9972
+ }
9574
9973
  function normalizeAllowedCwds(values) {
9575
9974
  return Array.from(new Set(values.flatMap((value) => {
9576
9975
  const normalized = value.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "0.0.42-beta",
3
+ "version": "0.0.44-beta",
4
4
  "description": "Linzumi CLI — 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": {