@linzumi/cli 0.0.4-beta → 0.0.6-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.
@@ -0,0 +1,109 @@
1
+ /*
2
+ - Date: 2026-04-26
3
+ Spec: kandan/server_v2/plans/2026-04-26-local-codex-driver-worldclass-spec.md
4
+ Relationship: Provides the local Codex channel session with an amortized O(1)
5
+ pending Kandan message queue so long shared sessions avoid repeated
6
+ `Array.shift()` reindexing while preserving interrupt fusion order.
7
+ */
8
+ import {
9
+ interruptQueuedMessages,
10
+ type QueueInterruptResult,
11
+ type QueuedKandanMessage,
12
+ } from "./kandanQueue";
13
+
14
+ export type PendingKandanMessageQueue = {
15
+ items: QueuedKandanMessage[];
16
+ head: number;
17
+ };
18
+
19
+ export function createPendingKandanMessageQueue(): PendingKandanMessageQueue {
20
+ return {
21
+ items: [],
22
+ head: 0,
23
+ };
24
+ }
25
+
26
+ export function pendingKandanMessageQueueLength(
27
+ queue: PendingKandanMessageQueue,
28
+ ): number {
29
+ return queue.items.length - queue.head;
30
+ }
31
+
32
+ export function enqueuePendingKandanMessage(
33
+ queue: PendingKandanMessageQueue,
34
+ message: QueuedKandanMessage,
35
+ ): void {
36
+ queue.items.push(message);
37
+ }
38
+
39
+ export function dequeuePendingKandanMessage(
40
+ queue: PendingKandanMessageQueue,
41
+ ): QueuedKandanMessage | undefined {
42
+ const message = queue.items[queue.head];
43
+
44
+ if (message === undefined) {
45
+ compactPendingKandanMessageQueue(queue);
46
+ return undefined;
47
+ }
48
+
49
+ queue.head = queue.head + 1;
50
+ compactPendingKandanMessageQueue(queue);
51
+ return message;
52
+ }
53
+
54
+ export function requeuePendingKandanMessageFront(
55
+ queue: PendingKandanMessageQueue,
56
+ message: QueuedKandanMessage,
57
+ ): void {
58
+ if (queue.head > 0) {
59
+ queue.head = queue.head - 1;
60
+ queue.items[queue.head] = message;
61
+ return;
62
+ }
63
+
64
+ queue.items.unshift(message);
65
+ }
66
+
67
+ export function interruptPendingKandanMessages(
68
+ queue: PendingKandanMessageQueue,
69
+ throughSeq: number | undefined,
70
+ ): QueueInterruptResult {
71
+ const result = interruptQueuedMessages(pendingKandanMessages(queue), throughSeq);
72
+
73
+ if (result.ok) {
74
+ replacePendingKandanMessages(queue, result.queue);
75
+ }
76
+
77
+ return result;
78
+ }
79
+
80
+ function replacePendingKandanMessages(
81
+ queue: PendingKandanMessageQueue,
82
+ messages: QueuedKandanMessage[],
83
+ ): void {
84
+ queue.items = messages;
85
+ queue.head = 0;
86
+ }
87
+
88
+ function pendingKandanMessages(
89
+ queue: PendingKandanMessageQueue,
90
+ ): QueuedKandanMessage[] {
91
+ return queue.head === 0 ? queue.items : queue.items.slice(queue.head);
92
+ }
93
+
94
+ function compactPendingKandanMessageQueue(queue: PendingKandanMessageQueue): void {
95
+ if (queue.head === 0) {
96
+ return;
97
+ }
98
+
99
+ if (queue.head >= queue.items.length) {
100
+ queue.items = [];
101
+ queue.head = 0;
102
+ return;
103
+ }
104
+
105
+ if (queue.head > queue.items.length / 2) {
106
+ queue.items = queue.items.slice(queue.head);
107
+ queue.head = 0;
108
+ }
109
+ }
package/src/phoenix.ts CHANGED
@@ -13,6 +13,11 @@
13
13
  Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
14
14
  Relationship: Treats the Kandan websocket as a reconnectable transport so
15
15
  server restarts do not terminate the durable local Codex session.
16
+
17
+ - Date: 2026-04-26
18
+ Spec: plans/2026_04_26_linzumi_cli_review_followups_note.md
19
+ Relationship: Treats non-ok Phoenix replies for runner pushes as transport
20
+ failures so failed Kandan writes cannot be mistaken for synchronized state.
16
21
  */
