@linzumi/cli 0.0.43-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 +370 -21
  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.43-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
@@ -2671,6 +2671,7 @@ function initialChannelSessionState(cursor, rootSeq, kandanThreadId, codexThread
2671
2671
  pendingApprovalRequests: new Map,
2672
2672
  approvalPromptChain: Promise.resolve(),
2673
2673
  pendingPortForwardRequests: new Map,
2674
+ portForwardPreviousProcessingStates: new Map,
2674
2675
  queuedPortForwardCandidates: new Map,
2675
2676
  approvedForwardPorts: new Set,
2676
2677
  approvedForwardTargets: new Map,
@@ -2995,6 +2996,9 @@ async function resolvePendingPortForwardRequest(args, state, payloadContext, con
2995
2996
  state.pendingPortForwardRequests.delete(control.requestId);
2996
2997
  if (control.decision === "deny") {
2997
2998
  state.dismissedForwardTargets.set(request.port, approvedTargetFromRequest(request));
2999
+ await publishForwardPortResolvedEvent(args, request, {
3000
+ decision: "deny"
3001
+ });
2998
3002
  await publishMessageStateForPortForwardResult(args, state, request, "failed");
2999
3003
  args.log("port_forward.request_denied", {
3000
3004
  request_id: control.requestId,
@@ -3014,7 +3018,10 @@ async function resolvePendingPortForwardRequest(args, state, payloadContext, con
3014
3018
  processName: processIdentity?.appName ?? null,
3015
3019
  processIconKey: processIdentity?.iconKey ?? null
3016
3020
  });
3017
- await publishForwardPortResolvedEvent(args, request, capabilities);
3021
+ await publishForwardPortResolvedEvent(args, request, {
3022
+ decision: "approve",
3023
+ capabilities
3024
+ });
3018
3025
  await publishMessageStateForPortForwardResult(args, state, request, "processed");
3019
3026
  await publishPortForwardReadyMessage(args, state, payloadContext, request);
3020
3027
  args.log("port_forward.request_approved", {
@@ -3099,12 +3106,14 @@ async function publishPortForwardPrompt(args, state, payloadContext, candidate)
3099
3106
  candidate
3100
3107
  });
3101
3108
  state.pendingPortForwardRequests.set(requestId, request);
3109
+ if (state.activeProcessingState !== undefined) {
3110
+ state.portForwardPreviousProcessingStates.set(requestId, state.activeProcessingState);
3111
+ }
3102
3112
  const processIdentity = guessCanonicalProcessFromCommand(candidate.command);
3103
3113
  const processIconPath = processIdentity?.iconKey === undefined ? undefined : `/web/process-icons/${processIdentity.iconKey}.png`;
3104
3114
  const processName = processIdentity?.appName ?? label;
3105
- await publishForwardPortRequestedEvent(args, request);
3106
- await publishMessageState(args, state.kandanThreadId, sourceSeq, {
3107
- status: "processing",
3115
+ state.activeProcessingState = {
3116
+ seq: sourceSeq,
3108
3117
  reason: "awaiting approval",
3109
3118
  approval: {
3110
3119
  requestId,
@@ -3133,7 +3142,13 @@ async function publishPortForwardPrompt(args, state, payloadContext, candidate)
3133
3142
  allowedActorSlug: args.options.channelSession.listenUser,
3134
3143
  allowedActorUserId: args.options.channelSession.listenUser.toLowerCase() === (payloadContext.runnerIdentity.actorUsername ?? "").toLowerCase() ? payloadContext.runnerIdentity.actorUserId : undefined
3135
3144
  }
3145
+ };
3146
+ await publishMessageState(args, state.kandanThreadId, sourceSeq, {
3147
+ status: "processing",
3148
+ reason: "awaiting approval",
3149
+ approval: state.activeProcessingState.approval
3136
3150
  });
3151
+ await publishForwardPortRequestedEvent(args, state, request, processIdentity);
3137
3152
  args.log("port_forward.request_pending", {
3138
3153
  request_id: requestId,
3139
3154
  port: candidate.port,
@@ -3174,6 +3189,10 @@ async function expireLostPendingPortForwardRequest(args, state, payloadContext,
3174
3189
  return;
3175
3190
  }
3176
3191
  state.pendingPortForwardRequests.delete(request.requestId);
3192
+ await publishForwardPortResolvedEvent(args, request, {
3193
+ decision: "expired",
3194
+ reason: "listener_exited"
3195
+ });
3177
3196
  await publishMessageStateForPortForwardResult(args, state, request, "failed", "port_forward_listener_exited");
3178
3197
  args.log("port_forward.pending_request_expired", {
3179
3198
  request_id: request.requestId,
@@ -3220,17 +3239,23 @@ async function publishPortForwardReadyMessage(args, state, payloadContext, reque
3220
3239
  }
3221
3240
  }, args.log);
3222
3241
  }
3223
- async function publishForwardPortRequestedEvent(args, request) {
3242
+ async function publishForwardPortRequestedEvent(args, state, request, processIdentity) {
3224
3243
  await pushOptional(args.kandan, args.topic, "forward_port_requested", {
3225
3244
  instanceId: args.instanceId,
3226
3245
  requestId: request.requestId,
3246
+ sourceSeq: request.sourceSeq,
3227
3247
  port: request.port,
3228
3248
  pid: request.pid,
3229
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 },
3230
3255
  ...request.cwd === undefined ? {} : { cwd: request.cwd }
3231
3256
  }, args.log);
3232
3257
  }
3233
- async function publishForwardPortResolvedEvent(args, request, capabilities) {
3258
+ async function publishForwardPortResolvedEvent(args, request, result) {
3234
3259
  await pushOptional(args.kandan, args.topic, "forward_port_resolved", {
3235
3260
  instanceId: args.instanceId,
3236
3261
  requestId: request.requestId,
@@ -3238,18 +3263,52 @@ async function publishForwardPortResolvedEvent(args, request, capabilities) {
3238
3263
  pid: request.pid,
3239
3264
  command: request.command,
3240
3265
  ...request.cwd === undefined ? {} : { cwd: request.cwd },
3241
- decision: "approve",
3242
- ...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 }
3243
3270
  }, args.log);
3244
3271
  }
3245
3272
  async function publishMessageStateForPortForwardResult(args, state, request, status, failedReason = "port_forward_denied") {
3246
3273
  if (state.kandanThreadId === undefined) {
3247
3274
  return;
3248
3275
  }
3276
+ const previousProcessingState = state.portForwardPreviousProcessingStates.get(request.requestId);
3277
+ state.portForwardPreviousProcessingStates.delete(request.requestId);
3249
3278
  const activeProcessingState = state.activeProcessingState;
3250
3279
  if (activeProcessingState !== undefined && activeProcessingState.seq === request.sourceSeq) {
3251
- await publishMessageState(args, state.kandanThreadId, request.sourceSeq, processingMessageStateFromActive(activeProcessingState), undefined, undefined, state.codexThreadId);
3252
- 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
+ }
3253
3312
  }
3254
3313
  switch (status) {
3255
3314
  case "processed":
@@ -3419,7 +3478,7 @@ function kandanMessageClaimKey(args, threadId, seq) {
3419
3478
  function claimKandanMessage(args, state, event) {
3420
3479
  const threadId = event.threadId;
3421
3480
  if (threadId === undefined) {
3422
- return false;
3481
+ return true;
3423
3482
  }
3424
3483
  const key = kandanMessageClaimKey(args, threadId, event.seq);
3425
3484
  if (claimedKandanMessageKeys.has(key)) {
@@ -3428,8 +3487,9 @@ function claimKandanMessage(args, state, event) {
3428
3487
  claimedKandanMessageKeys.add(key);
3429
3488
  state.claimedKandanMessageKeys.add(key);
3430
3489
  if (state.claimedKandanMessageKeys.size > maxClaimedKandanMessageKeys) {
3431
- const [expiredKey] = state.claimedKandanMessageKeys;
3432
- if (expiredKey !== undefined) {
3490
+ const overflow = state.claimedKandanMessageKeys.size - maxClaimedKandanMessageKeys;
3491
+ const expiredKeys = Array.from(state.claimedKandanMessageKeys).slice(0, overflow);
3492
+ for (const expiredKey of expiredKeys) {
3433
3493
  state.claimedKandanMessageKeys.delete(expiredKey);
3434
3494
  claimedKandanMessageKeys.delete(expiredKey);
3435
3495
  }
@@ -3604,7 +3664,16 @@ async function drainKandanMessageQueue(args, state, payloadContext) {
3604
3664
  new_codex_thread_id: newCodexThreadId
3605
3665
  });
3606
3666
  await postCodexThreadReboundMessage(args, state, payloadContext, oldCodexThreadId, newCodexThreadId);
3607
- 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
+ }
3608
3677
  requeuePendingKandanMessageFront(state.queue, next);
3609
3678
  state.turn = { status: "idle" };
3610
3679
  await drainKandanMessageQueue(args, state, payloadContext);
@@ -4963,6 +5032,9 @@ async function refreshActiveProcessingState(args, state, turnId, reason) {
4963
5032
  if (state.activeProcessingState?.seq === seq && state.activeProcessingState.reason === reason) {
4964
5033
  return;
4965
5034
  }
5035
+ if (state.activeProcessingState?.seq === seq && state.activeProcessingState.reason === "awaiting approval") {
5036
+ return;
5037
+ }
4966
5038
  state.activeProcessingState = { seq, reason };
4967
5039
  await publishMessageState(args, state.kandanThreadId, seq, {
4968
5040
  status: "processing",
@@ -4974,6 +5046,14 @@ async function refreshActiveProcessingHeartbeat(args, state) {
4974
5046
  if (activeProcessingState === undefined || state.kandanThreadId === undefined) {
4975
5047
  return;
4976
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
+ }
4977
5057
  await publishMessageState(args, state.kandanThreadId, activeProcessingState.seq, processingMessageStateFromActive(activeProcessingState), undefined, undefined, state.codexThreadId);
4978
5058
  }
4979
5059
  function clearActiveProcessingState(state, seq) {
@@ -8183,7 +8263,7 @@ function realpathOrResolved(pathValue) {
8183
8263
  }
8184
8264
 
8185
8265
  // src/version.ts
8186
- var linzumiCliVersion = "0.0.43-beta";
8266
+ var linzumiCliVersion = "0.0.44-beta";
8187
8267
  var linzumiCliVersionText = `linzumi ${linzumiCliVersion}`;
8188
8268
 
8189
8269
  // src/runnerLock.ts
@@ -8552,6 +8632,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8552
8632
  const allowedForwardPorts = options.allowedForwardPorts ?? [];
8553
8633
  const liveForwardPorts = new Set(allowedForwardPorts);
8554
8634
  const managedForwardPorts = new Set;
8635
+ const kandanControlPort = explicitUrlPort(options.kandanUrl);
8636
+ const suppressedForwardPorts = () => suppressedForwardPortsForRunner(kandanControlPort, managedForwardPorts);
8555
8637
  const forwardPortAttributions = new Map;
8556
8638
  const setForwardPortAttribution = (port, attribution) => {
8557
8639
  forwardPortAttributions.set(port, {
@@ -8559,7 +8641,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8559
8641
  codexThreadId: attribution.codexThreadId ?? null,
8560
8642
  channelSlug: attribution.channelSlug ?? null,
8561
8643
  processName: attribution.processName ?? null,
8562
- processIconKey: attribution.processIconKey ?? null
8644
+ processIconKey: attribution.processIconKey ?? null,
8645
+ localEditor: attribution.localEditor === true
8563
8646
  });
8564
8647
  };
8565
8648
  const clearForwardPortAttribution = (port) => {
@@ -8573,7 +8656,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8573
8656
  codexThreadId: attribution?.codexThreadId ?? null,
8574
8657
  channelSlug: attribution?.channelSlug ?? null,
8575
8658
  processName: attribution?.processName ?? null,
8576
- processIconKey: attribution?.processIconKey ?? null
8659
+ processIconKey: attribution?.processIconKey ?? null,
8660
+ localEditor: attribution?.localEditor === true
8577
8661
  };
8578
8662
  });
8579
8663
  const allowedCwds = { value: [...options.allowedCwds] };
@@ -8584,11 +8668,35 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8584
8668
  value: { status: "disabled" }
8585
8669
  };
8586
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
+ };
8587
8679
  cleanup.actions.push(() => {
8588
8680
  if (localEditorState.value.status === "running") {
8589
8681
  localEditorState.value.process.kill("SIGINT");
8590
8682
  localEditorState.value.collaboration?.process.kill("SIGINT");
8591
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
+ };
8592
8700
  });
8593
8701
  const capabilitiesPayload = () => ({
8594
8702
  codexAppServer: true,
@@ -8600,6 +8708,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8600
8708
  portForwarding: liveForwardPorts.size > 0,
8601
8709
  allowedPorts: Array.from(liveForwardPorts).sort((left, right) => left - right),
8602
8710
  forwardedPortAttributions: buildForwardPortAttributionPayload(),
8711
+ pendingEditorForwardPortRequests: pendingEditorForwardPortRequests(),
8603
8712
  toolStatus: options.dependencyStatus === undefined ? null : dependencyStatusPayload(options.dependencyStatus),
8604
8713
  editorRuntime: options.dependencyStatus?.editorRuntime === undefined ? null : dependencyStatusPayload(options.dependencyStatus).editorRuntime,
8605
8714
  ...localEditorCapabilities(options.editorRuntime, allowedCwds.value, localEditorState.value)
@@ -8611,6 +8720,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8611
8720
  const joinPayload = () => ({
8612
8721
  clientName: "kandan-local-codex-runner",
8613
8722
  clientId,
8723
+ runnerPid: process.pid,
8614
8724
  version: linzumiCliVersion,
8615
8725
  workspace: runnerWorkspaceSlug(options) ?? null,
8616
8726
  channel: options.channelSession?.channelSlug ?? null,
@@ -8648,12 +8758,204 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8648
8758
  });
8649
8759
  });
8650
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
+ };
8651
8952
  const watchLocalEditorExit = (state, generation, initialStatusPushed) => {
8652
8953
  const handleExit = () => {
8653
8954
  if (localEditorGeneration.value !== generation || localEditorState.value.status !== "running" || localEditorState.value.process !== state.process) {
8654
8955
  return;
8655
8956
  }
8656
8957
  localEditorState.value = { status: "disabled" };
8958
+ const revokedEditorPorts = clearEditorPortForwarding("editor_exited");
8657
8959
  liveForwardPorts.delete(state.port);
8658
8960
  managedForwardPorts.delete(state.port);
8659
8961
  if (state.collaboration !== undefined) {
@@ -8666,7 +8968,11 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8666
8968
  cwd: state.cwd,
8667
8969
  capabilities: {
8668
8970
  ...capabilitiesPayload(),
8669
- 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
+ ]
8670
8976
  }
8671
8977
  });
8672
8978
  };
@@ -8724,7 +9030,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8724
9030
  launchTui: options.launchTui,
8725
9031
  enablePortForwardWatch: true,
8726
9032
  initialForwardPorts: allowedForwardPorts,
8727
- suppressedForwardPorts: () => Array.from(managedForwardPorts),
9033
+ suppressedForwardPorts,
8728
9034
  onForwardPortApproved: (port, attribution) => {
8729
9035
  liveForwardPorts.add(port);
8730
9036
  setForwardPortAttribution(port, attribution);
@@ -8779,7 +9085,7 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8779
9085
  launchTui: false,
8780
9086
  enablePortForwardWatch: true,
8781
9087
  initialForwardPorts: allowedForwardPorts,
8782
- suppressedForwardPorts: () => Array.from(managedForwardPorts),
9088
+ suppressedForwardPorts,
8783
9089
  onForwardPortApproved: (port, attribution) => {
8784
9090
  liveForwardPorts.add(port);
8785
9091
  setForwardPortAttribution(port, attribution);
@@ -8950,6 +9256,11 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8950
9256
  if (result.ok) {
8951
9257
  localEditorGeneration.value += 1;
8952
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
+ ] : [];
8953
9264
  if (localEditorState.value.status === "running") {
8954
9265
  liveForwardPorts.delete(localEditorState.value.port);
8955
9266
  managedForwardPorts.delete(localEditorState.value.port);
@@ -8969,10 +9280,14 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
8969
9280
  instanceId,
8970
9281
  requestId: control.requestId,
8971
9282
  ok: true,
8972
- capabilities: capabilitiesPayload(),
9283
+ capabilities: {
9284
+ ...capabilitiesPayload(),
9285
+ ...restartedEditorRevokedPorts.length === 0 ? {} : { revokedPorts: restartedEditorRevokedPorts }
9286
+ },
8973
9287
  ...result.event
8974
9288
  });
8975
9289
  if (result.state.status === "running") {
9290
+ startLocalEditorPortForwardWatcher(result.state);
8976
9291
  watchLocalEditorExit(result.state, editorGeneration, initialStatusPushed);
8977
9292
  }
8978
9293
  return initialStatusPushed;
@@ -9005,6 +9320,17 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
9005
9320
  pushHeartbeat();
9006
9321
  return;
9007
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
+ }
9008
9334
  if (isSetPortForwardEnabledControl(control)) {
9009
9335
  switch (control.enabled) {
9010
9336
  case true:
@@ -9014,6 +9340,8 @@ async function openLocalCodexRunner(options, log, cleanup, close) {
9014
9340
  liveForwardPorts.delete(control.port);
9015
9341
  managedForwardPorts.delete(control.port);
9016
9342
  clearForwardPortAttribution(control.port);
9343
+ localEditorForwardState.approvedPorts.delete(control.port);
9344
+ localEditorForwardState.approvedTargets.delete(control.port);
9017
9345
  kandan.push(topic, "forward_port_revoked", {
9018
9346
  instanceId,
9019
9347
  port: control.port,
@@ -9205,6 +9533,24 @@ function codexTuiArgs(codexUrl, codexThreadId, session, fast) {
9205
9533
  const overrides = codexTuiConfigArgs(session, fast);
9206
9534
  return codexThreadId === undefined ? ["--remote", codexUrl, ...overrides] : ["resume", "--remote", codexUrl, ...overrides, codexThreadId];
9207
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
+ }
9208
9554
  function codexTuiConfigArgs(session, fast) {
9209
9555
  const modelArgs = session?.model === undefined ? [] : ["--model", session.model];
9210
9556
  const reasoningArgs = session?.reasoningEffort === undefined ? [] : ["-c", `model_reasoning_effort="${session.reasoningEffort}"`];
@@ -9621,6 +9967,9 @@ function isUpdateRunnerConfigControl(control) {
9621
9967
  function isSetPortForwardEnabledControl(control) {
9622
9968
  return control.type === "set_port_forward_enabled" && Number.isInteger(control.port) && control.port > 0 && control.port <= 65535;
9623
9969
  }
9970
+ function isResolvePortForwardRequestControl(control) {
9971
+ return control.type === "resolve_port_forward_request" && typeof control.requestId === "string" && (control.decision === "approve" || control.decision === "deny");
9972
+ }
9624
9973
  function normalizeAllowedCwds(values) {
9625
9974
  return Array.from(new Set(values.flatMap((value) => {
9626
9975
  const normalized = value.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "0.0.43-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": {