@linzumi/cli 0.0.1-beta → 0.0.2-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.
- package/README.md +78 -94
- package/bin/linzumi.js +16 -8
- package/package.json +4 -2
- package/src/authCache.ts +157 -0
- package/src/authResolution.ts +75 -0
- package/src/channelSession.ts +3248 -0
- package/src/channelSessionSupport.ts +255 -0
- package/src/codexAppServer.ts +380 -0
- package/src/codexOutput.ts +846 -0
- package/src/index.ts +354 -0
- package/src/json.ts +49 -0
- package/src/kandanQueue.ts +102 -0
- package/src/oauth.ts +294 -0
- package/src/phoenix.ts +335 -0
- package/src/protocol.ts +211 -0
- package/src/runner.ts +524 -0
- package/src/runnerConsoleReporter.ts +142 -0
- package/src/runnerLogger.ts +50 -0
- package/src/cli.js +0 -240
package/src/index.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-24
|
|
3
|
+
Spec: plans/2026-04-24-local-codex-runner-plan.md
|
|
4
|
+
Relationship: Provides the spec's initial Bun CLI entry point for starting
|
|
5
|
+
a customer-machine local runner with Kandan and Codex connection options.
|
|
6
|
+
|
|
7
|
+
- Date: 2026-04-24
|
|
8
|
+
Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
|
|
9
|
+
Relationship: Parses the channel-bound runner options that let a local Codex
|
|
10
|
+
process bind to one Kandan channel/thread and filter accepted senders.
|
|
11
|
+
|
|
12
|
+
- Date: 2026-04-24
|
|
13
|
+
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
14
|
+
Relationship: Keeps default local runner identity collision-resistant for
|
|
15
|
+
concurrent local starts.
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { runLocalCodexRunner, type RunnerOptions } from "./runner";
|
|
19
|
+
import { writeCachedLocalRunnerToken } from "./authCache";
|
|
20
|
+
import { resolveLocalRunnerToken } from "./authResolution";
|
|
21
|
+
import { acquireLocalRunnerTokenDetails } from "./oauth";
|
|
22
|
+
|
|
23
|
+
type FlagDefinition = {
|
|
24
|
+
readonly kind: "value" | "boolean";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const flagDefinitions = new Map<string, FlagDefinition>([
|
|
28
|
+
["version", { kind: "boolean" }],
|
|
29
|
+
["kandan-url", { kind: "value" }],
|
|
30
|
+
["token", { kind: "value" }],
|
|
31
|
+
["runner-id", { kind: "value" }],
|
|
32
|
+
["cwd", { kind: "value" }],
|
|
33
|
+
["codex-bin", { kind: "value" }],
|
|
34
|
+
["codex-url", { kind: "value" }],
|
|
35
|
+
["launch-tui", { kind: "boolean" }],
|
|
36
|
+
["workspace", { kind: "value" }],
|
|
37
|
+
["channel", { kind: "value" }],
|
|
38
|
+
["kandan-thread-id", { kind: "value" }],
|
|
39
|
+
["listen-user", { kind: "value" }],
|
|
40
|
+
["model", { kind: "value" }],
|
|
41
|
+
["reasoning-effort", { kind: "value" }],
|
|
42
|
+
["sandbox", { kind: "value" }],
|
|
43
|
+
["approval-policy", { kind: "value" }],
|
|
44
|
+
["fast", { kind: "boolean" }],
|
|
45
|
+
["log-file", { kind: "value" }],
|
|
46
|
+
["auth-file", { kind: "value" }],
|
|
47
|
+
["oauth-callback-host", { kind: "value" }],
|
|
48
|
+
["help", { kind: "boolean" }],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await main(process.argv.slice(2));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main(args: readonly string[]): Promise<void> {
|
|
59
|
+
const parsed = parseCommand(args);
|
|
60
|
+
|
|
61
|
+
switch (parsed.command) {
|
|
62
|
+
case "guide":
|
|
63
|
+
process.stdout.write(connectGuideText());
|
|
64
|
+
return;
|
|
65
|
+
case "version":
|
|
66
|
+
process.stdout.write("linzumi 0.0.2-beta\n");
|
|
67
|
+
return;
|
|
68
|
+
case "auth":
|
|
69
|
+
await runAuthCommand(parsed.args);
|
|
70
|
+
return;
|
|
71
|
+
case "run": {
|
|
72
|
+
const options = await parseRunnerArgs(parsed.args);
|
|
73
|
+
await runLocalCodexRunner(options);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type ParsedCommand =
|
|
80
|
+
| { readonly command: "guide"; readonly args: readonly string[] }
|
|
81
|
+
| { readonly command: "version"; readonly args: readonly string[] }
|
|
82
|
+
| { readonly command: "auth"; readonly args: readonly string[] }
|
|
83
|
+
| { readonly command: "run"; readonly args: readonly string[] };
|
|
84
|
+
|
|
85
|
+
function parseCommand(args: readonly string[]): ParsedCommand {
|
|
86
|
+
const [first, ...rest] = args;
|
|
87
|
+
|
|
88
|
+
switch (first) {
|
|
89
|
+
case undefined:
|
|
90
|
+
case "connect":
|
|
91
|
+
case "local-codex-runner":
|
|
92
|
+
return rest.length === 0
|
|
93
|
+
? { command: "guide", args: [] }
|
|
94
|
+
: { command: "run", args: rest };
|
|
95
|
+
case "--version":
|
|
96
|
+
return { command: "version", args: [] };
|
|
97
|
+
case "--help":
|
|
98
|
+
case "-h":
|
|
99
|
+
return { command: "run", args: ["--help"] };
|
|
100
|
+
case "auth":
|
|
101
|
+
return { command: "auth", args: rest };
|
|
102
|
+
case "run":
|
|
103
|
+
return { command: "run", args: rest };
|
|
104
|
+
default:
|
|
105
|
+
return { command: "run", args };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runAuthCommand(args: readonly string[]): Promise<void> {
|
|
110
|
+
const values = strictFlagValues(args);
|
|
111
|
+
|
|
112
|
+
if (values.get("help") === true) {
|
|
113
|
+
process.stdout.write(helpText());
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const kandanUrl = required(values, "kandan-url");
|
|
118
|
+
const target = parseOptionalChannelTarget(values);
|
|
119
|
+
const token = await acquireLocalRunnerTokenDetails({
|
|
120
|
+
kandanUrl,
|
|
121
|
+
workspaceSlug: target?.workspaceSlug,
|
|
122
|
+
channelSlug: target?.channelSlug,
|
|
123
|
+
callbackHost: stringValue(values, "oauth-callback-host"),
|
|
124
|
+
});
|
|
125
|
+
const cached = writeCachedLocalRunnerToken({
|
|
126
|
+
kandanUrl,
|
|
127
|
+
accessToken: token.accessToken,
|
|
128
|
+
expiresInSeconds: token.expiresInSeconds,
|
|
129
|
+
authFilePath: stringValue(values, "auth-file"),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
process.stdout.write(`Saved Kandan local runner auth for ${cached.kandanBaseUrl}\n`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function parseRunnerArgs(args: readonly string[]): Promise<RunnerOptions> {
|
|
136
|
+
const values = strictFlagValues(args);
|
|
137
|
+
|
|
138
|
+
if (values.get("help") === true) {
|
|
139
|
+
process.stdout.write(helpText());
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (values.get("version") === true) {
|
|
144
|
+
process.stdout.write("linzumi 0.0.2-beta\n");
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const channelSession = parseChannelSession(values);
|
|
149
|
+
const kandanUrl = required(values, "kandan-url");
|
|
150
|
+
const explicitToken = stringValue(values, "token");
|
|
151
|
+
const token = await resolveLocalRunnerToken({
|
|
152
|
+
kandanUrl,
|
|
153
|
+
explicitToken,
|
|
154
|
+
workspaceSlug: channelSession?.workspaceSlug,
|
|
155
|
+
channelSlug: channelSession?.channelSlug,
|
|
156
|
+
authFilePath: stringValue(values, "auth-file"),
|
|
157
|
+
callbackHost: stringValue(values, "oauth-callback-host"),
|
|
158
|
+
reportRejectedCachedToken: () => {
|
|
159
|
+
process.stderr.write("Cached Kandan local runner auth was rejected; starting OAuth.\n");
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
kandanUrl,
|
|
165
|
+
token,
|
|
166
|
+
runnerId: stringValue(values, "runner-id") ?? `runner-${randomUUID()}`,
|
|
167
|
+
cwd: stringValue(values, "cwd") ?? process.cwd(),
|
|
168
|
+
codexBin: stringValue(values, "codex-bin") ?? "codex",
|
|
169
|
+
codexUrl: stringValue(values, "codex-url"),
|
|
170
|
+
launchTui: values.get("launch-tui") === true,
|
|
171
|
+
fast: values.get("fast") === true,
|
|
172
|
+
logFile: stringValue(values, "log-file"),
|
|
173
|
+
channelSession
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function strictFlagValues(args: readonly string[]): Map<string, string | true> {
|
|
178
|
+
const values = new Map<string, string | true>();
|
|
179
|
+
|
|
180
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
181
|
+
const arg = args[index];
|
|
182
|
+
|
|
183
|
+
if (arg === undefined || !arg.startsWith("--")) {
|
|
184
|
+
throw new Error(`invalid argument: ${arg ?? ""}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const key = arg.slice(2);
|
|
188
|
+
const definition = flagDefinitions.get(key);
|
|
189
|
+
|
|
190
|
+
if (definition === undefined) {
|
|
191
|
+
throw new Error(`invalid flag: --${key}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (definition.kind === "boolean") {
|
|
195
|
+
values.set(key, true);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const next = args[index + 1];
|
|
200
|
+
|
|
201
|
+
if (next === undefined || next.startsWith("--")) {
|
|
202
|
+
throw new Error(`missing value for --${key}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
values.set(key, next);
|
|
206
|
+
index += 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return values;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseChannelSession(
|
|
213
|
+
values: Map<string, string | true>
|
|
214
|
+
): RunnerOptions["channelSession"] {
|
|
215
|
+
const target = parseOptionalChannelTarget(values);
|
|
216
|
+
|
|
217
|
+
if (target === undefined) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const listenUser = required(values, "listen-user");
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
workspaceSlug: target.workspaceSlug,
|
|
225
|
+
channelSlug: target.channelSlug,
|
|
226
|
+
kandanThreadId: stringValue(values, "kandan-thread-id"),
|
|
227
|
+
listenUser,
|
|
228
|
+
model: stringValue(values, "model"),
|
|
229
|
+
reasoningEffort: stringValue(values, "reasoning-effort"),
|
|
230
|
+
sandbox: stringValue(values, "sandbox"),
|
|
231
|
+
approvalPolicy: stringValue(values, "approval-policy"),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseOptionalChannelTarget(
|
|
236
|
+
values: Map<string, string | true>
|
|
237
|
+
): { readonly workspaceSlug: string; readonly channelSlug: string } | undefined {
|
|
238
|
+
const channel = stringValue(values, "channel");
|
|
239
|
+
const workspace = stringValue(values, "workspace");
|
|
240
|
+
|
|
241
|
+
if (channel === undefined && workspace === undefined) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return channel !== undefined && channel.includes("/")
|
|
246
|
+
? parseChannelPath(channel)
|
|
247
|
+
: {
|
|
248
|
+
workspaceSlug: workspace ?? required(values, "workspace"),
|
|
249
|
+
channelSlug: channel ?? required(values, "channel")
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseChannelPath(channel: string): {
|
|
254
|
+
readonly workspaceSlug: string;
|
|
255
|
+
readonly channelSlug: string;
|
|
256
|
+
} {
|
|
257
|
+
const [workspaceSlug, channelSlug, ...rest] = channel.split("/");
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
workspaceSlug === undefined ||
|
|
261
|
+
workspaceSlug.trim() === "" ||
|
|
262
|
+
channelSlug === undefined ||
|
|
263
|
+
channelSlug.trim() === "" ||
|
|
264
|
+
rest.length > 0
|
|
265
|
+
) {
|
|
266
|
+
throw new Error("--channel must be CHANNEL or WORKSPACE/CHANNEL");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
workspaceSlug: workspaceSlug.trim(),
|
|
271
|
+
channelSlug: channelSlug.trim()
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function required(values: Map<string, string | true>, key: string): string {
|
|
276
|
+
const value = stringValue(values, key);
|
|
277
|
+
|
|
278
|
+
if (value === undefined) {
|
|
279
|
+
throw new Error(`missing --${key}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function stringValue(values: Map<string, string | true>, key: string): string | undefined {
|
|
286
|
+
const value = values.get(key);
|
|
287
|
+
|
|
288
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function helpText(): string {
|
|
296
|
+
return `Kandan local Codex runner
|
|
297
|
+
|
|
298
|
+
Usage:
|
|
299
|
+
linzumi
|
|
300
|
+
linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> --listen-user <username|all> [options]
|
|
301
|
+
linzumi auth --kandan-url <ws-url> [--workspace <slug> --channel <slug>]
|
|
302
|
+
|
|
303
|
+
Required:
|
|
304
|
+
--kandan-url <ws-url> Kandan websocket base URL, for example ws://127.0.0.1:4160
|
|
305
|
+
--token <jwt> Optional override token. Otherwise ~/.kandan/auth.json is validated or OAuth opens.
|
|
306
|
+
--auth-file <path> Auth cache path, default ~/.kandan/auth.json
|
|
307
|
+
--oauth-callback-host <ip> Callback host reachable by your browser, for example a Tailscale IP
|
|
308
|
+
|
|
309
|
+
Channel binding:
|
|
310
|
+
--workspace <slug> Workspace slug
|
|
311
|
+
--channel <slug|w/c> Channel slug, or workspace/channel
|
|
312
|
+
--kandan-thread-id <uuid> Resume an existing Kandan thread instead of announcing a new root
|
|
313
|
+
--listen-user <user|all> User whose replies are accepted, or all
|
|
314
|
+
|
|
315
|
+
Codex:
|
|
316
|
+
--cwd <path> Working directory for Codex, default current directory
|
|
317
|
+
--codex-bin <path> Codex executable, default codex
|
|
318
|
+
--codex-url <ws-url> Existing Codex app-server websocket URL
|
|
319
|
+
--launch-tui Launch codex --remote against the app-server
|
|
320
|
+
--model <name> Model requested for the Codex thread and shown in Kandan
|
|
321
|
+
--reasoning-effort <value> Reasoning effort requested for Codex and shown in Kandan
|
|
322
|
+
--sandbox <value> Sandbox metadata shown in Kandan
|
|
323
|
+
--approval-policy <value> Approval-policy metadata shown in Kandan
|
|
324
|
+
--fast Mark this runner as low-latency/fast in the availability message
|
|
325
|
+
--log-file <path> JSONL event log path, default <cwd>/.kandan-local-codex-runner.log
|
|
326
|
+
|
|
327
|
+
Examples:
|
|
328
|
+
Good:
|
|
329
|
+
linzumi auth --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
|
|
330
|
+
linzumi auth --kandan-url ws://100.71.192.98:4160 --oauth-callback-host 100.71.192.98 --workspace default --channel seans-playground
|
|
331
|
+
linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --workspace default --channel seans-playground --listen-user sean --cwd /tmp/kandan-runner-a
|
|
332
|
+
linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground --listen-user sean
|
|
333
|
+
linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --channel default/seans-playground --listen-user all --launch-tui
|
|
334
|
+
|
|
335
|
+
Bad:
|
|
336
|
+
linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
|
|
337
|
+
Missing --listen-user.
|
|
338
|
+
linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --listen-users sean
|
|
339
|
+
Invalid flag: use --listen-user.
|
|
340
|
+
`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function connectGuideText(): string {
|
|
344
|
+
return `linzumi connect
|
|
345
|
+
|
|
346
|
+
Connect this computer to Kandan as a local Codex runner.
|
|
347
|
+
|
|
348
|
+
Use:
|
|
349
|
+
linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> --listen-user <username|all> [options]
|
|
350
|
+
|
|
351
|
+
For help:
|
|
352
|
+
linzumi connect --help
|
|
353
|
+
`;
|
|
354
|
+
}
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-24
|
|
3
|
+
Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
|
|
4
|
+
Relationship: Provides small JSON readers used by the local runner's
|
|
5
|
+
channel binding and Codex transcript normalization without ad hoc casts.
|
|
6
|
+
*/
|
|
7
|
+
import { type JsonObject, type JsonValue, isJsonObject } from "./protocol";
|
|
8
|
+
|
|
9
|
+
export function objectValue(value: JsonValue | undefined): JsonObject | undefined {
|
|
10
|
+
return value !== undefined && isJsonObject(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function arrayValue(
|
|
14
|
+
value: JsonValue | undefined,
|
|
15
|
+
): readonly JsonValue[] | undefined {
|
|
16
|
+
return Array.isArray(value) ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function stringValue(value: JsonValue | undefined): string | undefined {
|
|
20
|
+
return typeof value === "string" && value.trim() !== ""
|
|
21
|
+
? value.trim()
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function integerValue(value: JsonValue | undefined): number | undefined {
|
|
26
|
+
return typeof value === "number" && Number.isInteger(value)
|
|
27
|
+
? value
|
|
28
|
+
: undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function stringListValue(value: JsonValue | undefined): string[] {
|
|
32
|
+
if (!Array.isArray(value)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return value.flatMap((entry) => {
|
|
37
|
+
const normalized = stringValue(entry);
|
|
38
|
+
return normalized === undefined ? [] : [normalized];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseJsonObjectOrUndefined(text: string): JsonObject | undefined {
|
|
43
|
+
try {
|
|
44
|
+
const parsed: unknown = JSON.parse(text);
|
|
45
|
+
return isJsonObject(parsed) ? parsed : undefined;
|
|
46
|
+
} catch (_error) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-24
|
|
3
|
+
Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
|
|
4
|
+
Relationship: Owns deterministic queue formatting and interrupt fusion for
|
|
5
|
+
Kandan thread replies before they are sent into local Codex.
|
|
6
|
+
|
|
7
|
+
- Date: 2026-04-24
|
|
8
|
+
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
9
|
+
Relationship: Carries selected queued message seqs through interrupt fusion
|
|
10
|
+
so the UI can show which pending replies were accepted by the interrupt.
|
|
11
|
+
*/
|
|
12
|
+
export type QueuedKandanMessage = {
|
|
13
|
+
readonly seq: number;
|
|
14
|
+
readonly actorSlug: string | undefined;
|
|
15
|
+
readonly actorUserId: number | undefined;
|
|
16
|
+
readonly body: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type QueueInterruptResult =
|
|
20
|
+
| {
|
|
21
|
+
readonly ok: true;
|
|
22
|
+
readonly queue: QueuedKandanMessage[];
|
|
23
|
+
readonly selectedCount: number;
|
|
24
|
+
readonly selectedSeqs: number[];
|
|
25
|
+
readonly remainingCount: number;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
readonly ok: false;
|
|
29
|
+
readonly reason: "queue_empty";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function formatQueuedKandanMessage(message: QueuedKandanMessage): string {
|
|
33
|
+
const sender =
|
|
34
|
+
message.actorSlug === undefined || message.actorSlug.trim() === ""
|
|
35
|
+
? "unknown"
|
|
36
|
+
: message.actorSlug.trim();
|
|
37
|
+
const userId =
|
|
38
|
+
message.actorUserId === undefined
|
|
39
|
+
? "unknown"
|
|
40
|
+
: message.actorUserId.toString();
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
`Kandan message seq=${message.seq} from ${sender} (user_id=${userId}):`,
|
|
44
|
+
"",
|
|
45
|
+
message.body,
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function codexInputForQueuedKandanMessage(message: QueuedKandanMessage): string {
|
|
50
|
+
const command = message.body.trimStart();
|
|
51
|
+
|
|
52
|
+
return command.startsWith("!") ? command : formatQueuedKandanMessage(message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function interruptQueuedMessages(
|
|
56
|
+
queue: readonly QueuedKandanMessage[],
|
|
57
|
+
throughSeq: number | undefined,
|
|
58
|
+
): QueueInterruptResult {
|
|
59
|
+
const selected =
|
|
60
|
+
throughSeq === undefined
|
|
61
|
+
? Array.from(queue)
|
|
62
|
+
: queue.filter((message) => message.seq <= throughSeq);
|
|
63
|
+
const remaining =
|
|
64
|
+
throughSeq === undefined
|
|
65
|
+
? []
|
|
66
|
+
: queue.filter((message) => message.seq > throughSeq);
|
|
67
|
+
|
|
68
|
+
if (selected.length === 0) {
|
|
69
|
+
return { ok: false, reason: "queue_empty" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fused = fuseQueuedMessages(selected);
|
|
73
|
+
|
|
74
|
+
if (fused === undefined) {
|
|
75
|
+
return { ok: false, reason: "queue_empty" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
queue: [fused, ...remaining],
|
|
81
|
+
selectedCount: selected.length,
|
|
82
|
+
selectedSeqs: selected.map(message => message.seq),
|
|
83
|
+
remainingCount: remaining.length,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fuseQueuedMessages(
|
|
88
|
+
selected: readonly QueuedKandanMessage[],
|
|
89
|
+
): QueuedKandanMessage | undefined {
|
|
90
|
+
const last = selected[selected.length - 1];
|
|
91
|
+
|
|
92
|
+
if (last === undefined) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
seq: last.seq,
|
|
98
|
+
actorSlug: "kandan",
|
|
99
|
+
actorUserId: undefined,
|
|
100
|
+
body: selected.map(codexInputForQueuedKandanMessage).join("\n\n---\n\n"),
|
|
101
|
+
};
|
|
102
|
+
}
|