17
22
  import {
18
23
  type JsonObject,
@@ -23,6 +28,7 @@ import {
23
28
  } from "./protocol";
24
29
 
25
30
  type PendingPush = {
31
+ readonly event: string;
26
32
  readonly resolve: (payload: JsonValue) => void;
27
33
  readonly reject: (error: Error) => void;
28
34
  };
@@ -48,6 +54,14 @@ export type PhoenixClient = {
48
54
 
49
55
  export function phoenixWebsocketUrl(baseUrl: string, token: string): string {
50
56
  const parsed = new URL(baseUrl);
57
+ switch (parsed.protocol) {
58
+ case "http:":
59
+ parsed.protocol = "ws:";
60
+ break;
61
+ case "https:":
62
+ parsed.protocol = "wss:";
63
+ break;
64
+ }
51
65
  parsed.pathname = parsed.pathname.replace(/\/$/, "") + "/socket/websocket";
52
66
  parsed.searchParams.set("token", token);
53
67
  parsed.searchParams.set("vsn", "2.0.0");
@@ -112,6 +126,8 @@ export async function connectPhoenixClient(
112
126
 
113
127
  if (name === "phx_error") {
114
128
  pendingPush.reject(new Error("phoenix push failed"));
129
+ } else if (isNonOkPushReply(payload) && pendingPush.event !== "phx_join") {
130
+ pendingPush.reject(new Error(`phoenix push failed: ${replyErrorMessage(payload)}`));
115
131
  } else {
116
132
  pendingPush.resolve(payload);
117
133
  }
@@ -143,7 +159,7 @@ export async function connectPhoenixClient(
143
159
  const frame: PhoenixFrame = [null, ref, topic, event, payload];
144
160
 
145
161
  return new Promise((resolve, reject) => {
146
- pending.set(ref, { resolve, reject });
162
+ pending.set(ref, { event, resolve, reject });
147
163
  websocket.send(JSON.stringify(frame));
148
164
  });
149
165
  };
@@ -304,6 +320,10 @@ function isJoinReply(value: JsonValue): value is {
304
320
  }
305
321
 
306
322
  function joinErrorMessage(value: JsonValue): string {
323
+ return replyErrorMessage(value);
324
+ }
325
+
326
+ function replyErrorMessage(value: JsonValue): string {
307
327
  if (!isJsonObject(value)) {
308
328
  return "invalid reply";
309
329
  }
@@ -321,6 +341,10 @@ function joinErrorMessage(value: JsonValue): string {
321
341
  return "unknown";
322
342
  }
323
343
 
344
+ function isNonOkPushReply(value: JsonValue): boolean {
345
+ return isJsonObject(value) && value.status !== "ok";
346
+ }
347
+
324
348
  function isKandanControl(value: JsonValue): value is KandanControl {
325
349
  return isJsonObject(value) && typeof value.type === "string";
326
350
  }
@@ -0,0 +1,181 @@
1
+ /*
2
+ - Date: 2026-04-27
3
+ Spec: plans/2026-04-26-local-runner-subdomain-forwarding-epr.md
4
+ Relationship: Keeps local runner port-forward approval and revocation policy
5
+ pure so security decisions are tested directly instead of hiding inside the
6
+ channel-session orchestration module.
7
+ */
8
+ import { type JsonObject } from "./protocol";
9
+ import {
10
+ commandLabel,
11
+ sameForwardCandidate,
12
+ type PortForwardCandidate,
13
+ } from "./portForwardWatcher";
14
+
15
+ export type PendingPortForwardRequest = {
16
+ readonly requestId: string;
17
+ readonly port: number;
18
+ readonly pid: number;
19
+ readonly command: string;
20
+ readonly cwd?: string | undefined;
21
+ readonly sourceSeq: number;
22
+ };
23
+
24
+ export type PortForwardCandidateReview =
25
+ | {
26
+ readonly type: "skip";
27
+ readonly reason:
28
+ | "thread_not_bound"
29
+ | "suppressed_port"
30
+ | "already_pending"
31
+ | "already_approved"
32
+ | "recently_dismissed";
33
+ }
34
+ | { readonly type: "remember_approved_target"; readonly target: PortForwardCandidate }
35
+ | {
36
+ readonly type: "revoke_and_prompt";
37
+ readonly revoked: PortForwardCandidate;
38
+ readonly candidate: PortForwardCandidate;
39
+ readonly reason: "listener_changed";
40
+ }
41
+ | { readonly type: "prompt"; readonly candidate: PortForwardCandidate };
42
+
43
+ export function reviewPortForwardCandidate(options: {
44
+ readonly candidate: PortForwardCandidate;
45
+ readonly threadBound: boolean;
46
+ readonly suppressedPorts?: ReadonlySet<number> | undefined;
47
+ readonly approvedPorts: ReadonlySet<number>;
48
+ readonly approvedTargets: ReadonlyMap<number, PortForwardCandidate>;
49
+ readonly dismissedTargets?: ReadonlyMap<number, PortForwardCandidate> | undefined;
50
+ readonly pendingRequests: readonly PendingPortForwardRequest[];
51
+ }): PortForwardCandidateReview {
52
+ if (!options.threadBound) {
53
+ return { type: "skip", reason: "thread_not_bound" };
54
+ }
55
+
56
+ if (options.suppressedPorts?.has(options.candidate.port) === true) {
57
+ return { type: "skip", reason: "suppressed_port" };
58
+ }
59
+
60
+ const dismissedTarget = options.dismissedTargets?.get(options.candidate.port);
61
+
62
+ if (
63
+ dismissedTarget !== undefined &&
64
+ sameDismissedPortForwardTarget(dismissedTarget, options.candidate)
65
+ ) {
66
+ return { type: "skip", reason: "recently_dismissed" };
67
+ }
68
+
69
+ if (options.approvedPorts.has(options.candidate.port)) {
70
+ const approvedTarget = options.approvedTargets.get(options.candidate.port);
71
+
72
+ if (approvedTarget === undefined) {
73
+ return { type: "remember_approved_target", target: options.candidate };
74
+ }
75
+
76
+ if (sameForwardCandidate(approvedTarget, options.candidate)) {
77
+ return { type: "skip", reason: "already_approved" };
78
+ }
79
+
80
+ return {
81
+ type: "revoke_and_prompt",
82
+ revoked: approvedTarget,
83
+ candidate: options.candidate,
84
+ reason: "listener_changed",
85
+ };
86
+ }
87
+
88
+ if (options.pendingRequests.some(request => request.port === options.candidate.port)) {
89
+ return { type: "skip", reason: "already_pending" };
90
+ }
91
+
92
+ return { type: "prompt", candidate: options.candidate };
93
+ }
94
+
95
+ export function pendingRequestFromCandidate(options: {
96
+ readonly requestId: string;
97
+ readonly sourceSeq: number;
98
+ readonly candidate: PortForwardCandidate;
99
+ }): PendingPortForwardRequest {
100
+ return {
101
+ requestId: options.requestId,
102
+ port: options.candidate.port,
103
+ pid: options.candidate.pid,
104
+ command: options.candidate.command,
105
+ ...(options.candidate.cwd === undefined ? {} : { cwd: options.candidate.cwd }),
106
+ sourceSeq: options.sourceSeq,
107
+ };
108
+ }
109
+
110
+ export function approvedTargetFromRequest(
111
+ request: PendingPortForwardRequest,
112
+ ): PortForwardCandidate {
113
+ return {
114
+ port: request.port,
115
+ pid: request.pid,
116
+ command: request.command,
117
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
118
+ };
119
+ }
120
+
121
+ export function portForwardPromptLabel(candidate: PortForwardCandidate): string {
122
+ return commandLabel(candidate.command);
123
+ }
124
+
125
+ export function portForwardPromptBody(
126
+ candidate: PortForwardCandidate,
127
+ requestId: string,
128
+ ): string {
129
+ const label = portForwardPromptLabel(candidate);
130
+ const cwdLine = candidate.cwd === undefined ? undefined : `Working directory: ${candidate.cwd}`;
131
+
132
+ return [
133
+ `Detected ${label} listening from a descendant process.`,
134
+ `Port: ${candidate.port}`,
135
+ `PID: ${candidate.pid}`,
136
+ `Command: ${candidate.command}`,
137
+ ...(cwdLine === undefined ? [] : [cwdLine]),
138
+ "Kandan can open this as an authenticated HTTP, HTTPS, or WebSocket preview. It does not expose arbitrary TCP or UDP protocols.",
139
+ "Open this preview in Kandan?",
140
+ "",
141
+ `Fallback commands: ${portForwardDecisionCommand("approve", requestId)} or ${portForwardDecisionCommand("deny", requestId)}`,
142
+ ].join("\n");
143
+ }
144
+
145
+ export function portForwardPromptReason(candidate: PortForwardCandidate): string {
146
+ return [
147
+ `Port ${candidate.port}`,
148
+ `PID ${candidate.pid}`,
149
+ `command: ${candidate.command}`,
150
+ ...(candidate.cwd === undefined ? [] : [`cwd: ${candidate.cwd}`]),
151
+ "preview protocols: HTTP, HTTPS, WebSocket",
152
+ ].join(" / ");
153
+ }
154
+
155
+ export function portForwardDecisionCommand(
156
+ decision: "approve" | "deny",
157
+ requestId: string,
158
+ ): string {
159
+ return `/kandan ${decision}-port-forward ${requestId}`;
160
+ }
161
+
162
+ export function forwardPreviewPath(runnerId: string, port: number): string {
163
+ return `/local-codex-runners/${encodeURIComponent(runnerId)}/forwards/${port}/preview`;
164
+ }
165
+
166
+ export function revocationCapabilities(
167
+ capabilities: JsonObject | undefined,
168
+ port: number,
169
+ ): JsonObject {
170
+ return {
171
+ ...(capabilities ?? {}),
172
+ revokedPorts: [port],
173
+ };
174
+ }
175
+
176
+ function sameDismissedPortForwardTarget(
177
+ left: PortForwardCandidate,
178
+ right: PortForwardCandidate,
179
+ ): boolean {
180
+ return left.port === right.port && left.command === right.command && left.cwd === right.cwd;
181
+ }