@threadbase-sh/streamer 1.36.4 → 1.37.0
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/dist/cli.cjs +26477 -24984
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1635 -149
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +377 -4
- package/dist/index.d.ts +377 -4
- package/dist/index.js +1638 -153
- package/dist/index.js.map +1 -1
- package/dist/migrations/010_create_managed_sessions.sql +65 -0
- package/dist/migrations/011_create_devices.sql +39 -0
- package/dist/migrations/012_create_push_tokens.sql +49 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -38,6 +38,7 @@ __export(index_exports, {
|
|
|
38
38
|
SessionStore: () => SessionStore,
|
|
39
39
|
StreamerServer: () => StreamerServer,
|
|
40
40
|
WSHub: () => WSHub,
|
|
41
|
+
confidenceForSource: () => confidenceForSource,
|
|
41
42
|
createAgentClient: () => createAgentClient,
|
|
42
43
|
createConversationWriter: () => createConversationWriter,
|
|
43
44
|
createPool: () => createPool,
|
|
@@ -329,6 +330,165 @@ var import_crypto = require("crypto");
|
|
|
329
330
|
var import_fs = require("fs");
|
|
330
331
|
var import_os = require("os");
|
|
331
332
|
var import_path = require("path");
|
|
333
|
+
|
|
334
|
+
// src/claude-flags.ts
|
|
335
|
+
var PERMISSION_MODES = [
|
|
336
|
+
"acceptEdits",
|
|
337
|
+
"auto",
|
|
338
|
+
"bypassPermissions",
|
|
339
|
+
"manual",
|
|
340
|
+
"dontAsk",
|
|
341
|
+
"plan"
|
|
342
|
+
];
|
|
343
|
+
function isPermissionMode(value) {
|
|
344
|
+
return typeof value === "string" && PERMISSION_MODES.includes(value);
|
|
345
|
+
}
|
|
346
|
+
var DANGEROUS_PERMISSION_MODES = [
|
|
347
|
+
"bypassPermissions",
|
|
348
|
+
"dontAsk"
|
|
349
|
+
];
|
|
350
|
+
function isDangerousPermissionMode(mode) {
|
|
351
|
+
return DANGEROUS_PERMISSION_MODES.includes(mode);
|
|
352
|
+
}
|
|
353
|
+
var CLAUDE_FLAGS = [
|
|
354
|
+
{
|
|
355
|
+
id: "permissionMode",
|
|
356
|
+
flag: "--permission-mode",
|
|
357
|
+
valueType: "enum",
|
|
358
|
+
enumValues: PERMISSION_MODES,
|
|
359
|
+
risk: "low"
|
|
360
|
+
},
|
|
361
|
+
{ id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
|
|
362
|
+
{ id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
|
|
363
|
+
{ id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
|
|
364
|
+
{ id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
|
|
365
|
+
{ id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
|
|
366
|
+
];
|
|
367
|
+
function findFlag(id) {
|
|
368
|
+
return CLAUDE_FLAGS.find((f) => f.id === id);
|
|
369
|
+
}
|
|
370
|
+
function validateFlagValues(raw) {
|
|
371
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
372
|
+
const out = {};
|
|
373
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
374
|
+
const def = findFlag(id);
|
|
375
|
+
if (!def) continue;
|
|
376
|
+
switch (def.valueType) {
|
|
377
|
+
case "boolean":
|
|
378
|
+
if (typeof value === "boolean") out[id] = value;
|
|
379
|
+
break;
|
|
380
|
+
case "enum":
|
|
381
|
+
if (typeof value === "string" && def.enumValues?.includes(value)) out[id] = value;
|
|
382
|
+
break;
|
|
383
|
+
case "string":
|
|
384
|
+
if (typeof value === "string" && value.trim().length > 0) out[id] = value.trim();
|
|
385
|
+
break;
|
|
386
|
+
case "list": {
|
|
387
|
+
if (!Array.isArray(value)) break;
|
|
388
|
+
const items = value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
|
|
389
|
+
if (items.length > 0) out[id] = items;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
function tokenizeExtraArgs(input) {
|
|
397
|
+
if (!input) return [];
|
|
398
|
+
const tokens = [];
|
|
399
|
+
let current = "";
|
|
400
|
+
let quote = null;
|
|
401
|
+
let started = false;
|
|
402
|
+
for (const ch of input) {
|
|
403
|
+
if (quote) {
|
|
404
|
+
if (ch === quote) quote = null;
|
|
405
|
+
else current += ch;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (ch === '"' || ch === "'") {
|
|
409
|
+
quote = ch;
|
|
410
|
+
started = true;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (/\s/.test(ch)) {
|
|
414
|
+
if (started) {
|
|
415
|
+
tokens.push(current);
|
|
416
|
+
current = "";
|
|
417
|
+
started = false;
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
current += ch;
|
|
422
|
+
started = true;
|
|
423
|
+
}
|
|
424
|
+
if (started) tokens.push(current);
|
|
425
|
+
return tokens;
|
|
426
|
+
}
|
|
427
|
+
function buildFlagArgs(values, extraArgs) {
|
|
428
|
+
const args = [];
|
|
429
|
+
const safe = validateFlagValues(values ?? {});
|
|
430
|
+
for (const def of CLAUDE_FLAGS) {
|
|
431
|
+
if (def.id === "permissionMode") continue;
|
|
432
|
+
const value = safe[def.id];
|
|
433
|
+
if (value === void 0) continue;
|
|
434
|
+
if (def.valueType === "boolean") {
|
|
435
|
+
if (value === true) args.push(def.flag);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (Array.isArray(value)) {
|
|
439
|
+
args.push(def.flag, ...value);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
args.push(def.flag, String(value));
|
|
443
|
+
}
|
|
444
|
+
args.push(...tokenizeExtraArgs(extraArgs));
|
|
445
|
+
return args;
|
|
446
|
+
}
|
|
447
|
+
function buildSettingsJson(permissionMode) {
|
|
448
|
+
const settings = { spinnerTipsEnabled: false };
|
|
449
|
+
if (isDangerousPermissionMode(permissionMode)) {
|
|
450
|
+
settings.skipDangerousModePermissionPrompt = true;
|
|
451
|
+
}
|
|
452
|
+
return JSON.stringify(settings);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/logger.ts
|
|
456
|
+
var import_pino = __toESM(require("pino"), 1);
|
|
457
|
+
var baseLogger = (0, import_pino.default)({
|
|
458
|
+
level: process.env.LOG_LEVEL ?? "info",
|
|
459
|
+
base: { service: "tb-streamer" },
|
|
460
|
+
timestamp: import_pino.default.stdTimeFunctions.isoTime,
|
|
461
|
+
redact: {
|
|
462
|
+
paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
|
|
463
|
+
censor: "[redacted]"
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
function emit(pinoChild, level, msg, fields, dest) {
|
|
467
|
+
if (dest === "pino" || dest === "both") {
|
|
468
|
+
if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
|
|
469
|
+
else pinoChild[level](msg);
|
|
470
|
+
}
|
|
471
|
+
if (dest === "console" || dest === "both") {
|
|
472
|
+
const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
|
|
473
|
+
console[consoleMethod](msg);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function build(pinoChild) {
|
|
477
|
+
return {
|
|
478
|
+
debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
|
|
479
|
+
info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
|
|
480
|
+
warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
|
|
481
|
+
error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
|
|
482
|
+
log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
|
|
483
|
+
pino: pinoChild
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function getLogger(component) {
|
|
487
|
+
return build(component ? baseLogger.child({ component }) : baseLogger);
|
|
488
|
+
}
|
|
489
|
+
var logger = build(baseLogger);
|
|
490
|
+
|
|
491
|
+
// src/auth.ts
|
|
332
492
|
function configDir() {
|
|
333
493
|
return process.env.THREADBASE_CONFIG_DIR ?? (0, import_path.join)((0, import_os.homedir)(), ".threadbase");
|
|
334
494
|
}
|
|
@@ -407,11 +567,79 @@ function loadDefaultPermissionMode() {
|
|
|
407
567
|
const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
|
|
408
568
|
const match = content.match(/default_permission_mode:\s*(\S+)/);
|
|
409
569
|
const value = match?.[1]?.trim();
|
|
410
|
-
if (value
|
|
570
|
+
if (isPermissionMode(value)) return value;
|
|
411
571
|
} catch {
|
|
412
572
|
}
|
|
413
573
|
return void 0;
|
|
414
574
|
}
|
|
575
|
+
function setConfigValue(key, value) {
|
|
576
|
+
const file = configFile();
|
|
577
|
+
(0, import_fs.mkdirSync)(configDir(), { recursive: true });
|
|
578
|
+
let content = "";
|
|
579
|
+
try {
|
|
580
|
+
content = (0, import_fs.readFileSync)(file, "utf-8");
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (err.code !== "ENOENT") throw err;
|
|
583
|
+
}
|
|
584
|
+
const lineRe = new RegExp(`^${key}:\\s*.*$\\n?`, "m");
|
|
585
|
+
let updated;
|
|
586
|
+
if (value === void 0) {
|
|
587
|
+
updated = content.replace(lineRe, "");
|
|
588
|
+
} else {
|
|
589
|
+
const line = `${key}: ${value}`;
|
|
590
|
+
if (lineRe.test(content)) {
|
|
591
|
+
updated = content.replace(lineRe, `${line}
|
|
592
|
+
`);
|
|
593
|
+
} else if (content.length === 0 || content.endsWith("\n")) {
|
|
594
|
+
updated = `${content}${line}
|
|
595
|
+
`;
|
|
596
|
+
} else {
|
|
597
|
+
updated = `${content}
|
|
598
|
+
${line}
|
|
599
|
+
`;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const tmpFile = `${file}.tmp`;
|
|
603
|
+
(0, import_fs.writeFileSync)(tmpFile, updated, { encoding: "utf-8", mode: 384 });
|
|
604
|
+
(0, import_fs.chmodSync)(tmpFile, 384);
|
|
605
|
+
(0, import_fs.renameSync)(tmpFile, file);
|
|
606
|
+
}
|
|
607
|
+
function loadClaudeFlags() {
|
|
608
|
+
try {
|
|
609
|
+
const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
|
|
610
|
+
const match = content.match(/^claude_flags:\s*(.+)$/m);
|
|
611
|
+
if (!match?.[1]) return {};
|
|
612
|
+
return validateFlagValues(JSON.parse(match[1].trim()));
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (err.code !== "ENOENT") {
|
|
615
|
+
getLogger("auth").warn(`Ignoring unreadable claude_flags in server.yaml: ${String(err)}`, {
|
|
616
|
+
event: "config.claude_flags_parse_failed"
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return {};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function setClaudeFlags(values) {
|
|
623
|
+
const safe = validateFlagValues(values);
|
|
624
|
+
setConfigValue("claude_flags", Object.keys(safe).length === 0 ? void 0 : JSON.stringify(safe));
|
|
625
|
+
}
|
|
626
|
+
function loadClaudeExtraArgs() {
|
|
627
|
+
try {
|
|
628
|
+
const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
|
|
629
|
+
const match = content.match(/^claude_extra_args:\s*(.+)$/m);
|
|
630
|
+
const value = match?.[1]?.trim();
|
|
631
|
+
return value && value.length > 0 ? value : void 0;
|
|
632
|
+
} catch {
|
|
633
|
+
}
|
|
634
|
+
return void 0;
|
|
635
|
+
}
|
|
636
|
+
function setClaudeExtraArgs(text) {
|
|
637
|
+
const trimmed = text?.trim();
|
|
638
|
+
if (trimmed && /[\r\n]/.test(trimmed)) {
|
|
639
|
+
throw new Error("claude_extra_args must not contain newlines");
|
|
640
|
+
}
|
|
641
|
+
setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
|
|
642
|
+
}
|
|
415
643
|
function validatePublicUrl(raw) {
|
|
416
644
|
let parsed;
|
|
417
645
|
try {
|
|
@@ -560,42 +788,6 @@ var import_crypto2 = require("crypto");
|
|
|
560
788
|
var import_fs5 = require("fs");
|
|
561
789
|
var import_path5 = require("path");
|
|
562
790
|
|
|
563
|
-
// src/logger.ts
|
|
564
|
-
var import_pino = __toESM(require("pino"), 1);
|
|
565
|
-
var baseLogger = (0, import_pino.default)({
|
|
566
|
-
level: process.env.LOG_LEVEL ?? "info",
|
|
567
|
-
base: { service: "tb-streamer" },
|
|
568
|
-
timestamp: import_pino.default.stdTimeFunctions.isoTime,
|
|
569
|
-
redact: {
|
|
570
|
-
paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
|
|
571
|
-
censor: "[redacted]"
|
|
572
|
-
}
|
|
573
|
-
});
|
|
574
|
-
function emit(pinoChild, level, msg, fields, dest) {
|
|
575
|
-
if (dest === "pino" || dest === "both") {
|
|
576
|
-
if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
|
|
577
|
-
else pinoChild[level](msg);
|
|
578
|
-
}
|
|
579
|
-
if (dest === "console" || dest === "both") {
|
|
580
|
-
const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
|
|
581
|
-
console[consoleMethod](msg);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
function build(pinoChild) {
|
|
585
|
-
return {
|
|
586
|
-
debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
|
|
587
|
-
info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
|
|
588
|
-
warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
|
|
589
|
-
error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
|
|
590
|
-
log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
|
|
591
|
-
pino: pinoChild
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
function getLogger(component) {
|
|
595
|
-
return build(component ? baseLogger.child({ component }) : baseLogger);
|
|
596
|
-
}
|
|
597
|
-
var logger = build(baseLogger);
|
|
598
|
-
|
|
599
791
|
// src/platform.ts
|
|
600
792
|
var import_child_process = require("child_process");
|
|
601
793
|
var import_fs3 = require("fs");
|
|
@@ -947,6 +1139,8 @@ var CodexPtyRunner = class {
|
|
|
947
1139
|
projectName,
|
|
948
1140
|
branch: options.branch ?? "",
|
|
949
1141
|
status: "running",
|
|
1142
|
+
statusSource: "spawn",
|
|
1143
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
950
1144
|
startedAt: /* @__PURE__ */ new Date(),
|
|
951
1145
|
completedAt: null,
|
|
952
1146
|
promptCount: 0,
|
|
@@ -994,6 +1188,8 @@ var CodexPtyRunner = class {
|
|
|
994
1188
|
projectName,
|
|
995
1189
|
branch: "",
|
|
996
1190
|
status: "running",
|
|
1191
|
+
statusSource: "spawn",
|
|
1192
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
997
1193
|
startedAt: /* @__PURE__ */ new Date(),
|
|
998
1194
|
completedAt: null,
|
|
999
1195
|
promptCount: 0,
|
|
@@ -1024,7 +1220,7 @@ var CodexPtyRunner = class {
|
|
|
1024
1220
|
this.readyFallbackTimers.delete(sessionId);
|
|
1025
1221
|
const session = this.sessions.get(sessionId);
|
|
1026
1222
|
if (session?.status === "running" && this.pendingReady.has(sessionId)) {
|
|
1027
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
1223
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
1028
1224
|
}
|
|
1029
1225
|
}, CODEX_READY_FALLBACK_MS);
|
|
1030
1226
|
timer.unref?.();
|
|
@@ -1039,6 +1235,8 @@ var CodexPtyRunner = class {
|
|
|
1039
1235
|
}
|
|
1040
1236
|
if (session.status === "waiting_input") {
|
|
1041
1237
|
session.status = "running";
|
|
1238
|
+
session.statusSource = "user-input";
|
|
1239
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1042
1240
|
this.onStatusChange?.(toPublicSession(session));
|
|
1043
1241
|
}
|
|
1044
1242
|
const gate = this.openGate.get(sessionId);
|
|
@@ -1108,6 +1306,8 @@ var CodexPtyRunner = class {
|
|
|
1108
1306
|
}
|
|
1109
1307
|
if (session.status === "waiting_input") {
|
|
1110
1308
|
session.status = "running";
|
|
1309
|
+
session.statusSource = "user-input";
|
|
1310
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1111
1311
|
this.onStatusChange?.(toPublicSession(session));
|
|
1112
1312
|
}
|
|
1113
1313
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -1208,6 +1408,8 @@ var CodexPtyRunner = class {
|
|
|
1208
1408
|
} catch {
|
|
1209
1409
|
}
|
|
1210
1410
|
session.status = "idle";
|
|
1411
|
+
session.statusSource = "shutdown";
|
|
1412
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1211
1413
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1212
1414
|
session.screen.dispose();
|
|
1213
1415
|
this.sessions.delete(sessionId);
|
|
@@ -1252,6 +1454,11 @@ var CodexPtyRunner = class {
|
|
|
1252
1454
|
getInputHistory(sessionId) {
|
|
1253
1455
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
1254
1456
|
}
|
|
1457
|
+
// OS pid of the spawned agent, or null if the session isn't live here.
|
|
1458
|
+
// Mirrors PTYManager.getPid — see there for why the registry records it.
|
|
1459
|
+
getPid(sessionId) {
|
|
1460
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
1461
|
+
}
|
|
1255
1462
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
1256
1463
|
// Called from writeSubmit (direct and flush paths) — never from sendKeys.
|
|
1257
1464
|
recordUserMessage(session, text) {
|
|
@@ -1353,9 +1560,9 @@ var CodexPtyRunner = class {
|
|
|
1353
1560
|
if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
|
|
1354
1561
|
const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
|
|
1355
1562
|
if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
|
|
1356
|
-
this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1563
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1357
1564
|
} else if (trigger === "quiet") {
|
|
1358
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
1565
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
1359
1566
|
}
|
|
1360
1567
|
}
|
|
1361
1568
|
// Answer a gate from the persisted remember-store, or surface it as a
|
|
@@ -1386,9 +1593,11 @@ var CodexPtyRunner = class {
|
|
|
1386
1593
|
});
|
|
1387
1594
|
this.onPermissionChange?.(sessionId, card);
|
|
1388
1595
|
}
|
|
1389
|
-
markReady(sessionId, session, reason) {
|
|
1596
|
+
markReady(sessionId, session, source, reason) {
|
|
1390
1597
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
1391
1598
|
session.status = "waiting_input";
|
|
1599
|
+
session.statusSource = source;
|
|
1600
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1392
1601
|
this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
|
|
1393
1602
|
event: "codex.ready",
|
|
1394
1603
|
sessionId,
|
|
@@ -1406,6 +1615,8 @@ var CodexPtyRunner = class {
|
|
|
1406
1615
|
if (!session) return;
|
|
1407
1616
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1408
1617
|
session.status = "idle";
|
|
1618
|
+
session.statusSource = "process-exit";
|
|
1619
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1409
1620
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
1410
1621
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
1411
1622
|
if (!(0, import_fs5.existsSync)(session.projectPath)) {
|
|
@@ -1435,6 +1646,8 @@ function toPublicSession(s) {
|
|
|
1435
1646
|
lastOutput: s.lastOutput,
|
|
1436
1647
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
1437
1648
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
1649
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
1650
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
1438
1651
|
...s.filePath != null && { filePath: s.filePath }
|
|
1439
1652
|
};
|
|
1440
1653
|
}
|
|
@@ -1763,16 +1976,17 @@ var PTYManager = class {
|
|
|
1763
1976
|
}
|
|
1764
1977
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
1765
1978
|
//
|
|
1766
|
-
// options.permissionMode defaults to `acceptEdits`
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
//
|
|
1770
|
-
//
|
|
1771
|
-
//
|
|
1772
|
-
//
|
|
1773
|
-
//
|
|
1774
|
-
// `
|
|
1775
|
-
//
|
|
1979
|
+
// options.permissionMode defaults to `acceptEdits` — the safe default that
|
|
1980
|
+
// auto-approves file edits while still prompting for shell commands. All six
|
|
1981
|
+
// Claude CLI modes are accepted (see PERMISSION_MODES in claude-flags.ts).
|
|
1982
|
+
//
|
|
1983
|
+
// On the bypass modes: `bypassPermissions`/`dontAsk` DO trigger a blocking
|
|
1984
|
+
// "Bypass Permissions mode" warning menu at boot ("1. No, exit" /
|
|
1985
|
+
// "2. Yes, I accept") which would strand the PTY and leave mobile on an empty
|
|
1986
|
+
// screen. buildSettingsJson() suppresses it by adding
|
|
1987
|
+
// `skipDangerousModePermissionPrompt` to the `--settings` blob for exactly
|
|
1988
|
+
// those modes — probe-verified on Claude Code v2.1.218. We never pass
|
|
1989
|
+
// `--dangerously-skip-permissions`; bypass is requested via --permission-mode.
|
|
1776
1990
|
// (The other first-run gates — onboarding/theme, workspace trust,
|
|
1777
1991
|
// custom-API-key — are cleared by the seeded ~/.claude.json in
|
|
1778
1992
|
// docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
|
|
@@ -1790,28 +2004,27 @@ var PTYManager = class {
|
|
|
1790
2004
|
async doStart(sessionId, options) {
|
|
1791
2005
|
const nodePty = await loadPty2();
|
|
1792
2006
|
const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
);
|
|
2007
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
2008
|
+
const args = [
|
|
2009
|
+
"--permission-mode",
|
|
2010
|
+
permissionMode,
|
|
2011
|
+
"--settings",
|
|
2012
|
+
buildSettingsJson(permissionMode),
|
|
2013
|
+
"--model",
|
|
2014
|
+
options.model ?? "sonnet",
|
|
2015
|
+
"--effort",
|
|
2016
|
+
options.effort ?? "low",
|
|
2017
|
+
"--resume",
|
|
2018
|
+
sessionId
|
|
2019
|
+
];
|
|
2020
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
2021
|
+
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
2022
|
+
name: "xterm-256color",
|
|
2023
|
+
cols: 120,
|
|
2024
|
+
rows: 40,
|
|
2025
|
+
cwd: options.projectPath,
|
|
2026
|
+
env: buildSpawnEnv()
|
|
2027
|
+
});
|
|
1815
2028
|
const session = {
|
|
1816
2029
|
id: sessionId,
|
|
1817
2030
|
provider: CLAUDE_CODE_PROVIDER,
|
|
@@ -1819,6 +2032,8 @@ var PTYManager = class {
|
|
|
1819
2032
|
projectName,
|
|
1820
2033
|
branch: options.branch ?? "",
|
|
1821
2034
|
status: "running",
|
|
2035
|
+
statusSource: "spawn",
|
|
2036
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1822
2037
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1823
2038
|
completedAt: null,
|
|
1824
2039
|
promptCount: 0,
|
|
@@ -1846,11 +2061,12 @@ var PTYManager = class {
|
|
|
1846
2061
|
const nodePty = await loadPty2();
|
|
1847
2062
|
const sessionId = (0, import_crypto3.randomUUID)();
|
|
1848
2063
|
const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
|
|
2064
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
1849
2065
|
const args = [
|
|
1850
2066
|
"--permission-mode",
|
|
1851
|
-
|
|
2067
|
+
permissionMode,
|
|
1852
2068
|
"--settings",
|
|
1853
|
-
|
|
2069
|
+
buildSettingsJson(permissionMode),
|
|
1854
2070
|
"--model",
|
|
1855
2071
|
options.model ?? "sonnet",
|
|
1856
2072
|
"--effort",
|
|
@@ -1861,6 +2077,7 @@ var PTYManager = class {
|
|
|
1861
2077
|
if (options.systemPrompt) {
|
|
1862
2078
|
args.push("--system-prompt", options.systemPrompt);
|
|
1863
2079
|
}
|
|
2080
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
1864
2081
|
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
1865
2082
|
name: "xterm-256color",
|
|
1866
2083
|
cols: 120,
|
|
@@ -1875,6 +2092,8 @@ var PTYManager = class {
|
|
|
1875
2092
|
projectName,
|
|
1876
2093
|
branch: "",
|
|
1877
2094
|
status: "running",
|
|
2095
|
+
statusSource: "spawn",
|
|
2096
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1878
2097
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1879
2098
|
completedAt: null,
|
|
1880
2099
|
promptCount: 0,
|
|
@@ -1905,6 +2124,8 @@ var PTYManager = class {
|
|
|
1905
2124
|
}
|
|
1906
2125
|
if (session.status === "waiting_input") {
|
|
1907
2126
|
session.status = "running";
|
|
2127
|
+
session.statusSource = "user-input";
|
|
2128
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1908
2129
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1909
2130
|
}
|
|
1910
2131
|
this.log.info(
|
|
@@ -1940,6 +2161,8 @@ var PTYManager = class {
|
|
|
1940
2161
|
}
|
|
1941
2162
|
if (session.status === "waiting_input") {
|
|
1942
2163
|
session.status = "running";
|
|
2164
|
+
session.statusSource = "user-input";
|
|
2165
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1943
2166
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1944
2167
|
}
|
|
1945
2168
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -2061,6 +2284,8 @@ var PTYManager = class {
|
|
|
2061
2284
|
} catch {
|
|
2062
2285
|
}
|
|
2063
2286
|
session.status = "idle";
|
|
2287
|
+
session.statusSource = "shutdown";
|
|
2288
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2064
2289
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2065
2290
|
session.screen.dispose();
|
|
2066
2291
|
this.sessions.delete(sessionId);
|
|
@@ -2096,6 +2321,13 @@ var PTYManager = class {
|
|
|
2096
2321
|
getInputHistory(sessionId) {
|
|
2097
2322
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
2098
2323
|
}
|
|
2324
|
+
// OS pid of the spawned agent, or null if the session isn't live here. The
|
|
2325
|
+
// durable registry records this so a later streamer run can probe whether the
|
|
2326
|
+
// process outlived it. Liveness alone is never identity — a recycled pid is
|
|
2327
|
+
// why the registry stores a cmdline alongside it.
|
|
2328
|
+
getPid(sessionId) {
|
|
2329
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
2330
|
+
}
|
|
2099
2331
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
2100
2332
|
// Called from writeSubmit (both direct and flush paths) — never from
|
|
2101
2333
|
// sendKeys, so raw keystrokes aren't logged as messages.
|
|
@@ -2172,9 +2404,9 @@ var PTYManager = class {
|
|
|
2172
2404
|
session.lastOutput = stripped;
|
|
2173
2405
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
|
|
2174
2406
|
if (session.status === "running" && matchedMarker) {
|
|
2175
|
-
this.markReady(sessionId, session, `marker:${matchedMarker}`);
|
|
2407
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
|
|
2176
2408
|
} else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
|
|
2177
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
2409
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
2178
2410
|
}
|
|
2179
2411
|
this.onOutput?.(sessionId, data);
|
|
2180
2412
|
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
@@ -2274,7 +2506,7 @@ var PTYManager = class {
|
|
|
2274
2506
|
const session = this.sessions.get(sessionId);
|
|
2275
2507
|
if (session?.status !== "running") return;
|
|
2276
2508
|
if (this.pendingReady.has(sessionId)) {
|
|
2277
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
2509
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
2278
2510
|
} else {
|
|
2279
2511
|
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2280
2512
|
this.log.warn("[pty.ready] screen recheck failed", {
|
|
@@ -2303,14 +2535,16 @@ var PTYManager = class {
|
|
|
2303
2535
|
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2304
2536
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2305
2537
|
if (matchedMarker && session.status === "running") {
|
|
2306
|
-
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2538
|
+
this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
|
|
2307
2539
|
}
|
|
2308
2540
|
}
|
|
2309
2541
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2310
2542
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2311
|
-
markReady(sessionId, session, reason) {
|
|
2543
|
+
markReady(sessionId, session, source, reason) {
|
|
2312
2544
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2313
2545
|
session.status = "waiting_input";
|
|
2546
|
+
session.statusSource = source;
|
|
2547
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2314
2548
|
const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
|
|
2315
2549
|
this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
|
|
2316
2550
|
event: "pty.ready",
|
|
@@ -2330,6 +2564,8 @@ var PTYManager = class {
|
|
|
2330
2564
|
if (!session) return;
|
|
2331
2565
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2332
2566
|
session.status = "idle";
|
|
2567
|
+
session.statusSource = "process-exit";
|
|
2568
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2333
2569
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
2334
2570
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
2335
2571
|
if (!(0, import_fs6.existsSync)(session.projectPath)) {
|
|
@@ -2364,6 +2600,8 @@ function toPublicSession2(s) {
|
|
|
2364
2600
|
lastOutput: s.lastOutput,
|
|
2365
2601
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
2366
2602
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2603
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2604
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2367
2605
|
...s.filePath != null && { filePath: s.filePath }
|
|
2368
2606
|
};
|
|
2369
2607
|
}
|
|
@@ -2436,6 +2674,16 @@ var LiveSessionManager = class {
|
|
|
2436
2674
|
}
|
|
2437
2675
|
return null;
|
|
2438
2676
|
}
|
|
2677
|
+
// Scans rather than using runnerFor(): the registry records a pid on a
|
|
2678
|
+
// best-effort basis, so an unknown session must return null rather than
|
|
2679
|
+
// throw the way the input-routing methods do.
|
|
2680
|
+
getPid(sessionId) {
|
|
2681
|
+
for (const runner of this.runners.values()) {
|
|
2682
|
+
const pid = runner.getPid(sessionId);
|
|
2683
|
+
if (pid != null) return pid;
|
|
2684
|
+
}
|
|
2685
|
+
return null;
|
|
2686
|
+
}
|
|
2439
2687
|
hasSession(sessionId) {
|
|
2440
2688
|
for (const runner of this.runners.values()) {
|
|
2441
2689
|
if (runner.hasSession(sessionId)) return true;
|
|
@@ -2612,6 +2860,18 @@ async function getProcessCwdUnix(pid) {
|
|
|
2612
2860
|
async function getProcessArgsUnix(pid) {
|
|
2613
2861
|
return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
|
|
2614
2862
|
}
|
|
2863
|
+
async function getProcessArgs(pid) {
|
|
2864
|
+
if (!Number.isInteger(pid) || pid < 1) return "";
|
|
2865
|
+
try {
|
|
2866
|
+
if ((0, import_os4.platform)() === "win32") {
|
|
2867
|
+
const info = await getProcessInfoWindows(pid);
|
|
2868
|
+
return info?.args ?? "";
|
|
2869
|
+
}
|
|
2870
|
+
return await getProcessArgsUnix(pid);
|
|
2871
|
+
} catch {
|
|
2872
|
+
return "";
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2615
2875
|
async function getProcessStartTimeUnix(pid) {
|
|
2616
2876
|
const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
|
|
2617
2877
|
const d = new Date(raw);
|
|
@@ -2727,6 +2987,7 @@ async function readGitBranch(dir) {
|
|
|
2727
2987
|
var import_node_ws = require("@hono/node-ws");
|
|
2728
2988
|
var import_client = require("@temporalio/client");
|
|
2729
2989
|
var import_scanner3 = require("@threadbase-sh/scanner");
|
|
2990
|
+
var import_crypto11 = require("crypto");
|
|
2730
2991
|
var import_events = require("events");
|
|
2731
2992
|
var import_fs18 = require("fs");
|
|
2732
2993
|
var import_promises7 = require("fs/promises");
|
|
@@ -2991,7 +3252,202 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2991
3252
|
}
|
|
2992
3253
|
|
|
2993
3254
|
// src/api/app.ts
|
|
2994
|
-
var
|
|
3255
|
+
var import_hono16 = require("hono");
|
|
3256
|
+
|
|
3257
|
+
// src/db/repositories/devices.repository.ts
|
|
3258
|
+
var import_crypto5 = require("crypto");
|
|
3259
|
+
|
|
3260
|
+
// src/services/security/capabilities.ts
|
|
3261
|
+
var CAPABILITIES = [
|
|
3262
|
+
"history:read",
|
|
3263
|
+
// read conversations, search
|
|
3264
|
+
"session:control",
|
|
3265
|
+
// start, resume, send input, interrupt
|
|
3266
|
+
"fs:browse",
|
|
3267
|
+
// browse the project tree
|
|
3268
|
+
"fs:upload",
|
|
3269
|
+
// upload files into a project
|
|
3270
|
+
"notifications",
|
|
3271
|
+
// register for push
|
|
3272
|
+
"admin"
|
|
3273
|
+
// rotate keys, manage devices
|
|
3274
|
+
];
|
|
3275
|
+
function isCapability(value) {
|
|
3276
|
+
return typeof value === "string" && CAPABILITIES.includes(value);
|
|
3277
|
+
}
|
|
3278
|
+
var FULL_CAPABILITIES = [
|
|
3279
|
+
"history:read",
|
|
3280
|
+
"session:control",
|
|
3281
|
+
"fs:browse",
|
|
3282
|
+
"fs:upload",
|
|
3283
|
+
"notifications"
|
|
3284
|
+
];
|
|
3285
|
+
var READ_ONLY_CAPABILITIES = ["history:read"];
|
|
3286
|
+
function capabilitiesForPreset(preset) {
|
|
3287
|
+
return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
|
|
3288
|
+
}
|
|
3289
|
+
function legacyPrincipal() {
|
|
3290
|
+
return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
|
|
3291
|
+
}
|
|
3292
|
+
function hasCapability(principal, required) {
|
|
3293
|
+
return principal.capabilities.includes(required);
|
|
3294
|
+
}
|
|
3295
|
+
var ROUTE_CAPABILITIES = [
|
|
3296
|
+
// Most specific first for readability; matching sorts by length anyway.
|
|
3297
|
+
["/api/sessions/", "session:control"],
|
|
3298
|
+
["/api/sessions", "history:read"],
|
|
3299
|
+
// listing sessions is a read
|
|
3300
|
+
["/api/conversations", "history:read"],
|
|
3301
|
+
["/api/projects", "history:read"],
|
|
3302
|
+
["/api/search", "history:read"],
|
|
3303
|
+
["/api/providers", "history:read"],
|
|
3304
|
+
["/api/browse", "fs:browse"],
|
|
3305
|
+
["/api/upload", "fs:upload"],
|
|
3306
|
+
["/api/push", "notifications"],
|
|
3307
|
+
["/api/devices", "admin"],
|
|
3308
|
+
["/api/config", "admin"],
|
|
3309
|
+
["/api/auth/rotate", "admin"],
|
|
3310
|
+
["/api/backup", "admin"],
|
|
3311
|
+
// Server identity and capability discovery. A read-only device must be able
|
|
3312
|
+
// to see WHICH server it is talking to and what it supports, or it cannot
|
|
3313
|
+
// render anything at all.
|
|
3314
|
+
["/api/info", "history:read"],
|
|
3315
|
+
["/api/profiles", "history:read"],
|
|
3316
|
+
["/api/diagnostics", "history:read"],
|
|
3317
|
+
["/api/cache/alert", "history:read"],
|
|
3318
|
+
// Client log shipping: any authenticated client may report its own errors.
|
|
3319
|
+
// Gating this behind a capability would silence diagnostics from exactly the
|
|
3320
|
+
// devices most likely to be misbehaving.
|
|
3321
|
+
["/api/__client-log", "history:read"],
|
|
3322
|
+
// Logs viewer is localhost-only and already bypasses this middleware; the
|
|
3323
|
+
// mapping exists so a remote request is classified rather than denied as
|
|
3324
|
+
// unclassified.
|
|
3325
|
+
["/api/logs", "admin"],
|
|
3326
|
+
// Pairing routes other than the public exchange (e.g. minting a token).
|
|
3327
|
+
["/api/pair", "admin"],
|
|
3328
|
+
// The live WebSocket. Subscribing is a read — terminal output, session
|
|
3329
|
+
// updates, conversation events. Control still flows through the HTTP input
|
|
3330
|
+
// routes, which carry their own capability check, so a read-only device can
|
|
3331
|
+
// watch a session stream without being able to drive it.
|
|
3332
|
+
["/ws", "history:read"],
|
|
3333
|
+
// Progress webhook (multi-agent). Authenticated by HMAC in the handler and
|
|
3334
|
+
// already skipped by the middleware; classified so a stray request is denied
|
|
3335
|
+
// by rule rather than as "unclassified".
|
|
3336
|
+
["/internal/sessions", "admin"]
|
|
3337
|
+
];
|
|
3338
|
+
function requiredCapability(path, method) {
|
|
3339
|
+
if (path.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
|
|
3340
|
+
return "history:read";
|
|
3341
|
+
}
|
|
3342
|
+
let best = null;
|
|
3343
|
+
for (const [prefix, cap] of ROUTE_CAPABILITIES) {
|
|
3344
|
+
if (path.startsWith(prefix) && (best === null || prefix.length > best.len)) {
|
|
3345
|
+
best = { len: prefix.length, cap };
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
return best?.cap ?? null;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
// src/db/repositories/devices.repository.ts
|
|
3352
|
+
function generateDeviceToken() {
|
|
3353
|
+
return (0, import_crypto5.randomBytes)(32).toString("base64url");
|
|
3354
|
+
}
|
|
3355
|
+
function hashDeviceToken(token) {
|
|
3356
|
+
return (0, import_crypto5.createHash)("sha256").update(token).digest("hex");
|
|
3357
|
+
}
|
|
3358
|
+
function safeHashEquals(a, b) {
|
|
3359
|
+
if (a.length !== b.length) return false;
|
|
3360
|
+
return (0, import_crypto5.timingSafeEqual)(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
3361
|
+
}
|
|
3362
|
+
function parseCapabilities(raw) {
|
|
3363
|
+
try {
|
|
3364
|
+
const parsed = JSON.parse(raw);
|
|
3365
|
+
if (!Array.isArray(parsed)) return [];
|
|
3366
|
+
return parsed.filter(isCapability);
|
|
3367
|
+
} catch {
|
|
3368
|
+
return [];
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
function toDeviceView(row) {
|
|
3372
|
+
return {
|
|
3373
|
+
deviceId: row.device_id,
|
|
3374
|
+
name: row.name,
|
|
3375
|
+
capabilities: parseCapabilities(row.capabilities),
|
|
3376
|
+
createdAt: row.created_at,
|
|
3377
|
+
lastSeenAt: row.last_seen_at,
|
|
3378
|
+
revokedAt: row.revoked_at
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
var DevicesRepository = class {
|
|
3382
|
+
insertStmt;
|
|
3383
|
+
byTokenHashStmt;
|
|
3384
|
+
byIdStmt;
|
|
3385
|
+
listStmt;
|
|
3386
|
+
revokeStmt;
|
|
3387
|
+
touchStmt;
|
|
3388
|
+
constructor(db) {
|
|
3389
|
+
this.insertStmt = db.prepare(`
|
|
3390
|
+
INSERT INTO devices (
|
|
3391
|
+
device_id, public_key, token_hash, name, capabilities, created_at
|
|
3392
|
+
) VALUES (
|
|
3393
|
+
@device_id, @public_key, @token_hash, @name, @capabilities, @created_at
|
|
3394
|
+
)
|
|
3395
|
+
`);
|
|
3396
|
+
this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
|
|
3397
|
+
this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
|
|
3398
|
+
this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
|
|
3399
|
+
this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
|
|
3400
|
+
this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* Record a newly paired device and mint its token.
|
|
3404
|
+
*
|
|
3405
|
+
* The raw token is returned to the caller and never stored — this is the only
|
|
3406
|
+
* moment it exists outside the client.
|
|
3407
|
+
*/
|
|
3408
|
+
register(args) {
|
|
3409
|
+
const deviceId = (0, import_crypto5.randomUUID)();
|
|
3410
|
+
const deviceToken = generateDeviceToken();
|
|
3411
|
+
const capabilities = capabilitiesForPreset(args.preset ?? "full");
|
|
3412
|
+
this.insertStmt.run({
|
|
3413
|
+
device_id: deviceId,
|
|
3414
|
+
public_key: args.publicKey,
|
|
3415
|
+
token_hash: hashDeviceToken(deviceToken),
|
|
3416
|
+
name: args.name ?? null,
|
|
3417
|
+
capabilities: JSON.stringify(capabilities),
|
|
3418
|
+
created_at: args.now ?? Date.now()
|
|
3419
|
+
});
|
|
3420
|
+
return { deviceId, deviceToken, capabilities };
|
|
3421
|
+
}
|
|
3422
|
+
/**
|
|
3423
|
+
* Resolve a presented token to a device, or null.
|
|
3424
|
+
*
|
|
3425
|
+
* Returns null for a revoked device, so revocation takes effect on the very
|
|
3426
|
+
* next request with no cache to go stale.
|
|
3427
|
+
*/
|
|
3428
|
+
authenticate(token) {
|
|
3429
|
+
const hash = hashDeviceToken(token);
|
|
3430
|
+
const row = this.byTokenHashStmt.get(hash);
|
|
3431
|
+
if (!row) return null;
|
|
3432
|
+
if (!safeHashEquals(row.token_hash, hash)) return null;
|
|
3433
|
+
if (row.revoked_at != null) return null;
|
|
3434
|
+
return row;
|
|
3435
|
+
}
|
|
3436
|
+
get(deviceId) {
|
|
3437
|
+
return this.byIdStmt.get(deviceId) ?? null;
|
|
3438
|
+
}
|
|
3439
|
+
/** All devices, including revoked ones — an audit surface needs the history. */
|
|
3440
|
+
list() {
|
|
3441
|
+
return this.listStmt.all().map(toDeviceView);
|
|
3442
|
+
}
|
|
3443
|
+
/** Revoke one device. Others are untouched — no key rotation, no collateral. */
|
|
3444
|
+
revoke(deviceId, now = Date.now()) {
|
|
3445
|
+
return this.revokeStmt.run(now, deviceId).changes > 0;
|
|
3446
|
+
}
|
|
3447
|
+
touch(deviceId, now = Date.now()) {
|
|
3448
|
+
this.touchStmt.run(now, deviceId);
|
|
3449
|
+
}
|
|
3450
|
+
};
|
|
2995
3451
|
|
|
2996
3452
|
// src/api/middleware/auth.middleware.ts
|
|
2997
3453
|
function isLocalRequest(remoteAddr) {
|
|
@@ -3022,19 +3478,40 @@ var authMiddleware = (deps) => async (c, next) => {
|
|
|
3022
3478
|
}
|
|
3023
3479
|
}
|
|
3024
3480
|
const authorization = c.req.header("authorization");
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3481
|
+
const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : void 0;
|
|
3482
|
+
const queryKey = c.req.query("key") ?? void 0;
|
|
3483
|
+
const presented = bearer ?? queryKey;
|
|
3484
|
+
if (!presented) {
|
|
3485
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3486
|
+
}
|
|
3487
|
+
let principal = null;
|
|
3488
|
+
const device = deps.devicesRepo()?.authenticate(presented) ?? null;
|
|
3489
|
+
if (device) {
|
|
3490
|
+
principal = {
|
|
3491
|
+
kind: "device",
|
|
3492
|
+
deviceId: device.device_id,
|
|
3493
|
+
capabilities: parseCapabilities(device.capabilities)
|
|
3494
|
+
};
|
|
3495
|
+
try {
|
|
3496
|
+
deps.devicesRepo()?.touch(device.device_id);
|
|
3497
|
+
} catch {
|
|
3030
3498
|
}
|
|
3499
|
+
} else if (validateApiKey(presented, deps.apiKey)) {
|
|
3500
|
+
principal = legacyPrincipal();
|
|
3501
|
+
}
|
|
3502
|
+
if (!principal) {
|
|
3503
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3031
3504
|
}
|
|
3032
|
-
const
|
|
3033
|
-
if (
|
|
3505
|
+
const required = requiredCapability(path, method);
|
|
3506
|
+
if (required === null) {
|
|
3034
3507
|
await next();
|
|
3035
3508
|
return;
|
|
3036
3509
|
}
|
|
3037
|
-
|
|
3510
|
+
if (!hasCapability(principal, required)) {
|
|
3511
|
+
return c.json({ error: "Forbidden", code: "MISSING_CAPABILITY", required }, 403);
|
|
3512
|
+
}
|
|
3513
|
+
c.set("principal", principal);
|
|
3514
|
+
await next();
|
|
3038
3515
|
};
|
|
3039
3516
|
|
|
3040
3517
|
// src/api/middleware/cors.middleware.ts
|
|
@@ -3173,12 +3650,67 @@ var createCacheAlertRoutes = (deps) => {
|
|
|
3173
3650
|
return app;
|
|
3174
3651
|
};
|
|
3175
3652
|
|
|
3176
|
-
// src/api/routes/
|
|
3653
|
+
// src/api/routes/config.routes.ts
|
|
3177
3654
|
var import_hono4 = require("hono");
|
|
3655
|
+
|
|
3656
|
+
// src/schemas/claudeFlags.schema.ts
|
|
3657
|
+
var import_zod2 = require("zod");
|
|
3658
|
+
var ClaudeFlagsBodySchema = import_zod2.z.object({
|
|
3659
|
+
values: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.union([import_zod2.z.string(), import_zod2.z.boolean(), import_zod2.z.array(import_zod2.z.string())])).default({}),
|
|
3660
|
+
// A newline would corrupt the flat one-line-per-key server.yaml, so reject
|
|
3661
|
+
// it here with a field error instead of silently stripping it.
|
|
3662
|
+
extraArgs: import_zod2.z.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
|
|
3663
|
+
}).strict();
|
|
3664
|
+
|
|
3665
|
+
// src/api/routes/config.routes.ts
|
|
3666
|
+
function readRawBody3(req) {
|
|
3667
|
+
return new Promise((resolve2, reject) => {
|
|
3668
|
+
const chunks = [];
|
|
3669
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3670
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
3671
|
+
req.on("error", reject);
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
var createConfigRoutes = (deps) => {
|
|
3675
|
+
const app = new import_hono4.Hono();
|
|
3676
|
+
app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
|
|
3677
|
+
app.put("/claude-flags", async (c) => {
|
|
3678
|
+
if (deps.localNoAuth) {
|
|
3679
|
+
return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
|
|
3680
|
+
}
|
|
3681
|
+
let body;
|
|
3682
|
+
try {
|
|
3683
|
+
const incoming = c.env?.incoming;
|
|
3684
|
+
const raw = incoming ? await readRawBody3(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
|
|
3685
|
+
body = raw ? JSON.parse(raw) : {};
|
|
3686
|
+
} catch {
|
|
3687
|
+
return c.json({ error: "invalid json" }, 400);
|
|
3688
|
+
}
|
|
3689
|
+
const parsed = ClaudeFlagsBodySchema.safeParse(body);
|
|
3690
|
+
if (!parsed.success) {
|
|
3691
|
+
return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
|
|
3692
|
+
}
|
|
3693
|
+
try {
|
|
3694
|
+
const result = deps.setClaudeFlagsConfig(parsed.data.values, parsed.data.extraArgs);
|
|
3695
|
+
return c.json({
|
|
3696
|
+
...result,
|
|
3697
|
+
...result.persisted ? {} : {
|
|
3698
|
+
warning: "Flags applied in memory only. The server was started with --claude-flag, so the CLI values will be restored on restart. Drop the flag and let the server manage them via ~/.threadbase/server.yaml for changes to survive restarts."
|
|
3699
|
+
}
|
|
3700
|
+
});
|
|
3701
|
+
} catch (err) {
|
|
3702
|
+
return c.json({ error: err instanceof Error ? err.message : "could not apply flags" }, 400);
|
|
3703
|
+
}
|
|
3704
|
+
});
|
|
3705
|
+
return app;
|
|
3706
|
+
};
|
|
3707
|
+
|
|
3708
|
+
// src/api/routes/conversations.routes.ts
|
|
3709
|
+
var import_hono5 = require("hono");
|
|
3178
3710
|
var ALREADY_HANDLED2 = 597;
|
|
3179
3711
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
3180
3712
|
var createConversationRoutes = (deps) => {
|
|
3181
|
-
const app = new
|
|
3713
|
+
const app = new import_hono5.Hono();
|
|
3182
3714
|
app.get("/count", async (c) => {
|
|
3183
3715
|
const url = new URL(c.req.url);
|
|
3184
3716
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -3204,8 +3736,34 @@ var createConversationRoutes = (deps) => {
|
|
|
3204
3736
|
return app;
|
|
3205
3737
|
};
|
|
3206
3738
|
|
|
3739
|
+
// src/api/routes/devices.routes.ts
|
|
3740
|
+
var import_hono6 = require("hono");
|
|
3741
|
+
var createDeviceRoutes = (deps) => {
|
|
3742
|
+
const app = new import_hono6.Hono();
|
|
3743
|
+
app.get("/", (c) => {
|
|
3744
|
+
const repo = deps.devicesRepo();
|
|
3745
|
+
if (!repo) return c.json({ devices: [], available: false });
|
|
3746
|
+
return c.json({ devices: repo.list(), available: true });
|
|
3747
|
+
});
|
|
3748
|
+
app.post("/:id/revoke", (c) => {
|
|
3749
|
+
const repo = deps.devicesRepo();
|
|
3750
|
+
if (!repo) {
|
|
3751
|
+
return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
3752
|
+
}
|
|
3753
|
+
const id = c.req.param("id");
|
|
3754
|
+
const existing = repo.get(id);
|
|
3755
|
+
if (!existing) return c.json({ error: "Device not found" }, 404);
|
|
3756
|
+
if (existing.revoked_at != null) {
|
|
3757
|
+
return c.json({ ok: true, alreadyRevoked: true });
|
|
3758
|
+
}
|
|
3759
|
+
repo.revoke(id);
|
|
3760
|
+
return c.json({ ok: true, alreadyRevoked: false });
|
|
3761
|
+
});
|
|
3762
|
+
return app;
|
|
3763
|
+
};
|
|
3764
|
+
|
|
3207
3765
|
// src/api/routes/health.routes.ts
|
|
3208
|
-
var
|
|
3766
|
+
var import_hono7 = require("hono");
|
|
3209
3767
|
|
|
3210
3768
|
// src/version.ts
|
|
3211
3769
|
var import_node_fs2 = require("fs");
|
|
@@ -3242,7 +3800,7 @@ function resolveVersion() {
|
|
|
3242
3800
|
|
|
3243
3801
|
// src/api/routes/health.routes.ts
|
|
3244
3802
|
var createHealthRoutes = (deps) => {
|
|
3245
|
-
const app = new
|
|
3803
|
+
const app = new import_hono7.Hono();
|
|
3246
3804
|
app.get("/", (c) => {
|
|
3247
3805
|
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3248
3806
|
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
@@ -3253,7 +3811,7 @@ var createHealthRoutes = (deps) => {
|
|
|
3253
3811
|
// src/api/routes/logs.routes.ts
|
|
3254
3812
|
var import_node_fs3 = require("fs");
|
|
3255
3813
|
var import_node_path5 = require("path");
|
|
3256
|
-
var
|
|
3814
|
+
var import_hono8 = require("hono");
|
|
3257
3815
|
|
|
3258
3816
|
// src/lifecycle/constants.ts
|
|
3259
3817
|
var import_node_os = require("os");
|
|
@@ -3311,7 +3869,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3311
3869
|
}
|
|
3312
3870
|
}
|
|
3313
3871
|
function createLogsRoutes() {
|
|
3314
|
-
const app = new
|
|
3872
|
+
const app = new import_hono8.Hono();
|
|
3315
3873
|
app.get("/", (c) => {
|
|
3316
3874
|
try {
|
|
3317
3875
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3382,7 +3940,7 @@ function createLogsRoutes() {
|
|
|
3382
3940
|
// src/api/routes/misc.routes.ts
|
|
3383
3941
|
var import_node_child_process = require("child_process");
|
|
3384
3942
|
var import_node_crypto2 = require("crypto");
|
|
3385
|
-
var
|
|
3943
|
+
var import_hono9 = require("hono");
|
|
3386
3944
|
var import_os5 = require("os");
|
|
3387
3945
|
|
|
3388
3946
|
// src/config/update-config.ts
|
|
@@ -3392,15 +3950,15 @@ var import_node_path6 = require("path");
|
|
|
3392
3950
|
var import_yaml = require("yaml");
|
|
3393
3951
|
|
|
3394
3952
|
// src/schemas/updateConfig.schema.ts
|
|
3395
|
-
var
|
|
3396
|
-
var UpdateConfigSchema =
|
|
3397
|
-
auto_update:
|
|
3398
|
-
channel:
|
|
3399
|
-
allow:
|
|
3400
|
-
poll_interval_minutes:
|
|
3401
|
-
defer_if_active_sessions:
|
|
3402
|
-
github_repo:
|
|
3403
|
-
webhook_secret:
|
|
3953
|
+
var import_zod3 = require("zod");
|
|
3954
|
+
var UpdateConfigSchema = import_zod3.z.object({
|
|
3955
|
+
auto_update: import_zod3.z.boolean().default(false),
|
|
3956
|
+
channel: import_zod3.z.enum(["stable", "next"]).default("stable"),
|
|
3957
|
+
allow: import_zod3.z.array(import_zod3.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
|
|
3958
|
+
poll_interval_minutes: import_zod3.z.number().int().min(0).default(1440),
|
|
3959
|
+
defer_if_active_sessions: import_zod3.z.boolean().default(true),
|
|
3960
|
+
github_repo: import_zod3.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
|
|
3961
|
+
webhook_secret: import_zod3.z.string().min(1).nullable().default(null)
|
|
3404
3962
|
}).strict();
|
|
3405
3963
|
|
|
3406
3964
|
// src/config/update-config.ts
|
|
@@ -3437,7 +3995,7 @@ function readJsonBody(req) {
|
|
|
3437
3995
|
req.on("error", reject);
|
|
3438
3996
|
});
|
|
3439
3997
|
}
|
|
3440
|
-
function
|
|
3998
|
+
function readRawBody4(req) {
|
|
3441
3999
|
return new Promise((resolve2, reject) => {
|
|
3442
4000
|
const chunks = [];
|
|
3443
4001
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3456,7 +4014,7 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3456
4014
|
}
|
|
3457
4015
|
var clientLog = getLogger("client");
|
|
3458
4016
|
var createMiscRoutes = (deps) => {
|
|
3459
|
-
const app = new
|
|
4017
|
+
const app = new import_hono9.Hono();
|
|
3460
4018
|
app.get("/api/info", (c) => {
|
|
3461
4019
|
const ptyIds = deps.ptyAttachedIds();
|
|
3462
4020
|
return c.json({
|
|
@@ -3464,7 +4022,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3464
4022
|
machineName: (0, import_os5.hostname)(),
|
|
3465
4023
|
platform: process.platform,
|
|
3466
4024
|
activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
|
|
3467
|
-
publicUrl: deps.publicUrl
|
|
4025
|
+
publicUrl: deps.publicUrl,
|
|
4026
|
+
// Capability flag: this server serves /api/config/claude-flags. Additive —
|
|
4027
|
+
// older clients ignore it, and clients talking to an older server see it
|
|
4028
|
+
// absent and hide the UI rather than 404ing.
|
|
4029
|
+
claudeFlags: true
|
|
3468
4030
|
});
|
|
3469
4031
|
});
|
|
3470
4032
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -3481,7 +4043,32 @@ var createMiscRoutes = (deps) => {
|
|
|
3481
4043
|
}
|
|
3482
4044
|
});
|
|
3483
4045
|
});
|
|
3484
|
-
app.post("/api/push/register", (c) =>
|
|
4046
|
+
app.post("/api/push/register", async (c) => {
|
|
4047
|
+
const body = await readJsonBody(c.env.incoming).catch(() => null);
|
|
4048
|
+
const token = body?.token;
|
|
4049
|
+
const platform3 = body?.platform;
|
|
4050
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
4051
|
+
return c.json({ error: "Missing token" }, 400);
|
|
4052
|
+
}
|
|
4053
|
+
if (platform3 !== "ios" && platform3 !== "android") {
|
|
4054
|
+
return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
|
|
4055
|
+
}
|
|
4056
|
+
const repo = deps.pushRepo();
|
|
4057
|
+
if (!repo) {
|
|
4058
|
+
return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
4059
|
+
}
|
|
4060
|
+
repo.register({
|
|
4061
|
+
token,
|
|
4062
|
+
platform: platform3,
|
|
4063
|
+
deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
|
|
4064
|
+
});
|
|
4065
|
+
return c.json({ ok: true });
|
|
4066
|
+
});
|
|
4067
|
+
app.get("/api/push/health", (c) => {
|
|
4068
|
+
const repo = deps.pushRepo();
|
|
4069
|
+
if (!repo) return c.json({ tokens: [], available: false });
|
|
4070
|
+
return c.json({ tokens: repo.listHealth(), available: true });
|
|
4071
|
+
});
|
|
3485
4072
|
app.post("/api/__update", async (c) => {
|
|
3486
4073
|
const cfg = loadUpdateConfig();
|
|
3487
4074
|
if (!cfg?.webhook_secret) {
|
|
@@ -3489,7 +4076,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3489
4076
|
}
|
|
3490
4077
|
let body;
|
|
3491
4078
|
try {
|
|
3492
|
-
body = await
|
|
4079
|
+
body = await readRawBody4(c.env.incoming);
|
|
3493
4080
|
} catch {
|
|
3494
4081
|
return c.json({ error: "could not read body" }, 400);
|
|
3495
4082
|
}
|
|
@@ -3532,11 +4119,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3532
4119
|
};
|
|
3533
4120
|
|
|
3534
4121
|
// src/api/routes/pair.routes.ts
|
|
3535
|
-
var
|
|
4122
|
+
var import_hono10 = require("hono");
|
|
3536
4123
|
var ALREADY_HANDLED3 = 597;
|
|
3537
4124
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3538
4125
|
var createPairRoutes = (deps) => {
|
|
3539
|
-
const app = new
|
|
4126
|
+
const app = new import_hono10.Hono();
|
|
3540
4127
|
app.post("/start", (c) => {
|
|
3541
4128
|
deps.handlePairStart(c.env.outgoing);
|
|
3542
4129
|
return alreadyHandled3();
|
|
@@ -3549,11 +4136,11 @@ var createPairRoutes = (deps) => {
|
|
|
3549
4136
|
};
|
|
3550
4137
|
|
|
3551
4138
|
// src/api/routes/projects.routes.ts
|
|
3552
|
-
var
|
|
4139
|
+
var import_hono11 = require("hono");
|
|
3553
4140
|
var ALREADY_HANDLED4 = 597;
|
|
3554
4141
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3555
4142
|
var createProjectRoutes = (deps) => {
|
|
3556
|
-
const app = new
|
|
4143
|
+
const app = new import_hono11.Hono();
|
|
3557
4144
|
app.get("/", (c) => {
|
|
3558
4145
|
const url = new URL(c.req.url);
|
|
3559
4146
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3567,12 +4154,147 @@ var createProjectRoutes = (deps) => {
|
|
|
3567
4154
|
return app;
|
|
3568
4155
|
};
|
|
3569
4156
|
|
|
4157
|
+
// src/api/routes/providers.routes.ts
|
|
4158
|
+
var import_hono12 = require("hono");
|
|
4159
|
+
|
|
4160
|
+
// src/services/providers/providerHealth.ts
|
|
4161
|
+
var import_child_process3 = require("child_process");
|
|
4162
|
+
|
|
4163
|
+
// src/services/providers/capabilities.ts
|
|
4164
|
+
var CLAUDE_CODE_CAPABILITIES = {
|
|
4165
|
+
freshSessionId: "explicit",
|
|
4166
|
+
resume: "native",
|
|
4167
|
+
systemPrompt: "flag",
|
|
4168
|
+
structuredQuestions: true,
|
|
4169
|
+
permissionGates: true,
|
|
4170
|
+
liveControl: true
|
|
4171
|
+
};
|
|
4172
|
+
var CODEX_CLI_CAPABILITIES = {
|
|
4173
|
+
freshSessionId: "late-bound",
|
|
4174
|
+
resume: "native",
|
|
4175
|
+
systemPrompt: "positional",
|
|
4176
|
+
structuredQuestions: false,
|
|
4177
|
+
permissionGates: true,
|
|
4178
|
+
liveControl: true
|
|
4179
|
+
};
|
|
4180
|
+
function capabilitiesFor(provider) {
|
|
4181
|
+
switch (provider) {
|
|
4182
|
+
case CLAUDE_CODE_PROVIDER:
|
|
4183
|
+
return CLAUDE_CODE_CAPABILITIES;
|
|
4184
|
+
case CODEX_CLI_PROVIDER:
|
|
4185
|
+
return CODEX_CLI_CAPABILITIES;
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
// src/services/providers/providerHealth.ts
|
|
4190
|
+
var VERIFIED_AGAINST = {
|
|
4191
|
+
[CLAUDE_CODE_PROVIDER]: { captured: ["2.1.214"], min: "2.1.0" },
|
|
4192
|
+
[CODEX_CLI_PROVIDER]: { captured: ["0.140.0-alpha.19"], min: "0.140.0" }
|
|
4193
|
+
};
|
|
4194
|
+
var VERSION_TIMEOUT_MS = 3e3;
|
|
4195
|
+
function parseVersionOutput(output) {
|
|
4196
|
+
const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
4197
|
+
return match ? match[0] : null;
|
|
4198
|
+
}
|
|
4199
|
+
function runVersion(exe) {
|
|
4200
|
+
return new Promise((resolve2) => {
|
|
4201
|
+
(0, import_child_process3.execFile)(exe, ["--version"], { timeout: VERSION_TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
4202
|
+
if (err && !stdout && !stderr) return resolve2(null);
|
|
4203
|
+
resolve2(parseVersionOutput(`${stdout}${stderr}`));
|
|
4204
|
+
});
|
|
4205
|
+
});
|
|
4206
|
+
}
|
|
4207
|
+
function compareToVerified(version, verified) {
|
|
4208
|
+
if (version === null) {
|
|
4209
|
+
return {
|
|
4210
|
+
code: "version_undetectable",
|
|
4211
|
+
message: "Could not determine the installed version, so compatibility is unverified. Parsing and prompt detection may not match this build."
|
|
4212
|
+
};
|
|
4213
|
+
}
|
|
4214
|
+
if (verified.captured.includes(version)) return null;
|
|
4215
|
+
const below = verified.min != null && compareSemver(version, verified.min) < 0;
|
|
4216
|
+
const above = verified.max != null && compareSemver(version, verified.max) > 0;
|
|
4217
|
+
if (!below && !above && verified.max != null) return null;
|
|
4218
|
+
if (!below && verified.max == null && !isNewerThanAllCaptured(version, verified.captured)) {
|
|
4219
|
+
return null;
|
|
4220
|
+
}
|
|
4221
|
+
return {
|
|
4222
|
+
code: "version_unverified",
|
|
4223
|
+
message: `Installed version ${version} is outside the range these adapters were verified against (captured: ${verified.captured.join(", ")}). It will still run; parsing or prompt detection may differ.`
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
function isNewerThanAllCaptured(version, captured) {
|
|
4227
|
+
return captured.every((c) => compareSemver(version, c) > 0);
|
|
4228
|
+
}
|
|
4229
|
+
function compareSemver(a, b) {
|
|
4230
|
+
const parse = (v) => {
|
|
4231
|
+
const [core, pre] = v.split("-", 2);
|
|
4232
|
+
const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
4233
|
+
return { nums, pre: pre ?? null };
|
|
4234
|
+
};
|
|
4235
|
+
const pa = parse(a);
|
|
4236
|
+
const pb = parse(b);
|
|
4237
|
+
for (let i = 0; i < 3; i++) {
|
|
4238
|
+
const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
|
|
4239
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
4240
|
+
}
|
|
4241
|
+
if (pa.pre === pb.pre) return 0;
|
|
4242
|
+
if (pa.pre === null) return 1;
|
|
4243
|
+
if (pb.pre === null) return -1;
|
|
4244
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
4245
|
+
}
|
|
4246
|
+
async function providerHealth(name, resolveExe, detect = runVersion) {
|
|
4247
|
+
const verifiedAgainst = VERIFIED_AGAINST[name];
|
|
4248
|
+
const capabilities = capabilitiesFor(name);
|
|
4249
|
+
let exe;
|
|
4250
|
+
try {
|
|
4251
|
+
exe = resolveExe();
|
|
4252
|
+
} catch {
|
|
4253
|
+
return {
|
|
4254
|
+
name,
|
|
4255
|
+
available: false,
|
|
4256
|
+
version: null,
|
|
4257
|
+
verifiedAgainst,
|
|
4258
|
+
capabilities,
|
|
4259
|
+
warnings: [
|
|
4260
|
+
{
|
|
4261
|
+
code: "provider_not_found",
|
|
4262
|
+
message: `${name} could not be located. Sessions for this provider cannot start.`
|
|
4263
|
+
}
|
|
4264
|
+
]
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
const version = await detect(exe);
|
|
4268
|
+
const warning = compareToVerified(version, verifiedAgainst);
|
|
4269
|
+
return {
|
|
4270
|
+
name,
|
|
4271
|
+
available: true,
|
|
4272
|
+
version,
|
|
4273
|
+
verifiedAgainst,
|
|
4274
|
+
capabilities,
|
|
4275
|
+
warnings: warning ? [warning] : []
|
|
4276
|
+
};
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
// src/api/routes/providers.routes.ts
|
|
4280
|
+
var createProviderRoutes = () => {
|
|
4281
|
+
const app = new import_hono12.Hono();
|
|
4282
|
+
app.get("/", async (c) => {
|
|
4283
|
+
const providers = await Promise.all([
|
|
4284
|
+
providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
|
|
4285
|
+
providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
|
|
4286
|
+
]);
|
|
4287
|
+
return c.json({ providers });
|
|
4288
|
+
});
|
|
4289
|
+
return app;
|
|
4290
|
+
};
|
|
4291
|
+
|
|
3570
4292
|
// src/api/routes/scanner.routes.ts
|
|
3571
|
-
var
|
|
4293
|
+
var import_hono13 = require("hono");
|
|
3572
4294
|
var ALREADY_HANDLED5 = 597;
|
|
3573
4295
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3574
4296
|
var createScannerRoutes = (deps) => {
|
|
3575
|
-
const app = new
|
|
4297
|
+
const app = new import_hono13.Hono();
|
|
3576
4298
|
app.get("/api/search", async (c) => {
|
|
3577
4299
|
const url = new URL(c.req.url);
|
|
3578
4300
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3582,11 +4304,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3582
4304
|
};
|
|
3583
4305
|
|
|
3584
4306
|
// src/api/routes/sessions.routes.ts
|
|
3585
|
-
var
|
|
4307
|
+
var import_hono14 = require("hono");
|
|
3586
4308
|
var ALREADY_HANDLED6 = 597;
|
|
3587
4309
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3588
4310
|
var createSessionRoutes = (deps) => {
|
|
3589
|
-
const app = new
|
|
4311
|
+
const app = new import_hono14.Hono();
|
|
3590
4312
|
app.get("/count", (c) => {
|
|
3591
4313
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3592
4314
|
return alreadyHandled6();
|
|
@@ -3653,9 +4375,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3653
4375
|
};
|
|
3654
4376
|
|
|
3655
4377
|
// src/api/routes/ws.routes.ts
|
|
3656
|
-
var
|
|
4378
|
+
var import_hono15 = require("hono");
|
|
3657
4379
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3658
|
-
const app = new
|
|
4380
|
+
const app = new import_hono15.Hono();
|
|
3659
4381
|
app.get(
|
|
3660
4382
|
"/ws",
|
|
3661
4383
|
upgradeWebSocket(() => {
|
|
@@ -3681,7 +4403,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3681
4403
|
|
|
3682
4404
|
// src/api/app.ts
|
|
3683
4405
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3684
|
-
const app = new
|
|
4406
|
+
const app = new import_hono16.Hono();
|
|
3685
4407
|
const httpLog = getLogger("http");
|
|
3686
4408
|
app.use("*", async (c, next) => {
|
|
3687
4409
|
const start = Date.now();
|
|
@@ -3706,7 +4428,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3706
4428
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3707
4429
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3708
4430
|
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
4431
|
+
app.route("/api/config", createConfigRoutes(deps));
|
|
3709
4432
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
4433
|
+
app.route("/api/providers", createProviderRoutes());
|
|
4434
|
+
app.route("/api/devices", createDeviceRoutes(deps));
|
|
3710
4435
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3711
4436
|
app.route("/api", createBrowseRoutes(deps));
|
|
3712
4437
|
app.route("/", createScannerRoutes(deps));
|
|
@@ -3914,11 +4639,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
|
3914
4639
|
}
|
|
3915
4640
|
|
|
3916
4641
|
// src/utils/fileIdentity.ts
|
|
3917
|
-
var
|
|
4642
|
+
var import_crypto6 = require("crypto");
|
|
3918
4643
|
function fileIdentity(stat3, headBytes) {
|
|
3919
4644
|
if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
|
|
3920
4645
|
const head = headBytes ?? Buffer.alloc(0);
|
|
3921
|
-
return `fp:${(0,
|
|
4646
|
+
return `fp:${(0, import_crypto6.createHash)("sha1").update(head).digest("hex")}`;
|
|
3922
4647
|
}
|
|
3923
4648
|
function splitCompleteLines(buf, baseOffset) {
|
|
3924
4649
|
const spans = [];
|
|
@@ -5200,8 +5925,122 @@ var ConversationsRepository = class {
|
|
|
5200
5925
|
}
|
|
5201
5926
|
};
|
|
5202
5927
|
|
|
5928
|
+
// src/db/repositories/managed-sessions.repository.ts
|
|
5929
|
+
var ManagedSessionsRepository = class {
|
|
5930
|
+
upsertStmt;
|
|
5931
|
+
updateStatusStmt;
|
|
5932
|
+
getStmt;
|
|
5933
|
+
listNonTerminalStmt;
|
|
5934
|
+
deleteStmt;
|
|
5935
|
+
constructor(db) {
|
|
5936
|
+
this.upsertStmt = db.prepare(`
|
|
5937
|
+
INSERT INTO managed_sessions (
|
|
5938
|
+
session_id, provider, pid, cmdline, project_path, project_name, branch,
|
|
5939
|
+
status, status_source, status_updated_at, started_at, completed_at,
|
|
5940
|
+
last_activity_at, prompt_count, session_name, project_id,
|
|
5941
|
+
bound_conversation_id, resumed_from_conversation_id, failure_reason,
|
|
5942
|
+
streamer_instance_id
|
|
5943
|
+
) VALUES (
|
|
5944
|
+
@session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
|
|
5945
|
+
@status, @status_source, @status_updated_at, @started_at, @completed_at,
|
|
5946
|
+
@last_activity_at, @prompt_count, @session_name, @project_id,
|
|
5947
|
+
@bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
|
|
5948
|
+
@streamer_instance_id
|
|
5949
|
+
)
|
|
5950
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
5951
|
+
pid = excluded.pid,
|
|
5952
|
+
cmdline = excluded.cmdline,
|
|
5953
|
+
project_path = excluded.project_path,
|
|
5954
|
+
project_name = excluded.project_name,
|
|
5955
|
+
branch = excluded.branch,
|
|
5956
|
+
status = excluded.status,
|
|
5957
|
+
status_source = excluded.status_source,
|
|
5958
|
+
status_updated_at = excluded.status_updated_at,
|
|
5959
|
+
completed_at = excluded.completed_at,
|
|
5960
|
+
last_activity_at = excluded.last_activity_at,
|
|
5961
|
+
prompt_count = excluded.prompt_count,
|
|
5962
|
+
session_name = excluded.session_name,
|
|
5963
|
+
project_id = excluded.project_id,
|
|
5964
|
+
bound_conversation_id = excluded.bound_conversation_id,
|
|
5965
|
+
resumed_from_conversation_id = excluded.resumed_from_conversation_id,
|
|
5966
|
+
failure_reason = excluded.failure_reason,
|
|
5967
|
+
streamer_instance_id = excluded.streamer_instance_id
|
|
5968
|
+
`);
|
|
5969
|
+
this.updateStatusStmt = db.prepare(`
|
|
5970
|
+
UPDATE managed_sessions
|
|
5971
|
+
SET status = @status,
|
|
5972
|
+
status_source = @status_source,
|
|
5973
|
+
status_updated_at = @status_updated_at,
|
|
5974
|
+
completed_at = @completed_at,
|
|
5975
|
+
last_activity_at = @last_activity_at,
|
|
5976
|
+
prompt_count = @prompt_count,
|
|
5977
|
+
failure_reason = COALESCE(@failure_reason, failure_reason)
|
|
5978
|
+
WHERE session_id = @session_id
|
|
5979
|
+
`);
|
|
5980
|
+
this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
|
|
5981
|
+
this.listNonTerminalStmt = db.prepare(`
|
|
5982
|
+
SELECT * FROM managed_sessions
|
|
5983
|
+
WHERE completed_at IS NULL
|
|
5984
|
+
ORDER BY started_at ASC
|
|
5985
|
+
`);
|
|
5986
|
+
this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
|
|
5987
|
+
}
|
|
5988
|
+
/** Record a session at spawn, or refresh every field of an existing row. */
|
|
5989
|
+
recordSpawn({ session, pid, cmdline, streamerInstanceId }) {
|
|
5990
|
+
this.upsertStmt.run({
|
|
5991
|
+
session_id: session.id,
|
|
5992
|
+
provider: session.provider ?? "claude-code",
|
|
5993
|
+
pid,
|
|
5994
|
+
cmdline,
|
|
5995
|
+
project_path: session.projectPath,
|
|
5996
|
+
project_name: session.projectName,
|
|
5997
|
+
branch: session.branch ?? "",
|
|
5998
|
+
status: session.status,
|
|
5999
|
+
status_source: "spawn",
|
|
6000
|
+
status_updated_at: Date.now(),
|
|
6001
|
+
started_at: session.startedAt.getTime(),
|
|
6002
|
+
completed_at: session.completedAt?.getTime() ?? null,
|
|
6003
|
+
last_activity_at: session.lastActivityAt?.getTime() ?? null,
|
|
6004
|
+
prompt_count: session.promptCount,
|
|
6005
|
+
session_name: session.sessionName ?? null,
|
|
6006
|
+
project_id: session.projectId ?? null,
|
|
6007
|
+
bound_conversation_id: session.boundConversationId ?? null,
|
|
6008
|
+
resumed_from_conversation_id: session.resumedFromConversationId ?? null,
|
|
6009
|
+
failure_reason: session.failureReason ?? null,
|
|
6010
|
+
streamer_instance_id: streamerInstanceId
|
|
6011
|
+
});
|
|
6012
|
+
}
|
|
6013
|
+
/**
|
|
6014
|
+
* Persist a status transition. `source` is required rather than defaulted:
|
|
6015
|
+
* a status whose provenance is unknown is the thing this table exists to
|
|
6016
|
+
* prevent, and the reconciler reads it to decide how much to trust the value.
|
|
6017
|
+
*/
|
|
6018
|
+
recordStatus(sessionId, status, source, fields = {}) {
|
|
6019
|
+
this.updateStatusStmt.run({
|
|
6020
|
+
session_id: sessionId,
|
|
6021
|
+
status,
|
|
6022
|
+
status_source: source,
|
|
6023
|
+
status_updated_at: Date.now(),
|
|
6024
|
+
completed_at: fields.completedAt?.getTime() ?? null,
|
|
6025
|
+
last_activity_at: fields.lastActivityAt?.getTime() ?? null,
|
|
6026
|
+
prompt_count: fields.promptCount ?? 0,
|
|
6027
|
+
failure_reason: fields.failureReason ?? null
|
|
6028
|
+
});
|
|
6029
|
+
}
|
|
6030
|
+
get(sessionId) {
|
|
6031
|
+
return this.getStmt.get(sessionId) ?? null;
|
|
6032
|
+
}
|
|
6033
|
+
/** Rows with no recorded completion — the reconciler's probe set. */
|
|
6034
|
+
listNonTerminal() {
|
|
6035
|
+
return this.listNonTerminalStmt.all();
|
|
6036
|
+
}
|
|
6037
|
+
delete(sessionId) {
|
|
6038
|
+
this.deleteStmt.run(sessionId);
|
|
6039
|
+
}
|
|
6040
|
+
};
|
|
6041
|
+
|
|
5203
6042
|
// src/db/repositories/projects.repository.ts
|
|
5204
|
-
var
|
|
6043
|
+
var import_crypto7 = require("crypto");
|
|
5205
6044
|
|
|
5206
6045
|
// src/utils/canonicalizeProjectPath.ts
|
|
5207
6046
|
function canonicalizeProjectPath(projectPath) {
|
|
@@ -5294,7 +6133,7 @@ var ProjectsRepository = class {
|
|
|
5294
6133
|
});
|
|
5295
6134
|
return rowToProject(this.getById.get(existing.id));
|
|
5296
6135
|
}
|
|
5297
|
-
const id = (0,
|
|
6136
|
+
const id = (0, import_crypto7.randomUUID)();
|
|
5298
6137
|
this.insert.run({
|
|
5299
6138
|
id,
|
|
5300
6139
|
path,
|
|
@@ -5316,6 +6155,125 @@ function deriveNameFromPath(path) {
|
|
|
5316
6155
|
return parts.length > 0 ? parts[parts.length - 1] : null;
|
|
5317
6156
|
}
|
|
5318
6157
|
|
|
6158
|
+
// src/db/repositories/push.repository.ts
|
|
6159
|
+
var FAILURE_STREAK_LIMIT = 5;
|
|
6160
|
+
function tokenState(row) {
|
|
6161
|
+
if (row.revoked_at != null) return "revoked";
|
|
6162
|
+
if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
|
|
6163
|
+
if (row.failure_streak > 0) return "failing";
|
|
6164
|
+
if (row.last_success_at == null) return "never-delivered";
|
|
6165
|
+
return "healthy";
|
|
6166
|
+
}
|
|
6167
|
+
function toHealth(row) {
|
|
6168
|
+
return {
|
|
6169
|
+
platform: row.platform,
|
|
6170
|
+
deviceId: row.device_id,
|
|
6171
|
+
registeredAt: row.registered_at,
|
|
6172
|
+
lastSuccessAt: row.last_success_at,
|
|
6173
|
+
lastFailureAt: row.last_failure_at,
|
|
6174
|
+
lastFailureCode: row.last_failure_code,
|
|
6175
|
+
failureStreak: row.failure_streak,
|
|
6176
|
+
revokedAt: row.revoked_at,
|
|
6177
|
+
state: tokenState(row)
|
|
6178
|
+
};
|
|
6179
|
+
}
|
|
6180
|
+
var PushRepository = class {
|
|
6181
|
+
upsertStmt;
|
|
6182
|
+
getStmt;
|
|
6183
|
+
listActiveStmt;
|
|
6184
|
+
listAllStmt;
|
|
6185
|
+
successStmt;
|
|
6186
|
+
failureStmt;
|
|
6187
|
+
revokeStmt;
|
|
6188
|
+
claimEventStmt;
|
|
6189
|
+
markDeliveredStmt;
|
|
6190
|
+
constructor(db) {
|
|
6191
|
+
this.upsertStmt = db.prepare(`
|
|
6192
|
+
INSERT INTO push_tokens (token, platform, device_id, registered_at)
|
|
6193
|
+
VALUES (@token, @platform, @device_id, @registered_at)
|
|
6194
|
+
ON CONFLICT(token) DO UPDATE SET
|
|
6195
|
+
platform = excluded.platform,
|
|
6196
|
+
device_id = COALESCE(excluded.device_id, push_tokens.device_id),
|
|
6197
|
+
registered_at = excluded.registered_at,
|
|
6198
|
+
-- A fresh registration clears prior failure state and any revocation:
|
|
6199
|
+
-- the client is telling us this token is live again.
|
|
6200
|
+
failure_streak = 0,
|
|
6201
|
+
last_failure_at = NULL,
|
|
6202
|
+
last_failure_code = NULL,
|
|
6203
|
+
revoked_at = NULL
|
|
6204
|
+
`);
|
|
6205
|
+
this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
|
|
6206
|
+
this.listActiveStmt = db.prepare(`
|
|
6207
|
+
SELECT * FROM push_tokens
|
|
6208
|
+
WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
6209
|
+
ORDER BY registered_at ASC
|
|
6210
|
+
`);
|
|
6211
|
+
this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
|
|
6212
|
+
this.successStmt = db.prepare(`
|
|
6213
|
+
UPDATE push_tokens
|
|
6214
|
+
SET last_success_at = @at, failure_streak = 0,
|
|
6215
|
+
last_failure_code = NULL
|
|
6216
|
+
WHERE token = @token
|
|
6217
|
+
`);
|
|
6218
|
+
this.failureStmt = db.prepare(`
|
|
6219
|
+
UPDATE push_tokens
|
|
6220
|
+
SET last_failure_at = @at, last_failure_code = @code,
|
|
6221
|
+
failure_streak = failure_streak + 1
|
|
6222
|
+
WHERE token = @token
|
|
6223
|
+
`);
|
|
6224
|
+
this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
|
|
6225
|
+
this.claimEventStmt = db.prepare(`
|
|
6226
|
+
INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
|
|
6227
|
+
VALUES (@event_id, @session_id, @created_at)
|
|
6228
|
+
`);
|
|
6229
|
+
this.markDeliveredStmt = db.prepare(
|
|
6230
|
+
"UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
|
|
6231
|
+
);
|
|
6232
|
+
}
|
|
6233
|
+
register(args) {
|
|
6234
|
+
this.upsertStmt.run({
|
|
6235
|
+
token: args.token,
|
|
6236
|
+
platform: args.platform,
|
|
6237
|
+
device_id: args.deviceId ?? null,
|
|
6238
|
+
registered_at: args.now ?? Date.now()
|
|
6239
|
+
});
|
|
6240
|
+
}
|
|
6241
|
+
get(token) {
|
|
6242
|
+
return this.getStmt.get(token) ?? null;
|
|
6243
|
+
}
|
|
6244
|
+
/** Tokens eligible for delivery — not revoked, not past the failure limit. */
|
|
6245
|
+
listDeliverable() {
|
|
6246
|
+
return this.listActiveStmt.all();
|
|
6247
|
+
}
|
|
6248
|
+
/** Every token, including dead and revoked ones, for the health report. */
|
|
6249
|
+
listHealth() {
|
|
6250
|
+
return this.listAllStmt.all().map(toHealth);
|
|
6251
|
+
}
|
|
6252
|
+
recordSuccess(token, now = Date.now()) {
|
|
6253
|
+
this.successStmt.run({ token, at: now });
|
|
6254
|
+
}
|
|
6255
|
+
recordFailure(token, code, now = Date.now()) {
|
|
6256
|
+
this.failureStmt.run({ token, at: now, code });
|
|
6257
|
+
}
|
|
6258
|
+
revoke(token, now = Date.now()) {
|
|
6259
|
+
return this.revokeStmt.run(now, token).changes > 0;
|
|
6260
|
+
}
|
|
6261
|
+
/**
|
|
6262
|
+
* Claim an event id for delivery.
|
|
6263
|
+
*
|
|
6264
|
+
* Returns true exactly once per event id. A retry, a reconnect
|
|
6265
|
+
* reconciliation, or two triggers firing for the same underlying event all
|
|
6266
|
+
* get false and must not notify — the user should never be told twice about
|
|
6267
|
+
* one thing.
|
|
6268
|
+
*/
|
|
6269
|
+
claimEvent(eventId, sessionId, now = Date.now()) {
|
|
6270
|
+
return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
|
|
6271
|
+
}
|
|
6272
|
+
markDelivered(eventId, now = Date.now()) {
|
|
6273
|
+
this.markDeliveredStmt.run(now, eventId);
|
|
6274
|
+
}
|
|
6275
|
+
};
|
|
6276
|
+
|
|
5319
6277
|
// src/db/repositories/sessions.repository.ts
|
|
5320
6278
|
var SessionsRepository = class {
|
|
5321
6279
|
constructor(store) {
|
|
@@ -5384,8 +6342,20 @@ function handleListProjects(url, res) {
|
|
|
5384
6342
|
res.end(JSON.stringify({ projects: page, total }));
|
|
5385
6343
|
}
|
|
5386
6344
|
|
|
6345
|
+
// src/lifecycle/process-liveness.ts
|
|
6346
|
+
function isPidAlive(pid) {
|
|
6347
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
6348
|
+
try {
|
|
6349
|
+
process.kill(pid, 0);
|
|
6350
|
+
return true;
|
|
6351
|
+
} catch (err) {
|
|
6352
|
+
const code = err.code;
|
|
6353
|
+
return code === "EPERM";
|
|
6354
|
+
}
|
|
6355
|
+
}
|
|
6356
|
+
|
|
5387
6357
|
// src/pair-store.ts
|
|
5388
|
-
var
|
|
6358
|
+
var import_crypto8 = require("crypto");
|
|
5389
6359
|
var DEFAULT_TTL_SECONDS = 180;
|
|
5390
6360
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
5391
6361
|
var PairTokenStore = class {
|
|
@@ -5400,7 +6370,7 @@ var PairTokenStore = class {
|
|
|
5400
6370
|
}
|
|
5401
6371
|
}
|
|
5402
6372
|
mint() {
|
|
5403
|
-
const token = `pt_${(0,
|
|
6373
|
+
const token = `pt_${(0, import_crypto8.randomBytes)(16).toString("hex")}`;
|
|
5404
6374
|
const expiresAt = Date.now() + this.ttlMs;
|
|
5405
6375
|
this.current = { token, expiresAt, used: false };
|
|
5406
6376
|
return {
|
|
@@ -5468,7 +6438,7 @@ function setCacheMetadata(repo, key, value) {
|
|
|
5468
6438
|
}
|
|
5469
6439
|
|
|
5470
6440
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5471
|
-
var
|
|
6441
|
+
var import_crypto9 = require("crypto");
|
|
5472
6442
|
var import_fs13 = require("fs");
|
|
5473
6443
|
|
|
5474
6444
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -5533,7 +6503,7 @@ function envInt(name, fallback) {
|
|
|
5533
6503
|
}
|
|
5534
6504
|
function fingerprintOf(ids) {
|
|
5535
6505
|
const sorted = [...ids].sort();
|
|
5536
|
-
return `sha256:${(0,
|
|
6506
|
+
return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5537
6507
|
}
|
|
5538
6508
|
var CacheIntegrityMonitor = class {
|
|
5539
6509
|
constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
|
|
@@ -6347,6 +7317,112 @@ function conversationBusy(input) {
|
|
|
6347
7317
|
};
|
|
6348
7318
|
}
|
|
6349
7319
|
|
|
7320
|
+
// src/services/sessions/idempotency.ts
|
|
7321
|
+
var IDEMPOTENCY_TTL_MS = 10 * 60 * 1e3;
|
|
7322
|
+
var IDEMPOTENCY_MAX_KEYS = 200;
|
|
7323
|
+
var IdempotencyStore = class {
|
|
7324
|
+
constructor(ttlMs = IDEMPOTENCY_TTL_MS, maxKeys = IDEMPOTENCY_MAX_KEYS) {
|
|
7325
|
+
this.ttlMs = ttlMs;
|
|
7326
|
+
this.maxKeys = maxKeys;
|
|
7327
|
+
}
|
|
7328
|
+
ttlMs;
|
|
7329
|
+
maxKeys;
|
|
7330
|
+
bySession = /* @__PURE__ */ new Map();
|
|
7331
|
+
/**
|
|
7332
|
+
* Previously recorded result for this key, or null if the key is new,
|
|
7333
|
+
* expired, or evicted. A miss always means "treat as a fresh request" —
|
|
7334
|
+
* failing open, because dropping a real prompt is far worse than allowing a
|
|
7335
|
+
* rare duplicate.
|
|
7336
|
+
*/
|
|
7337
|
+
get(sessionId, key, now = Date.now()) {
|
|
7338
|
+
const entries = this.bySession.get(sessionId);
|
|
7339
|
+
if (!entries) return null;
|
|
7340
|
+
const hit = entries.find((e) => e.key === key);
|
|
7341
|
+
if (!hit) return null;
|
|
7342
|
+
if (now - hit.at > this.ttlMs) {
|
|
7343
|
+
this.bySession.set(
|
|
7344
|
+
sessionId,
|
|
7345
|
+
entries.filter((e) => e !== hit)
|
|
7346
|
+
);
|
|
7347
|
+
return null;
|
|
7348
|
+
}
|
|
7349
|
+
return hit.result;
|
|
7350
|
+
}
|
|
7351
|
+
/** Record the outcome of an accepted write so a retry can replay it. */
|
|
7352
|
+
set(sessionId, key, result, now = Date.now()) {
|
|
7353
|
+
const entries = this.bySession.get(sessionId) ?? [];
|
|
7354
|
+
const pruned = entries.filter((e) => e.key !== key && now - e.at <= this.ttlMs);
|
|
7355
|
+
pruned.push({ key, at: now, result });
|
|
7356
|
+
this.bySession.set(sessionId, pruned.slice(-this.maxKeys));
|
|
7357
|
+
}
|
|
7358
|
+
/** Drop everything for a session whose PTY is gone. */
|
|
7359
|
+
clear(sessionId) {
|
|
7360
|
+
this.bySession.delete(sessionId);
|
|
7361
|
+
}
|
|
7362
|
+
/** Test/diagnostic helper: how many keys are currently held for a session. */
|
|
7363
|
+
size(sessionId) {
|
|
7364
|
+
return this.bySession.get(sessionId)?.length ?? 0;
|
|
7365
|
+
}
|
|
7366
|
+
};
|
|
7367
|
+
function readIdempotencyKey(body) {
|
|
7368
|
+
const raw = body.idempotencyKey;
|
|
7369
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
7370
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > 200) {
|
|
7371
|
+
throw new Error("idempotencyKey must be a non-empty string of at most 200 characters");
|
|
7372
|
+
}
|
|
7373
|
+
return raw;
|
|
7374
|
+
}
|
|
7375
|
+
|
|
7376
|
+
// src/services/sessions/reconcileSessions.ts
|
|
7377
|
+
async function classifySession(row, probe, currentInstanceId) {
|
|
7378
|
+
const { session_id: sessionId } = row;
|
|
7379
|
+
if (row.completed_at != null) {
|
|
7380
|
+
const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
|
|
7381
|
+
return {
|
|
7382
|
+
sessionId,
|
|
7383
|
+
lifecycle: clean ? "completed" : "failed",
|
|
7384
|
+
reason: `terminal (${row.status_source})`
|
|
7385
|
+
};
|
|
7386
|
+
}
|
|
7387
|
+
if (row.pid == null) {
|
|
7388
|
+
return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
|
|
7389
|
+
}
|
|
7390
|
+
if (!probe.isPidAlive(row.pid)) {
|
|
7391
|
+
const clean = probe.endedCleanly?.(row) ?? false;
|
|
7392
|
+
if (clean) {
|
|
7393
|
+
return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
|
|
7394
|
+
}
|
|
7395
|
+
return {
|
|
7396
|
+
sessionId,
|
|
7397
|
+
lifecycle: "resumable",
|
|
7398
|
+
reason: "process gone, resumable from provider history"
|
|
7399
|
+
};
|
|
7400
|
+
}
|
|
7401
|
+
const args = await probe.getProcessArgs(row.pid);
|
|
7402
|
+
const token = row.cmdline;
|
|
7403
|
+
if (!token || !args?.includes(token)) {
|
|
7404
|
+
return {
|
|
7405
|
+
sessionId,
|
|
7406
|
+
lifecycle: "orphaned",
|
|
7407
|
+
reason: args ? "pid alive but command line does not match" : "pid alive but argv unreadable"
|
|
7408
|
+
};
|
|
7409
|
+
}
|
|
7410
|
+
const sameRun = row.streamer_instance_id === currentInstanceId;
|
|
7411
|
+
return {
|
|
7412
|
+
sessionId,
|
|
7413
|
+
lifecycle: sameRun ? "attached" : "detached",
|
|
7414
|
+
reason: sameRun ? "owned by this run" : "survived a previous streamer run"
|
|
7415
|
+
};
|
|
7416
|
+
}
|
|
7417
|
+
async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
7418
|
+
return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
|
|
7419
|
+
}
|
|
7420
|
+
|
|
7421
|
+
// src/types.ts
|
|
7422
|
+
function confidenceForSource(source) {
|
|
7423
|
+
return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
|
|
7424
|
+
}
|
|
7425
|
+
|
|
6350
7426
|
// src/session-store.ts
|
|
6351
7427
|
var SessionStore = class {
|
|
6352
7428
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -6486,6 +7562,14 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6486
7562
|
conversationId: s.id,
|
|
6487
7563
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
6488
7564
|
status: s.status,
|
|
7565
|
+
// Lifecycle for a session this run knows about. `attached` while we hold
|
|
7566
|
+
// its PTY; once the PTY is gone the session is terminal from this run's
|
|
7567
|
+
// perspective — `failed` when it recorded a reason, else `completed`.
|
|
7568
|
+
// Sessions left by *previous* runs never reach here: they aren't in the
|
|
7569
|
+
// in-memory store, and the boot reconciler classifies them instead
|
|
7570
|
+
// (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
7571
|
+
lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
|
|
7572
|
+
lifecycleSource: ptyAttached ? "spawn" : "exit",
|
|
6489
7573
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6490
7574
|
// `activity` is attached for managed sessions.
|
|
6491
7575
|
ownership: "managed",
|
|
@@ -6509,6 +7593,13 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6509
7593
|
...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
|
|
6510
7594
|
...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
|
|
6511
7595
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt.toISOString() },
|
|
7596
|
+
// C3: how the status was derived, and how far to trust it. Confidence is
|
|
7597
|
+
// derived from the source rather than stored, so the two cannot disagree.
|
|
7598
|
+
...s.statusSource != null && {
|
|
7599
|
+
statusSource: s.statusSource,
|
|
7600
|
+
statusConfidence: confidenceForSource(s.statusSource)
|
|
7601
|
+
},
|
|
7602
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt.toISOString() },
|
|
6512
7603
|
...s.filePath != null && { filePath: s.filePath },
|
|
6513
7604
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
6514
7605
|
...s.resumedFromConversationId != null && {
|
|
@@ -6530,6 +7621,12 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6530
7621
|
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6531
7622
|
// report "gone" here — a vanished process simply stops being listed.
|
|
6532
7623
|
processLiveness: "alive",
|
|
7624
|
+
// Alive, but spawned outside this streamer, so we hold no PTY for it. That
|
|
7625
|
+
// is precisely `detached` — and it is strictly more informative than the
|
|
7626
|
+
// `status: "idle"` above, which discovery is forced to report because it
|
|
7627
|
+
// cannot see the process's prompt state.
|
|
7628
|
+
lifecycle: "detached",
|
|
7629
|
+
lifecycleSource: "probe",
|
|
6533
7630
|
projectPath: d.projectPath,
|
|
6534
7631
|
projectName: d.projectName,
|
|
6535
7632
|
branch: d.branch,
|
|
@@ -6544,7 +7641,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6544
7641
|
}
|
|
6545
7642
|
|
|
6546
7643
|
// src/uploads.ts
|
|
6547
|
-
var
|
|
7644
|
+
var import_crypto10 = require("crypto");
|
|
6548
7645
|
var import_promises6 = require("fs/promises");
|
|
6549
7646
|
var import_heic_convert = __toESM(require("heic-convert"), 1);
|
|
6550
7647
|
var import_path17 = require("path");
|
|
@@ -6578,7 +7675,7 @@ async function saveUploadFile(input) {
|
|
|
6578
7675
|
mimeType = "image/jpeg";
|
|
6579
7676
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
6580
7677
|
}
|
|
6581
|
-
const id = `up_${(0,
|
|
7678
|
+
const id = `up_${(0, import_crypto10.randomBytes)(8).toString("hex")}`;
|
|
6582
7679
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
6583
7680
|
const dir = (0, import_path17.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
6584
7681
|
await (0, import_promises6.mkdir)(dir, { recursive: true });
|
|
@@ -6612,21 +7709,46 @@ function extractCodexText(content) {
|
|
|
6612
7709
|
return "";
|
|
6613
7710
|
}).filter(Boolean).join("").trim();
|
|
6614
7711
|
}
|
|
6615
|
-
|
|
7712
|
+
var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
|
|
7713
|
+
function classifyCodexLine(line) {
|
|
6616
7714
|
let entry;
|
|
6617
7715
|
try {
|
|
6618
7716
|
entry = JSON.parse(line);
|
|
6619
7717
|
} catch {
|
|
6620
|
-
return
|
|
7718
|
+
return { kind: "unknown", raw: line, reason: "line is not valid JSON" };
|
|
7719
|
+
}
|
|
7720
|
+
if (typeof entry.type !== "string" || !KNOWN_CODEX_TYPES.has(entry.type)) {
|
|
7721
|
+
return {
|
|
7722
|
+
kind: "unknown",
|
|
7723
|
+
raw: line,
|
|
7724
|
+
reason: `unrecognized rollout envelope type: ${String(entry.type)}`
|
|
7725
|
+
};
|
|
7726
|
+
}
|
|
7727
|
+
if (entry.type !== "response_item") {
|
|
7728
|
+
return { kind: "ignored", reason: `${entry.type} carries no chat content` };
|
|
6621
7729
|
}
|
|
6622
|
-
if (entry.type !== "response_item") return null;
|
|
6623
7730
|
const payload = entry.payload;
|
|
6624
|
-
if (payload?.type !== "message")
|
|
7731
|
+
if (payload?.type !== "message") {
|
|
7732
|
+
return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
|
|
7733
|
+
}
|
|
6625
7734
|
const role = payload.role;
|
|
6626
|
-
if (role !== "user" && role !== "assistant")
|
|
7735
|
+
if (role !== "user" && role !== "assistant") {
|
|
7736
|
+
return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
|
|
7737
|
+
}
|
|
6627
7738
|
const text = extractCodexText(payload.content);
|
|
6628
|
-
if (!text)
|
|
6629
|
-
|
|
7739
|
+
if (!text) {
|
|
7740
|
+
return { kind: "ignored", reason: "message has no extractable text" };
|
|
7741
|
+
}
|
|
7742
|
+
if (role === "user" && isCodexInjectedContext(text)) {
|
|
7743
|
+
return { kind: "ignored", reason: "synthetic injected context" };
|
|
7744
|
+
}
|
|
7745
|
+
return { kind: "message", line: buildClaudeShapedLine(entry, payload, role, text) };
|
|
7746
|
+
}
|
|
7747
|
+
function normalizeCodexLineToClaudeShape(line) {
|
|
7748
|
+
const result = classifyCodexLine(line);
|
|
7749
|
+
return result.kind === "message" ? result.line : null;
|
|
7750
|
+
}
|
|
7751
|
+
function buildClaudeShapedLine(entry, payload, role, text) {
|
|
6630
7752
|
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6631
7753
|
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
6632
7754
|
return JSON.stringify({
|
|
@@ -6831,6 +7953,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
|
|
|
6831
7953
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
6832
7954
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6833
7955
|
var GRACE_MAX_DEFERS = 4;
|
|
7956
|
+
var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
|
|
7957
|
+
var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
|
|
6834
7958
|
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6835
7959
|
var DISCOVERY_TTL_MS = 15e3;
|
|
6836
7960
|
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
@@ -6952,6 +8076,16 @@ var StreamerServer = class {
|
|
|
6952
8076
|
defaultPermissionMode;
|
|
6953
8077
|
defaultModel;
|
|
6954
8078
|
defaultEffort;
|
|
8079
|
+
// Allowlisted Claude CLI flags + free-text escape hatch, applied to every
|
|
8080
|
+
// spawn. Resolved once at startup (flag → server.yaml), then mutated in place
|
|
8081
|
+
// by PUT /api/config/claude-flags so a change applies to the next session
|
|
8082
|
+
// without a restart.
|
|
8083
|
+
claudeFlags;
|
|
8084
|
+
claudeExtraArgs;
|
|
8085
|
+
// True when the values came from server.yaml (and so a write persists).
|
|
8086
|
+
// False when they were pinned by a CLI flag, mirroring the api-key rotate
|
|
8087
|
+
// contract: the write still takes effect in memory but won't survive restart.
|
|
8088
|
+
claudeFlagsPersistable;
|
|
6955
8089
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6956
8090
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6957
8091
|
// Consecutive grace-timer defers for a still-`running` session (see
|
|
@@ -6959,6 +8093,18 @@ var StreamerServer = class {
|
|
|
6959
8093
|
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6960
8094
|
// Map of sessionId → set of subscribed WS clients
|
|
6961
8095
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
8096
|
+
// sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
|
|
8097
|
+
// every provider; read only by the idle reaper. Entries are dropped when the
|
|
8098
|
+
// session leaves the runner (reap/exit/hold).
|
|
8099
|
+
lastAgentChunkAt = /* @__PURE__ */ new Map();
|
|
8100
|
+
// Recently accepted input idempotency keys (C4). A retried POST replays its
|
|
8101
|
+
// original outcome instead of submitting the prompt to the agent twice.
|
|
8102
|
+
idempotency = new IdempotencyStore();
|
|
8103
|
+
// sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
|
|
8104
|
+
// this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
|
|
8105
|
+
sessionLifecycles = /* @__PURE__ */ new Map();
|
|
8106
|
+
// Periodic sweep that releases PTYs no agent is using. Null until listen().
|
|
8107
|
+
idleReaperTimer = null;
|
|
6962
8108
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
6963
8109
|
clientIdToWs = /* @__PURE__ */ new Map();
|
|
6964
8110
|
// Reverse map for cleanup on close
|
|
@@ -6968,7 +8114,20 @@ var StreamerServer = class {
|
|
|
6968
8114
|
projectsRepo = null;
|
|
6969
8115
|
conversationsRepo = null;
|
|
6970
8116
|
sessionsRepo = null;
|
|
8117
|
+
// Durable session registry (C1 Phase 2). Null when the cache DB failed to
|
|
8118
|
+
// open — persistence degrades to today's in-memory-only behaviour rather than
|
|
8119
|
+
// taking the server down with it, so every write goes through `?.`.
|
|
8120
|
+
managedSessionsRepo = null;
|
|
8121
|
+
// Identifies this streamer run. A registry row carrying a different id is a
|
|
8122
|
+
// session that outlived the process that started it.
|
|
8123
|
+
streamerInstanceId = (0, import_crypto11.randomUUID)();
|
|
6971
8124
|
cacheMetadataRepo = null;
|
|
8125
|
+
// Push registration + delivery state (C7). Null when the cache DB failed to
|
|
8126
|
+
// open — registration then degrades to a no-op rather than 500ing.
|
|
8127
|
+
pushRepo = null;
|
|
8128
|
+
// Paired-device registry (C5). Null when the cache DB failed to open — auth
|
|
8129
|
+
// then falls back to the shared API key alone, which is the pre-C5 behaviour.
|
|
8130
|
+
devicesRepo = null;
|
|
6972
8131
|
discoveryCache = null;
|
|
6973
8132
|
cacheDir;
|
|
6974
8133
|
tailSize;
|
|
@@ -7007,6 +8166,9 @@ var StreamerServer = class {
|
|
|
7007
8166
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
7008
8167
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
7009
8168
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
8169
|
+
this.claudeFlagsPersistable = config.claudeFlags === void 0;
|
|
8170
|
+
this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
|
|
8171
|
+
this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
7010
8172
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os9.homedir)(), ".threadbase", "cache");
|
|
7011
8173
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
7012
8174
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
@@ -7131,6 +8293,7 @@ var StreamerServer = class {
|
|
|
7131
8293
|
this.ptyManager = new LiveSessionManager({
|
|
7132
8294
|
logger: getLogger("pty"),
|
|
7133
8295
|
onOutput: (sessionId, data) => {
|
|
8296
|
+
this.lastAgentChunkAt.set(sessionId, Date.now());
|
|
7134
8297
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
7135
8298
|
},
|
|
7136
8299
|
onUserMessage: (sessionId, text, ts) => {
|
|
@@ -7159,6 +8322,17 @@ var StreamerServer = class {
|
|
|
7159
8322
|
completedAt: session.completedAt,
|
|
7160
8323
|
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
|
|
7161
8324
|
});
|
|
8325
|
+
this.managedSessionsRepo?.recordStatus(
|
|
8326
|
+
session.id,
|
|
8327
|
+
session.status,
|
|
8328
|
+
session.completedAt != null ? "exit" : "transition",
|
|
8329
|
+
{
|
|
8330
|
+
completedAt: session.completedAt,
|
|
8331
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
8332
|
+
promptCount: session.promptCount,
|
|
8333
|
+
failureReason: session.failureReason ?? null
|
|
8334
|
+
}
|
|
8335
|
+
);
|
|
7162
8336
|
if (session.status === "waiting_input" || session.status === "idle") {
|
|
7163
8337
|
const filePath = this.sessionFileMap.get(session.id);
|
|
7164
8338
|
if (filePath) {
|
|
@@ -7230,6 +8404,8 @@ var StreamerServer = class {
|
|
|
7230
8404
|
localNoAuth: this.localNoAuth,
|
|
7231
8405
|
logMenubarRequests: this.logMenubarRequests,
|
|
7232
8406
|
rotateApiKey: () => this.rotateApiKey(),
|
|
8407
|
+
claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
|
|
8408
|
+
setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
|
|
7233
8409
|
publicUrl: this.publicUrl,
|
|
7234
8410
|
browseRoot: this.browseRoot,
|
|
7235
8411
|
browserCors: this.browserCors,
|
|
@@ -7238,6 +8414,8 @@ var StreamerServer = class {
|
|
|
7238
8414
|
wsHub: this.wsHub,
|
|
7239
8415
|
cache: () => this.cache,
|
|
7240
8416
|
cacheMonitor: () => this.cacheMonitor,
|
|
8417
|
+
pushRepo: () => this.pushRepo,
|
|
8418
|
+
devicesRepo: () => this.devicesRepo,
|
|
7241
8419
|
projectsRepo: () => this.projectsRepo,
|
|
7242
8420
|
conversationsRepo: () => this.conversationsRepo,
|
|
7243
8421
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -7271,7 +8449,9 @@ var StreamerServer = class {
|
|
|
7271
8449
|
handleMkdir: (req, res) => this.handleMkdir(req, res),
|
|
7272
8450
|
handleWsOpen: (ws) => {
|
|
7273
8451
|
this.wsHub.addClient(ws);
|
|
7274
|
-
const sessions = this.
|
|
8452
|
+
const sessions = this.withReconciledLifecycle(
|
|
8453
|
+
this.sessionStore.list(this.ptyAttachedIds())
|
|
8454
|
+
);
|
|
7275
8455
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
7276
8456
|
if (!this.currentWarmupState()) {
|
|
7277
8457
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
@@ -7347,11 +8527,8 @@ var StreamerServer = class {
|
|
|
7347
8527
|
this.clientIdToWs.delete(clientId);
|
|
7348
8528
|
this.wsToClientId.delete(ws);
|
|
7349
8529
|
}
|
|
7350
|
-
for (const
|
|
8530
|
+
for (const subscribers of this.sessionSubscribers.values()) {
|
|
7351
8531
|
subscribers.delete(ws);
|
|
7352
|
-
if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
|
|
7353
|
-
this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
|
|
7354
|
-
}
|
|
7355
8532
|
}
|
|
7356
8533
|
},
|
|
7357
8534
|
agentClient,
|
|
@@ -7414,7 +8591,7 @@ var StreamerServer = class {
|
|
|
7414
8591
|
const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
|
|
7415
8592
|
const payload = {
|
|
7416
8593
|
type: "session_list",
|
|
7417
|
-
sessions: this.sessionStore.list(this.ptyAttachedIds())
|
|
8594
|
+
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
7418
8595
|
};
|
|
7419
8596
|
if (ws) {
|
|
7420
8597
|
this.wsHub.unicast(ws, payload);
|
|
@@ -7422,6 +8599,29 @@ var StreamerServer = class {
|
|
|
7422
8599
|
this.wsHub.broadcast(payload);
|
|
7423
8600
|
}
|
|
7424
8601
|
}
|
|
8602
|
+
/**
|
|
8603
|
+
* Overlay boot-reconciliation verdicts onto session responses.
|
|
8604
|
+
*
|
|
8605
|
+
* A session left by a previous run is not in the in-memory store, so
|
|
8606
|
+
* SessionStore cannot classify it — it only ever sees what this run spawned.
|
|
8607
|
+
* Discovery may still surface the process, in which case the reconciler knows
|
|
8608
|
+
* strictly more about it than discovery does: it can tell `detached` (alive
|
|
8609
|
+
* and confirmed ours) from `orphaned` (alive but identity unconfirmed), which
|
|
8610
|
+
* a pid enumeration alone cannot.
|
|
8611
|
+
*
|
|
8612
|
+
* Only applied when the session is NOT live here: a session this run owns has
|
|
8613
|
+
* an authoritative lifecycle already, and a stale verdict must never override
|
|
8614
|
+
* it.
|
|
8615
|
+
*/
|
|
8616
|
+
withReconciledLifecycle(sessions) {
|
|
8617
|
+
if (this.sessionLifecycles.size === 0) return sessions;
|
|
8618
|
+
return sessions.map((s) => {
|
|
8619
|
+
if (s.ptyAttached) return s;
|
|
8620
|
+
const verdict = this.sessionLifecycles.get(s.id);
|
|
8621
|
+
if (!verdict) return s;
|
|
8622
|
+
return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
|
|
8623
|
+
});
|
|
8624
|
+
}
|
|
7425
8625
|
addSessionSubscriber(sessionId, ws) {
|
|
7426
8626
|
let subs = this.sessionSubscribers.get(sessionId);
|
|
7427
8627
|
if (!subs) {
|
|
@@ -7436,6 +8636,181 @@ var StreamerServer = class {
|
|
|
7436
8636
|
}
|
|
7437
8637
|
this.ptyGraceDeferCounts.delete(sessionId);
|
|
7438
8638
|
}
|
|
8639
|
+
/**
|
|
8640
|
+
* Classify sessions left behind by previous streamer runs (C1 Phase 3a).
|
|
8641
|
+
*
|
|
8642
|
+
* Agents already outlive the streamer today on the crash and dev-takeover
|
|
8643
|
+
* paths, which exit without reaching ptyManager.dispose() — they are just
|
|
8644
|
+
* invisible when they do, because nothing recorded that they existed. This
|
|
8645
|
+
* turns those rows into an explicit verdict per session.
|
|
8646
|
+
*
|
|
8647
|
+
* Read-only with respect to processes: it probes and classifies, and never
|
|
8648
|
+
* signals anything. `orphaned` is a report, not a cleanup trigger.
|
|
8649
|
+
*/
|
|
8650
|
+
async reconcilePreviousSessions() {
|
|
8651
|
+
if (!this.managedSessionsRepo) return [];
|
|
8652
|
+
let verdicts = [];
|
|
8653
|
+
try {
|
|
8654
|
+
const rows = this.managedSessionsRepo.listNonTerminal();
|
|
8655
|
+
if (rows.length === 0) return [];
|
|
8656
|
+
verdicts = await reconcileSessions(
|
|
8657
|
+
rows,
|
|
8658
|
+
{ isPidAlive, getProcessArgs },
|
|
8659
|
+
this.streamerInstanceId
|
|
8660
|
+
);
|
|
8661
|
+
for (const v of verdicts) {
|
|
8662
|
+
this.sessionLifecycles.set(v.sessionId, v.lifecycle);
|
|
8663
|
+
if (v.lifecycle === "completed" || v.lifecycle === "failed") {
|
|
8664
|
+
this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
|
|
8665
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
8666
|
+
});
|
|
8667
|
+
}
|
|
8668
|
+
}
|
|
8669
|
+
this.log.info(`[reconcile] classified ${verdicts.length} session(s) from previous runs`, {
|
|
8670
|
+
event: "sessions.reconciled",
|
|
8671
|
+
counts: verdicts.reduce((acc, v) => {
|
|
8672
|
+
acc[v.lifecycle] = (acc[v.lifecycle] ?? 0) + 1;
|
|
8673
|
+
return acc;
|
|
8674
|
+
}, {})
|
|
8675
|
+
});
|
|
8676
|
+
} catch (err) {
|
|
8677
|
+
this.log.warn("[reconcile] failed to reconcile previous sessions", {
|
|
8678
|
+
event: "sessions.reconcile_failed",
|
|
8679
|
+
err
|
|
8680
|
+
});
|
|
8681
|
+
}
|
|
8682
|
+
return verdicts;
|
|
8683
|
+
}
|
|
8684
|
+
/**
|
|
8685
|
+
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
8686
|
+
* reconciler's pid-reuse guard.
|
|
8687
|
+
*
|
|
8688
|
+
* Claude always passes the session id (`--resume <id>` or `--session-id
|
|
8689
|
+
* <id>`), so it is both present and unique. Codex only does on *resume*
|
|
8690
|
+
* (`codex resume <id>`); a fresh Codex spawn is `codex --cd <path>
|
|
8691
|
+
* --no-alt-screen` with no id at all, because the rollout id does not exist
|
|
8692
|
+
* until the CLI writes it. boundConversationId is what distinguishes the two:
|
|
8693
|
+
* it is set once that rollout has been discovered.
|
|
8694
|
+
*/
|
|
8695
|
+
spawnArgvToken(session) {
|
|
8696
|
+
if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
|
|
8697
|
+
return session.boundConversationId ?? session.projectPath;
|
|
8698
|
+
}
|
|
8699
|
+
/**
|
|
8700
|
+
* Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
|
|
8701
|
+
*
|
|
8702
|
+
* Called at each addManaged() site rather than inside SessionStore, because
|
|
8703
|
+
* the store is a pure in-memory structure with no DB dependency and adding
|
|
8704
|
+
* one would drag persistence into every unit test that touches it.
|
|
8705
|
+
*
|
|
8706
|
+
* Best-effort by design: a failed registry write must never break session
|
|
8707
|
+
* start. Losing a row costs post-restart *visibility* for that session, which
|
|
8708
|
+
* is strictly better than refusing to run the agent at all.
|
|
8709
|
+
*/
|
|
8710
|
+
recordSessionSpawn(session) {
|
|
8711
|
+
if (!this.managedSessionsRepo) return;
|
|
8712
|
+
try {
|
|
8713
|
+
const pid = this.ptyManager.getPid(session.id);
|
|
8714
|
+
this.managedSessionsRepo.recordSpawn({
|
|
8715
|
+
session,
|
|
8716
|
+
pid,
|
|
8717
|
+
// Identity guard against pid reuse: the reconciler requires this token
|
|
8718
|
+
// to appear in the live process's argv before it will claim the pid is
|
|
8719
|
+
// still ours (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
8720
|
+
//
|
|
8721
|
+
// Reading the real argv here would cost an async `ps` per session start
|
|
8722
|
+
// on a path the user is waiting on, so we record a token we already
|
|
8723
|
+
// know is in it. Claude always carries the session id (`--resume <id>`
|
|
8724
|
+
// on resume, `--session-id <id>` on fresh). A *fresh* Codex spawn does
|
|
8725
|
+
// not — its argv is only `--cd <path> --no-alt-screen`, because the
|
|
8726
|
+
// rollout id doesn't exist yet — so fall back to the project path,
|
|
8727
|
+
// which is present in every spawn path for both providers.
|
|
8728
|
+
//
|
|
8729
|
+
// The fallback is weaker: two sessions in one project share a token, so
|
|
8730
|
+
// it proves "a process of ours in this project" rather than "this exact
|
|
8731
|
+
// session". It still rejects an unrelated recycled pid, which is the
|
|
8732
|
+
// failure being guarded against.
|
|
8733
|
+
//
|
|
8734
|
+
// Note the Codex id is always *set* (a local placeholder) — it is just
|
|
8735
|
+
// not in the process's argv — so the choice keys off the provider, not
|
|
8736
|
+
// off the id being null.
|
|
8737
|
+
cmdline: pid != null ? this.spawnArgvToken(session) : null,
|
|
8738
|
+
streamerInstanceId: this.streamerInstanceId
|
|
8739
|
+
});
|
|
8740
|
+
} catch (err) {
|
|
8741
|
+
this.log.warn("[registry] failed to record session spawn", {
|
|
8742
|
+
event: "registry.spawn_write_failed",
|
|
8743
|
+
sessionId: session.id,
|
|
8744
|
+
err
|
|
8745
|
+
});
|
|
8746
|
+
}
|
|
8747
|
+
}
|
|
8748
|
+
/**
|
|
8749
|
+
* Stamp every live session as ended-by-shutdown before dispose() kills it.
|
|
8750
|
+
*
|
|
8751
|
+
* PTYManager.dispose() signals each child directly and fires no
|
|
8752
|
+
* onStatusChange, so the registry would otherwise keep rows sitting at
|
|
8753
|
+
* `running` forever and the next boot could not tell a deliberate restart
|
|
8754
|
+
* from a crash. Recording `shutdown` as the status source makes that
|
|
8755
|
+
* distinction explicit rather than inferred.
|
|
8756
|
+
*
|
|
8757
|
+
* Not a `completed_at` write for the agent's own work — the agent did not
|
|
8758
|
+
* finish, we stopped it — but the session is genuinely terminal, so it must
|
|
8759
|
+
* leave the reconciler's probe set.
|
|
8760
|
+
*/
|
|
8761
|
+
recordShutdownState() {
|
|
8762
|
+
if (!this.managedSessionsRepo) return;
|
|
8763
|
+
const now = /* @__PURE__ */ new Date();
|
|
8764
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
8765
|
+
try {
|
|
8766
|
+
this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
|
|
8767
|
+
completedAt: now,
|
|
8768
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
8769
|
+
promptCount: session.promptCount
|
|
8770
|
+
});
|
|
8771
|
+
} catch (err) {
|
|
8772
|
+
this.log.warn("[registry] failed to record shutdown state", {
|
|
8773
|
+
event: "registry.shutdown_write_failed",
|
|
8774
|
+
sessionId: session.id,
|
|
8775
|
+
err
|
|
8776
|
+
});
|
|
8777
|
+
}
|
|
8778
|
+
}
|
|
8779
|
+
}
|
|
8780
|
+
/**
|
|
8781
|
+
* Release PTYs whose agent has been silent past IDLE_REAP_AFTER_MS.
|
|
8782
|
+
*
|
|
8783
|
+
* This is the bound that lets handleWsClose stop arming kill timers. The
|
|
8784
|
+
* distinction that matters: the old timer measured how long nobody was
|
|
8785
|
+
* *watching*, which is uncorrelated with whether work is in flight. This
|
|
8786
|
+
* measures how long the *agent* has produced nothing, and only ever considers
|
|
8787
|
+
* sessions that are already settled — a `running` PTY is skipped regardless of
|
|
8788
|
+
* age, so a long silent turn is never interrupted.
|
|
8789
|
+
*
|
|
8790
|
+
* Exposed (not private) so tests can drive one sweep deterministically instead
|
|
8791
|
+
* of waiting on the interval.
|
|
8792
|
+
*/
|
|
8793
|
+
reapIdleSessions(now = Date.now()) {
|
|
8794
|
+
const reaped = [];
|
|
8795
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
8796
|
+
if (session.status === "running") continue;
|
|
8797
|
+
const lastActive = this.lastAgentChunkAt.get(session.id) ?? session.lastActivityAt?.getTime() ?? session.startedAt.getTime();
|
|
8798
|
+
if (now - lastActive < IDLE_REAP_AFTER_MS) continue;
|
|
8799
|
+
this.log.info(
|
|
8800
|
+
`[reap] releasing idle PTY for ${session.id} (idle ${Math.round((now - lastActive) / 6e4)}m)`,
|
|
8801
|
+
{ sessionId: session.id, event: "pty.idle_reap", idleMs: now - lastActive },
|
|
8802
|
+
"pino"
|
|
8803
|
+
);
|
|
8804
|
+
this.ptyManager.putOnHold(session.id);
|
|
8805
|
+
this.lastAgentChunkAt.delete(session.id);
|
|
8806
|
+
this.idempotency.clear(session.id);
|
|
8807
|
+
this.sessionSubscribers.delete(session.id);
|
|
8808
|
+
reaped.push(session.id);
|
|
8809
|
+
const held = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
8810
|
+
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
8811
|
+
}
|
|
8812
|
+
return reaped;
|
|
8813
|
+
}
|
|
7439
8814
|
startGraceTimer(sessionId, delayMs) {
|
|
7440
8815
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
7441
8816
|
if (existing) clearTimeout(existing);
|
|
@@ -7530,6 +8905,8 @@ var StreamerServer = class {
|
|
|
7530
8905
|
this.log.info("Database migrations applied", { event: "db.migrations_applied" });
|
|
7531
8906
|
}
|
|
7532
8907
|
await this.bindWithRetry(port);
|
|
8908
|
+
this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
|
|
8909
|
+
this.idleReaperTimer.unref?.();
|
|
7533
8910
|
const warmUp = new Promise((resolveWarm) => {
|
|
7534
8911
|
{
|
|
7535
8912
|
this.log.info(`Streamer server listening on port ${port}`, {
|
|
@@ -7563,7 +8940,11 @@ var StreamerServer = class {
|
|
|
7563
8940
|
this.projectsRepo = new ProjectsRepository(db);
|
|
7564
8941
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
7565
8942
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
8943
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(db);
|
|
8944
|
+
void this.reconcilePreviousSessions();
|
|
7566
8945
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
8946
|
+
this.pushRepo = new PushRepository(db);
|
|
8947
|
+
this.devicesRepo = new DevicesRepository(db);
|
|
7567
8948
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7568
8949
|
this.cache,
|
|
7569
8950
|
this.wsHub,
|
|
@@ -7781,6 +9162,12 @@ var StreamerServer = class {
|
|
|
7781
9162
|
async close() {
|
|
7782
9163
|
for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
|
|
7783
9164
|
this.ptyGraceTimers.clear();
|
|
9165
|
+
if (this.idleReaperTimer) {
|
|
9166
|
+
clearInterval(this.idleReaperTimer);
|
|
9167
|
+
this.idleReaperTimer = null;
|
|
9168
|
+
}
|
|
9169
|
+
this.lastAgentChunkAt.clear();
|
|
9170
|
+
this.recordShutdownState();
|
|
7784
9171
|
this.markScannerStaleDebounced.cancel();
|
|
7785
9172
|
await Promise.all([...this.inFlightCacheWrites]);
|
|
7786
9173
|
await Promise.all([...this.allScanners].map((s) => s.close()));
|
|
@@ -7870,12 +9257,28 @@ var StreamerServer = class {
|
|
|
7870
9257
|
ip,
|
|
7871
9258
|
ts
|
|
7872
9259
|
});
|
|
9260
|
+
let device = null;
|
|
9261
|
+
try {
|
|
9262
|
+
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
9263
|
+
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
9264
|
+
device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
|
|
9265
|
+
} catch (err) {
|
|
9266
|
+
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
9267
|
+
event: "pair.device_register_failed",
|
|
9268
|
+
err
|
|
9269
|
+
});
|
|
9270
|
+
}
|
|
7873
9271
|
json(res, 200, {
|
|
7874
9272
|
ciphertext: sealed.ciphertext,
|
|
7875
9273
|
nonce: sealed.nonce,
|
|
7876
9274
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
7877
9275
|
publicUrl: this.publicUrl,
|
|
7878
|
-
machineName: hostname2()
|
|
9276
|
+
machineName: hostname2(),
|
|
9277
|
+
...device && {
|
|
9278
|
+
deviceId: device.deviceId,
|
|
9279
|
+
deviceToken: device.deviceToken,
|
|
9280
|
+
capabilities: device.capabilities
|
|
9281
|
+
}
|
|
7879
9282
|
});
|
|
7880
9283
|
}
|
|
7881
9284
|
rotateApiKey() {
|
|
@@ -7892,6 +9295,48 @@ var StreamerServer = class {
|
|
|
7892
9295
|
});
|
|
7893
9296
|
return { newKey, persisted };
|
|
7894
9297
|
}
|
|
9298
|
+
getClaudeFlagsConfig() {
|
|
9299
|
+
return {
|
|
9300
|
+
registry: CLAUDE_FLAGS,
|
|
9301
|
+
values: this.claudeFlags,
|
|
9302
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
9303
|
+
persisted: this.claudeFlagsPersistable
|
|
9304
|
+
};
|
|
9305
|
+
}
|
|
9306
|
+
/**
|
|
9307
|
+
* Replace the per-server flag set. Applies to the NEXT spawn — a live PTY
|
|
9308
|
+
* keeps the argv it was started with.
|
|
9309
|
+
*
|
|
9310
|
+
* Mirrors rotateApiKey(): when the values were pinned by a CLI flag we still
|
|
9311
|
+
* apply them in memory but skip the server.yaml write, because the flag would
|
|
9312
|
+
* win again on restart and silently revert them.
|
|
9313
|
+
*
|
|
9314
|
+
* Logged with old→new at info level on purpose: this can disable the
|
|
9315
|
+
* permission prompts entirely, so it needs a forensic trail.
|
|
9316
|
+
*/
|
|
9317
|
+
setClaudeFlagsConfig(values, extraArgs) {
|
|
9318
|
+
const safe = validateFlagValues(values);
|
|
9319
|
+
const previous = { values: this.claudeFlags, extraArgs: this.claudeExtraArgs };
|
|
9320
|
+
if (this.claudeFlagsPersistable) {
|
|
9321
|
+
setClaudeExtraArgs(extraArgs);
|
|
9322
|
+
setClaudeFlags(safe);
|
|
9323
|
+
}
|
|
9324
|
+
this.claudeFlags = safe;
|
|
9325
|
+
this.claudeExtraArgs = extraArgs?.trim() ? extraArgs.trim() : void 0;
|
|
9326
|
+
this.log.info("Claude CLI flags updated", {
|
|
9327
|
+
event: "config.claude_flags_updated",
|
|
9328
|
+
persisted: this.claudeFlagsPersistable,
|
|
9329
|
+
previousValues: previous.values,
|
|
9330
|
+
previousExtraArgs: previous.extraArgs ?? null,
|
|
9331
|
+
values: this.claudeFlags,
|
|
9332
|
+
extraArgs: this.claudeExtraArgs ?? null
|
|
9333
|
+
});
|
|
9334
|
+
return {
|
|
9335
|
+
values: this.claudeFlags,
|
|
9336
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
9337
|
+
persisted: this.claudeFlagsPersistable
|
|
9338
|
+
};
|
|
9339
|
+
}
|
|
7895
9340
|
checkRateLimit(map, key, limit, windowMs) {
|
|
7896
9341
|
const now = Date.now();
|
|
7897
9342
|
const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
@@ -8909,7 +10354,13 @@ var StreamerServer = class {
|
|
|
8909
10354
|
}
|
|
8910
10355
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
8911
10356
|
if (!hasPaginationParams) {
|
|
8912
|
-
json(
|
|
10357
|
+
json(
|
|
10358
|
+
res,
|
|
10359
|
+
200,
|
|
10360
|
+
this.withExternalActivity(
|
|
10361
|
+
this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
10362
|
+
)
|
|
10363
|
+
);
|
|
8913
10364
|
return;
|
|
8914
10365
|
}
|
|
8915
10366
|
const parsed = parseSessionListQuery(url);
|
|
@@ -8919,7 +10370,7 @@ var StreamerServer = class {
|
|
|
8919
10370
|
}
|
|
8920
10371
|
try {
|
|
8921
10372
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8922
|
-
page.sessions = this.withExternalActivity(page.sessions);
|
|
10373
|
+
page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
|
|
8923
10374
|
json(res, 200, page);
|
|
8924
10375
|
} catch (err) {
|
|
8925
10376
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -8936,6 +10387,9 @@ var StreamerServer = class {
|
|
|
8936
10387
|
if (!(0, import_fs18.existsSync)(session.projectPath)) {
|
|
8937
10388
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
8938
10389
|
}
|
|
10390
|
+
const reconciled = this.withReconciledLifecycle([session])[0];
|
|
10391
|
+
session.lifecycle = reconciled.lifecycle;
|
|
10392
|
+
session.lifecycleSource = reconciled.lifecycleSource;
|
|
8939
10393
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
8940
10394
|
try {
|
|
8941
10395
|
const lines = await this.ptyManager.getOutputLines(sessionId, 10);
|
|
@@ -9033,10 +10487,13 @@ var StreamerServer = class {
|
|
|
9033
10487
|
projectName: body.projectName,
|
|
9034
10488
|
branch: body.branch,
|
|
9035
10489
|
permissionMode: this.defaultPermissionMode,
|
|
10490
|
+
claudeFlags: this.claudeFlags,
|
|
10491
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9036
10492
|
model: this.defaultModel,
|
|
9037
10493
|
effort: this.defaultEffort
|
|
9038
10494
|
});
|
|
9039
10495
|
this.sessionStore.addManaged(session);
|
|
10496
|
+
this.recordSessionSpawn(session);
|
|
9040
10497
|
void this.watchConversationFile(sessionId);
|
|
9041
10498
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
9042
10499
|
this.broadcastOrUnicastSessionList(req);
|
|
@@ -9109,6 +10566,24 @@ var StreamerServer = class {
|
|
|
9109
10566
|
}
|
|
9110
10567
|
const body = await readBody(req);
|
|
9111
10568
|
const { input, keys } = body;
|
|
10569
|
+
let idempotencyKey;
|
|
10570
|
+
try {
|
|
10571
|
+
idempotencyKey = readIdempotencyKey(body);
|
|
10572
|
+
} catch (err) {
|
|
10573
|
+
json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
|
|
10574
|
+
return;
|
|
10575
|
+
}
|
|
10576
|
+
if (idempotencyKey) {
|
|
10577
|
+
const replayed = this.idempotency.get(sessionId, idempotencyKey);
|
|
10578
|
+
if (replayed) {
|
|
10579
|
+
this.log.info(`[input.replay] ${sessionId.slice(0, 8)} duplicate idempotencyKey`, {
|
|
10580
|
+
event: "input.idempotent_replay",
|
|
10581
|
+
sessionId
|
|
10582
|
+
});
|
|
10583
|
+
json(res, replayed.status, replayed.body);
|
|
10584
|
+
return;
|
|
10585
|
+
}
|
|
10586
|
+
}
|
|
9112
10587
|
if (typeof keys === "string") {
|
|
9113
10588
|
try {
|
|
9114
10589
|
this.ptyManager.sendKeys(sessionId, keys);
|
|
@@ -9116,7 +10591,9 @@ var StreamerServer = class {
|
|
|
9116
10591
|
if (updated) {
|
|
9117
10592
|
this.wsHub.broadcast({ type: "session_update", session: updated });
|
|
9118
10593
|
}
|
|
9119
|
-
|
|
10594
|
+
const result = { status: 200, body: { ok: true } };
|
|
10595
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10596
|
+
json(res, result.status, result.body);
|
|
9120
10597
|
} catch (err) {
|
|
9121
10598
|
const message = err instanceof Error ? err.message : "Failed to send keys";
|
|
9122
10599
|
json(res, 400, { error: message });
|
|
@@ -9154,7 +10631,9 @@ var StreamerServer = class {
|
|
|
9154
10631
|
});
|
|
9155
10632
|
});
|
|
9156
10633
|
}
|
|
9157
|
-
|
|
10634
|
+
const result = { status: 200, body: { ok: true } };
|
|
10635
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10636
|
+
json(res, result.status, result.body);
|
|
9158
10637
|
} catch (err) {
|
|
9159
10638
|
const message = err instanceof Error ? err.message : "Failed to send input";
|
|
9160
10639
|
json(res, 400, { error: message });
|
|
@@ -9459,10 +10938,13 @@ var StreamerServer = class {
|
|
|
9459
10938
|
projectName,
|
|
9460
10939
|
branch,
|
|
9461
10940
|
permissionMode: this.defaultPermissionMode,
|
|
10941
|
+
claudeFlags: this.claudeFlags,
|
|
10942
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9462
10943
|
model: this.defaultModel,
|
|
9463
10944
|
effort: this.defaultEffort
|
|
9464
10945
|
});
|
|
9465
10946
|
this.sessionStore.addManaged(session);
|
|
10947
|
+
this.recordSessionSpawn(session);
|
|
9466
10948
|
void this.watchConversationFile(session.id);
|
|
9467
10949
|
this.wsHub.broadcast({
|
|
9468
10950
|
type: "session_list",
|
|
@@ -9532,10 +11014,13 @@ var StreamerServer = class {
|
|
|
9532
11014
|
projectName: body.projectName,
|
|
9533
11015
|
systemPrompt: systemPromptParts.join("\n"),
|
|
9534
11016
|
permissionMode: this.defaultPermissionMode,
|
|
11017
|
+
claudeFlags: this.claudeFlags,
|
|
11018
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9535
11019
|
model: this.defaultModel,
|
|
9536
11020
|
effort: this.defaultEffort
|
|
9537
11021
|
});
|
|
9538
11022
|
this.sessionStore.addManaged(session);
|
|
11023
|
+
this.recordSessionSpawn(session);
|
|
9539
11024
|
const readyOrFailed = new Promise((resolve2) => {
|
|
9540
11025
|
const handler = (status) => {
|
|
9541
11026
|
if (status === "waiting_input" || status === "idle") {
|
|
@@ -10057,6 +11542,7 @@ function readBody(req) {
|
|
|
10057
11542
|
SessionStore,
|
|
10058
11543
|
StreamerServer,
|
|
10059
11544
|
WSHub,
|
|
11545
|
+
confidenceForSource,
|
|
10060
11546
|
createAgentClient,
|
|
10061
11547
|
createConversationWriter,
|
|
10062
11548
|
createPool,
|