@threadbase-sh/streamer 1.36.3 → 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 +26490 -24984
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1648 -149
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +378 -4
- package/dist/index.d.ts +378 -4
- package/dist/index.js +1651 -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
|
}
|
|
@@ -1449,6 +1662,9 @@ var import_fs6 = require("fs");
|
|
|
1449
1662
|
var import_path6 = require("path");
|
|
1450
1663
|
|
|
1451
1664
|
// src/services/questions/detectPermissionGate.ts
|
|
1665
|
+
function permissionContentKey(gate) {
|
|
1666
|
+
return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
|
|
1667
|
+
}
|
|
1452
1668
|
var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
|
|
1453
1669
|
var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
|
|
1454
1670
|
function hasPermissionOsc(rawData) {
|
|
@@ -1760,16 +1976,17 @@ var PTYManager = class {
|
|
|
1760
1976
|
}
|
|
1761
1977
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
1762
1978
|
//
|
|
1763
|
-
// options.permissionMode defaults to `acceptEdits`
|
|
1764
|
-
//
|
|
1765
|
-
//
|
|
1766
|
-
//
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
//
|
|
1770
|
-
//
|
|
1771
|
-
// `
|
|
1772
|
-
//
|
|
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.
|
|
1773
1990
|
// (The other first-run gates — onboarding/theme, workspace trust,
|
|
1774
1991
|
// custom-API-key — are cleared by the seeded ~/.claude.json in
|
|
1775
1992
|
// docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
|
|
@@ -1787,28 +2004,27 @@ var PTYManager = class {
|
|
|
1787
2004
|
async doStart(sessionId, options) {
|
|
1788
2005
|
const nodePty = await loadPty2();
|
|
1789
2006
|
const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
|
|
1790
|
-
const
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
);
|
|
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
|
+
});
|
|
1812
2028
|
const session = {
|
|
1813
2029
|
id: sessionId,
|
|
1814
2030
|
provider: CLAUDE_CODE_PROVIDER,
|
|
@@ -1816,6 +2032,8 @@ var PTYManager = class {
|
|
|
1816
2032
|
projectName,
|
|
1817
2033
|
branch: options.branch ?? "",
|
|
1818
2034
|
status: "running",
|
|
2035
|
+
statusSource: "spawn",
|
|
2036
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1819
2037
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1820
2038
|
completedAt: null,
|
|
1821
2039
|
promptCount: 0,
|
|
@@ -1843,11 +2061,12 @@ var PTYManager = class {
|
|
|
1843
2061
|
const nodePty = await loadPty2();
|
|
1844
2062
|
const sessionId = (0, import_crypto3.randomUUID)();
|
|
1845
2063
|
const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
|
|
2064
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
1846
2065
|
const args = [
|
|
1847
2066
|
"--permission-mode",
|
|
1848
|
-
|
|
2067
|
+
permissionMode,
|
|
1849
2068
|
"--settings",
|
|
1850
|
-
|
|
2069
|
+
buildSettingsJson(permissionMode),
|
|
1851
2070
|
"--model",
|
|
1852
2071
|
options.model ?? "sonnet",
|
|
1853
2072
|
"--effort",
|
|
@@ -1858,6 +2077,7 @@ var PTYManager = class {
|
|
|
1858
2077
|
if (options.systemPrompt) {
|
|
1859
2078
|
args.push("--system-prompt", options.systemPrompt);
|
|
1860
2079
|
}
|
|
2080
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
1861
2081
|
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
1862
2082
|
name: "xterm-256color",
|
|
1863
2083
|
cols: 120,
|
|
@@ -1872,6 +2092,8 @@ var PTYManager = class {
|
|
|
1872
2092
|
projectName,
|
|
1873
2093
|
branch: "",
|
|
1874
2094
|
status: "running",
|
|
2095
|
+
statusSource: "spawn",
|
|
2096
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1875
2097
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1876
2098
|
completedAt: null,
|
|
1877
2099
|
promptCount: 0,
|
|
@@ -1902,6 +2124,8 @@ var PTYManager = class {
|
|
|
1902
2124
|
}
|
|
1903
2125
|
if (session.status === "waiting_input") {
|
|
1904
2126
|
session.status = "running";
|
|
2127
|
+
session.statusSource = "user-input";
|
|
2128
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1905
2129
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1906
2130
|
}
|
|
1907
2131
|
this.log.info(
|
|
@@ -1937,6 +2161,8 @@ var PTYManager = class {
|
|
|
1937
2161
|
}
|
|
1938
2162
|
if (session.status === "waiting_input") {
|
|
1939
2163
|
session.status = "running";
|
|
2164
|
+
session.statusSource = "user-input";
|
|
2165
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1940
2166
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1941
2167
|
}
|
|
1942
2168
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -2058,6 +2284,8 @@ var PTYManager = class {
|
|
|
2058
2284
|
} catch {
|
|
2059
2285
|
}
|
|
2060
2286
|
session.status = "idle";
|
|
2287
|
+
session.statusSource = "shutdown";
|
|
2288
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2061
2289
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2062
2290
|
session.screen.dispose();
|
|
2063
2291
|
this.sessions.delete(sessionId);
|
|
@@ -2093,6 +2321,13 @@ var PTYManager = class {
|
|
|
2093
2321
|
getInputHistory(sessionId) {
|
|
2094
2322
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
2095
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
|
+
}
|
|
2096
2331
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
2097
2332
|
// Called from writeSubmit (both direct and flush paths) — never from
|
|
2098
2333
|
// sendKeys, so raw keystrokes aren't logged as messages.
|
|
@@ -2169,9 +2404,9 @@ var PTYManager = class {
|
|
|
2169
2404
|
session.lastOutput = stripped;
|
|
2170
2405
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
|
|
2171
2406
|
if (session.status === "running" && matchedMarker) {
|
|
2172
|
-
this.markReady(sessionId, session, `marker:${matchedMarker}`);
|
|
2407
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
|
|
2173
2408
|
} else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
|
|
2174
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
2409
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
2175
2410
|
}
|
|
2176
2411
|
this.onOutput?.(sessionId, data);
|
|
2177
2412
|
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
@@ -2271,7 +2506,7 @@ var PTYManager = class {
|
|
|
2271
2506
|
const session = this.sessions.get(sessionId);
|
|
2272
2507
|
if (session?.status !== "running") return;
|
|
2273
2508
|
if (this.pendingReady.has(sessionId)) {
|
|
2274
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
2509
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
2275
2510
|
} else {
|
|
2276
2511
|
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2277
2512
|
this.log.warn("[pty.ready] screen recheck failed", {
|
|
@@ -2300,14 +2535,16 @@ var PTYManager = class {
|
|
|
2300
2535
|
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2301
2536
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2302
2537
|
if (matchedMarker && session.status === "running") {
|
|
2303
|
-
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2538
|
+
this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
|
|
2304
2539
|
}
|
|
2305
2540
|
}
|
|
2306
2541
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2307
2542
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2308
|
-
markReady(sessionId, session, reason) {
|
|
2543
|
+
markReady(sessionId, session, source, reason) {
|
|
2309
2544
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2310
2545
|
session.status = "waiting_input";
|
|
2546
|
+
session.statusSource = source;
|
|
2547
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2311
2548
|
const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
|
|
2312
2549
|
this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
|
|
2313
2550
|
event: "pty.ready",
|
|
@@ -2327,6 +2564,8 @@ var PTYManager = class {
|
|
|
2327
2564
|
if (!session) return;
|
|
2328
2565
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2329
2566
|
session.status = "idle";
|
|
2567
|
+
session.statusSource = "process-exit";
|
|
2568
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2330
2569
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
2331
2570
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
2332
2571
|
if (!(0, import_fs6.existsSync)(session.projectPath)) {
|
|
@@ -2361,6 +2600,8 @@ function toPublicSession2(s) {
|
|
|
2361
2600
|
lastOutput: s.lastOutput,
|
|
2362
2601
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
2363
2602
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2603
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2604
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2364
2605
|
...s.filePath != null && { filePath: s.filePath }
|
|
2365
2606
|
};
|
|
2366
2607
|
}
|
|
@@ -2433,6 +2674,16 @@ var LiveSessionManager = class {
|
|
|
2433
2674
|
}
|
|
2434
2675
|
return null;
|
|
2435
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
|
+
}
|
|
2436
2687
|
hasSession(sessionId) {
|
|
2437
2688
|
for (const runner of this.runners.values()) {
|
|
2438
2689
|
if (runner.hasSession(sessionId)) return true;
|
|
@@ -2609,6 +2860,18 @@ async function getProcessCwdUnix(pid) {
|
|
|
2609
2860
|
async function getProcessArgsUnix(pid) {
|
|
2610
2861
|
return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
|
|
2611
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
|
+
}
|
|
2612
2875
|
async function getProcessStartTimeUnix(pid) {
|
|
2613
2876
|
const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
|
|
2614
2877
|
const d = new Date(raw);
|
|
@@ -2724,6 +2987,7 @@ async function readGitBranch(dir) {
|
|
|
2724
2987
|
var import_node_ws = require("@hono/node-ws");
|
|
2725
2988
|
var import_client = require("@temporalio/client");
|
|
2726
2989
|
var import_scanner3 = require("@threadbase-sh/scanner");
|
|
2990
|
+
var import_crypto11 = require("crypto");
|
|
2727
2991
|
var import_events = require("events");
|
|
2728
2992
|
var import_fs18 = require("fs");
|
|
2729
2993
|
var import_promises7 = require("fs/promises");
|
|
@@ -2988,7 +3252,202 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2988
3252
|
}
|
|
2989
3253
|
|
|
2990
3254
|
// src/api/app.ts
|
|
2991
|
-
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
|
+
};
|
|
2992
3451
|
|
|
2993
3452
|
// src/api/middleware/auth.middleware.ts
|
|
2994
3453
|
function isLocalRequest(remoteAddr) {
|
|
@@ -3019,19 +3478,40 @@ var authMiddleware = (deps) => async (c, next) => {
|
|
|
3019
3478
|
}
|
|
3020
3479
|
}
|
|
3021
3480
|
const authorization = c.req.header("authorization");
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
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 {
|
|
3027
3498
|
}
|
|
3499
|
+
} else if (validateApiKey(presented, deps.apiKey)) {
|
|
3500
|
+
principal = legacyPrincipal();
|
|
3501
|
+
}
|
|
3502
|
+
if (!principal) {
|
|
3503
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3028
3504
|
}
|
|
3029
|
-
const
|
|
3030
|
-
if (
|
|
3505
|
+
const required = requiredCapability(path, method);
|
|
3506
|
+
if (required === null) {
|
|
3031
3507
|
await next();
|
|
3032
3508
|
return;
|
|
3033
3509
|
}
|
|
3034
|
-
|
|
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();
|
|
3035
3515
|
};
|
|
3036
3516
|
|
|
3037
3517
|
// src/api/middleware/cors.middleware.ts
|
|
@@ -3170,12 +3650,67 @@ var createCacheAlertRoutes = (deps) => {
|
|
|
3170
3650
|
return app;
|
|
3171
3651
|
};
|
|
3172
3652
|
|
|
3173
|
-
// src/api/routes/
|
|
3653
|
+
// src/api/routes/config.routes.ts
|
|
3174
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");
|
|
3175
3710
|
var ALREADY_HANDLED2 = 597;
|
|
3176
3711
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
3177
3712
|
var createConversationRoutes = (deps) => {
|
|
3178
|
-
const app = new
|
|
3713
|
+
const app = new import_hono5.Hono();
|
|
3179
3714
|
app.get("/count", async (c) => {
|
|
3180
3715
|
const url = new URL(c.req.url);
|
|
3181
3716
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -3201,8 +3736,34 @@ var createConversationRoutes = (deps) => {
|
|
|
3201
3736
|
return app;
|
|
3202
3737
|
};
|
|
3203
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
|
+
|
|
3204
3765
|
// src/api/routes/health.routes.ts
|
|
3205
|
-
var
|
|
3766
|
+
var import_hono7 = require("hono");
|
|
3206
3767
|
|
|
3207
3768
|
// src/version.ts
|
|
3208
3769
|
var import_node_fs2 = require("fs");
|
|
@@ -3239,7 +3800,7 @@ function resolveVersion() {
|
|
|
3239
3800
|
|
|
3240
3801
|
// src/api/routes/health.routes.ts
|
|
3241
3802
|
var createHealthRoutes = (deps) => {
|
|
3242
|
-
const app = new
|
|
3803
|
+
const app = new import_hono7.Hono();
|
|
3243
3804
|
app.get("/", (c) => {
|
|
3244
3805
|
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3245
3806
|
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
@@ -3250,7 +3811,7 @@ var createHealthRoutes = (deps) => {
|
|
|
3250
3811
|
// src/api/routes/logs.routes.ts
|
|
3251
3812
|
var import_node_fs3 = require("fs");
|
|
3252
3813
|
var import_node_path5 = require("path");
|
|
3253
|
-
var
|
|
3814
|
+
var import_hono8 = require("hono");
|
|
3254
3815
|
|
|
3255
3816
|
// src/lifecycle/constants.ts
|
|
3256
3817
|
var import_node_os = require("os");
|
|
@@ -3308,7 +3869,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3308
3869
|
}
|
|
3309
3870
|
}
|
|
3310
3871
|
function createLogsRoutes() {
|
|
3311
|
-
const app = new
|
|
3872
|
+
const app = new import_hono8.Hono();
|
|
3312
3873
|
app.get("/", (c) => {
|
|
3313
3874
|
try {
|
|
3314
3875
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3379,7 +3940,7 @@ function createLogsRoutes() {
|
|
|
3379
3940
|
// src/api/routes/misc.routes.ts
|
|
3380
3941
|
var import_node_child_process = require("child_process");
|
|
3381
3942
|
var import_node_crypto2 = require("crypto");
|
|
3382
|
-
var
|
|
3943
|
+
var import_hono9 = require("hono");
|
|
3383
3944
|
var import_os5 = require("os");
|
|
3384
3945
|
|
|
3385
3946
|
// src/config/update-config.ts
|
|
@@ -3389,15 +3950,15 @@ var import_node_path6 = require("path");
|
|
|
3389
3950
|
var import_yaml = require("yaml");
|
|
3390
3951
|
|
|
3391
3952
|
// src/schemas/updateConfig.schema.ts
|
|
3392
|
-
var
|
|
3393
|
-
var UpdateConfigSchema =
|
|
3394
|
-
auto_update:
|
|
3395
|
-
channel:
|
|
3396
|
-
allow:
|
|
3397
|
-
poll_interval_minutes:
|
|
3398
|
-
defer_if_active_sessions:
|
|
3399
|
-
github_repo:
|
|
3400
|
-
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)
|
|
3401
3962
|
}).strict();
|
|
3402
3963
|
|
|
3403
3964
|
// src/config/update-config.ts
|
|
@@ -3434,7 +3995,7 @@ function readJsonBody(req) {
|
|
|
3434
3995
|
req.on("error", reject);
|
|
3435
3996
|
});
|
|
3436
3997
|
}
|
|
3437
|
-
function
|
|
3998
|
+
function readRawBody4(req) {
|
|
3438
3999
|
return new Promise((resolve2, reject) => {
|
|
3439
4000
|
const chunks = [];
|
|
3440
4001
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3453,7 +4014,7 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3453
4014
|
}
|
|
3454
4015
|
var clientLog = getLogger("client");
|
|
3455
4016
|
var createMiscRoutes = (deps) => {
|
|
3456
|
-
const app = new
|
|
4017
|
+
const app = new import_hono9.Hono();
|
|
3457
4018
|
app.get("/api/info", (c) => {
|
|
3458
4019
|
const ptyIds = deps.ptyAttachedIds();
|
|
3459
4020
|
return c.json({
|
|
@@ -3461,7 +4022,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3461
4022
|
machineName: (0, import_os5.hostname)(),
|
|
3462
4023
|
platform: process.platform,
|
|
3463
4024
|
activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
|
|
3464
|
-
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
|
|
3465
4030
|
});
|
|
3466
4031
|
});
|
|
3467
4032
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -3478,7 +4043,32 @@ var createMiscRoutes = (deps) => {
|
|
|
3478
4043
|
}
|
|
3479
4044
|
});
|
|
3480
4045
|
});
|
|
3481
|
-
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
|
+
});
|
|
3482
4072
|
app.post("/api/__update", async (c) => {
|
|
3483
4073
|
const cfg = loadUpdateConfig();
|
|
3484
4074
|
if (!cfg?.webhook_secret) {
|
|
@@ -3486,7 +4076,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3486
4076
|
}
|
|
3487
4077
|
let body;
|
|
3488
4078
|
try {
|
|
3489
|
-
body = await
|
|
4079
|
+
body = await readRawBody4(c.env.incoming);
|
|
3490
4080
|
} catch {
|
|
3491
4081
|
return c.json({ error: "could not read body" }, 400);
|
|
3492
4082
|
}
|
|
@@ -3529,11 +4119,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3529
4119
|
};
|
|
3530
4120
|
|
|
3531
4121
|
// src/api/routes/pair.routes.ts
|
|
3532
|
-
var
|
|
4122
|
+
var import_hono10 = require("hono");
|
|
3533
4123
|
var ALREADY_HANDLED3 = 597;
|
|
3534
4124
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3535
4125
|
var createPairRoutes = (deps) => {
|
|
3536
|
-
const app = new
|
|
4126
|
+
const app = new import_hono10.Hono();
|
|
3537
4127
|
app.post("/start", (c) => {
|
|
3538
4128
|
deps.handlePairStart(c.env.outgoing);
|
|
3539
4129
|
return alreadyHandled3();
|
|
@@ -3546,11 +4136,11 @@ var createPairRoutes = (deps) => {
|
|
|
3546
4136
|
};
|
|
3547
4137
|
|
|
3548
4138
|
// src/api/routes/projects.routes.ts
|
|
3549
|
-
var
|
|
4139
|
+
var import_hono11 = require("hono");
|
|
3550
4140
|
var ALREADY_HANDLED4 = 597;
|
|
3551
4141
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3552
4142
|
var createProjectRoutes = (deps) => {
|
|
3553
|
-
const app = new
|
|
4143
|
+
const app = new import_hono11.Hono();
|
|
3554
4144
|
app.get("/", (c) => {
|
|
3555
4145
|
const url = new URL(c.req.url);
|
|
3556
4146
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3564,12 +4154,147 @@ var createProjectRoutes = (deps) => {
|
|
|
3564
4154
|
return app;
|
|
3565
4155
|
};
|
|
3566
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
|
+
|
|
3567
4292
|
// src/api/routes/scanner.routes.ts
|
|
3568
|
-
var
|
|
4293
|
+
var import_hono13 = require("hono");
|
|
3569
4294
|
var ALREADY_HANDLED5 = 597;
|
|
3570
4295
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3571
4296
|
var createScannerRoutes = (deps) => {
|
|
3572
|
-
const app = new
|
|
4297
|
+
const app = new import_hono13.Hono();
|
|
3573
4298
|
app.get("/api/search", async (c) => {
|
|
3574
4299
|
const url = new URL(c.req.url);
|
|
3575
4300
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3579,11 +4304,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3579
4304
|
};
|
|
3580
4305
|
|
|
3581
4306
|
// src/api/routes/sessions.routes.ts
|
|
3582
|
-
var
|
|
4307
|
+
var import_hono14 = require("hono");
|
|
3583
4308
|
var ALREADY_HANDLED6 = 597;
|
|
3584
4309
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3585
4310
|
var createSessionRoutes = (deps) => {
|
|
3586
|
-
const app = new
|
|
4311
|
+
const app = new import_hono14.Hono();
|
|
3587
4312
|
app.get("/count", (c) => {
|
|
3588
4313
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3589
4314
|
return alreadyHandled6();
|
|
@@ -3650,9 +4375,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3650
4375
|
};
|
|
3651
4376
|
|
|
3652
4377
|
// src/api/routes/ws.routes.ts
|
|
3653
|
-
var
|
|
4378
|
+
var import_hono15 = require("hono");
|
|
3654
4379
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3655
|
-
const app = new
|
|
4380
|
+
const app = new import_hono15.Hono();
|
|
3656
4381
|
app.get(
|
|
3657
4382
|
"/ws",
|
|
3658
4383
|
upgradeWebSocket(() => {
|
|
@@ -3678,7 +4403,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3678
4403
|
|
|
3679
4404
|
// src/api/app.ts
|
|
3680
4405
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3681
|
-
const app = new
|
|
4406
|
+
const app = new import_hono16.Hono();
|
|
3682
4407
|
const httpLog = getLogger("http");
|
|
3683
4408
|
app.use("*", async (c, next) => {
|
|
3684
4409
|
const start = Date.now();
|
|
@@ -3703,7 +4428,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3703
4428
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3704
4429
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3705
4430
|
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
4431
|
+
app.route("/api/config", createConfigRoutes(deps));
|
|
3706
4432
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
4433
|
+
app.route("/api/providers", createProviderRoutes());
|
|
4434
|
+
app.route("/api/devices", createDeviceRoutes(deps));
|
|
3707
4435
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3708
4436
|
app.route("/api", createBrowseRoutes(deps));
|
|
3709
4437
|
app.route("/", createScannerRoutes(deps));
|
|
@@ -3911,11 +4639,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
|
3911
4639
|
}
|
|
3912
4640
|
|
|
3913
4641
|
// src/utils/fileIdentity.ts
|
|
3914
|
-
var
|
|
4642
|
+
var import_crypto6 = require("crypto");
|
|
3915
4643
|
function fileIdentity(stat3, headBytes) {
|
|
3916
4644
|
if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
|
|
3917
4645
|
const head = headBytes ?? Buffer.alloc(0);
|
|
3918
|
-
return `fp:${(0,
|
|
4646
|
+
return `fp:${(0, import_crypto6.createHash)("sha1").update(head).digest("hex")}`;
|
|
3919
4647
|
}
|
|
3920
4648
|
function splitCompleteLines(buf, baseOffset) {
|
|
3921
4649
|
const spans = [];
|
|
@@ -5197,8 +5925,122 @@ var ConversationsRepository = class {
|
|
|
5197
5925
|
}
|
|
5198
5926
|
};
|
|
5199
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
|
+
|
|
5200
6042
|
// src/db/repositories/projects.repository.ts
|
|
5201
|
-
var
|
|
6043
|
+
var import_crypto7 = require("crypto");
|
|
5202
6044
|
|
|
5203
6045
|
// src/utils/canonicalizeProjectPath.ts
|
|
5204
6046
|
function canonicalizeProjectPath(projectPath) {
|
|
@@ -5291,7 +6133,7 @@ var ProjectsRepository = class {
|
|
|
5291
6133
|
});
|
|
5292
6134
|
return rowToProject(this.getById.get(existing.id));
|
|
5293
6135
|
}
|
|
5294
|
-
const id = (0,
|
|
6136
|
+
const id = (0, import_crypto7.randomUUID)();
|
|
5295
6137
|
this.insert.run({
|
|
5296
6138
|
id,
|
|
5297
6139
|
path,
|
|
@@ -5313,6 +6155,125 @@ function deriveNameFromPath(path) {
|
|
|
5313
6155
|
return parts.length > 0 ? parts[parts.length - 1] : null;
|
|
5314
6156
|
}
|
|
5315
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
|
+
|
|
5316
6277
|
// src/db/repositories/sessions.repository.ts
|
|
5317
6278
|
var SessionsRepository = class {
|
|
5318
6279
|
constructor(store) {
|
|
@@ -5381,8 +6342,20 @@ function handleListProjects(url, res) {
|
|
|
5381
6342
|
res.end(JSON.stringify({ projects: page, total }));
|
|
5382
6343
|
}
|
|
5383
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
|
+
|
|
5384
6357
|
// src/pair-store.ts
|
|
5385
|
-
var
|
|
6358
|
+
var import_crypto8 = require("crypto");
|
|
5386
6359
|
var DEFAULT_TTL_SECONDS = 180;
|
|
5387
6360
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
5388
6361
|
var PairTokenStore = class {
|
|
@@ -5397,7 +6370,7 @@ var PairTokenStore = class {
|
|
|
5397
6370
|
}
|
|
5398
6371
|
}
|
|
5399
6372
|
mint() {
|
|
5400
|
-
const token = `pt_${(0,
|
|
6373
|
+
const token = `pt_${(0, import_crypto8.randomBytes)(16).toString("hex")}`;
|
|
5401
6374
|
const expiresAt = Date.now() + this.ttlMs;
|
|
5402
6375
|
this.current = { token, expiresAt, used: false };
|
|
5403
6376
|
return {
|
|
@@ -5465,7 +6438,7 @@ function setCacheMetadata(repo, key, value) {
|
|
|
5465
6438
|
}
|
|
5466
6439
|
|
|
5467
6440
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5468
|
-
var
|
|
6441
|
+
var import_crypto9 = require("crypto");
|
|
5469
6442
|
var import_fs13 = require("fs");
|
|
5470
6443
|
|
|
5471
6444
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -5530,7 +6503,7 @@ function envInt(name, fallback) {
|
|
|
5530
6503
|
}
|
|
5531
6504
|
function fingerprintOf(ids) {
|
|
5532
6505
|
const sorted = [...ids].sort();
|
|
5533
|
-
return `sha256:${(0,
|
|
6506
|
+
return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5534
6507
|
}
|
|
5535
6508
|
var CacheIntegrityMonitor = class {
|
|
5536
6509
|
constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
|
|
@@ -6344,6 +7317,112 @@ function conversationBusy(input) {
|
|
|
6344
7317
|
};
|
|
6345
7318
|
}
|
|
6346
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
|
+
|
|
6347
7426
|
// src/session-store.ts
|
|
6348
7427
|
var SessionStore = class {
|
|
6349
7428
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -6483,6 +7562,14 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6483
7562
|
conversationId: s.id,
|
|
6484
7563
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
6485
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",
|
|
6486
7573
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6487
7574
|
// `activity` is attached for managed sessions.
|
|
6488
7575
|
ownership: "managed",
|
|
@@ -6506,6 +7593,13 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6506
7593
|
...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
|
|
6507
7594
|
...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
|
|
6508
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() },
|
|
6509
7603
|
...s.filePath != null && { filePath: s.filePath },
|
|
6510
7604
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
6511
7605
|
...s.resumedFromConversationId != null && {
|
|
@@ -6527,6 +7621,12 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6527
7621
|
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6528
7622
|
// report "gone" here — a vanished process simply stops being listed.
|
|
6529
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",
|
|
6530
7630
|
projectPath: d.projectPath,
|
|
6531
7631
|
projectName: d.projectName,
|
|
6532
7632
|
branch: d.branch,
|
|
@@ -6541,7 +7641,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6541
7641
|
}
|
|
6542
7642
|
|
|
6543
7643
|
// src/uploads.ts
|
|
6544
|
-
var
|
|
7644
|
+
var import_crypto10 = require("crypto");
|
|
6545
7645
|
var import_promises6 = require("fs/promises");
|
|
6546
7646
|
var import_heic_convert = __toESM(require("heic-convert"), 1);
|
|
6547
7647
|
var import_path17 = require("path");
|
|
@@ -6575,7 +7675,7 @@ async function saveUploadFile(input) {
|
|
|
6575
7675
|
mimeType = "image/jpeg";
|
|
6576
7676
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
6577
7677
|
}
|
|
6578
|
-
const id = `up_${(0,
|
|
7678
|
+
const id = `up_${(0, import_crypto10.randomBytes)(8).toString("hex")}`;
|
|
6579
7679
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
6580
7680
|
const dir = (0, import_path17.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
6581
7681
|
await (0, import_promises6.mkdir)(dir, { recursive: true });
|
|
@@ -6609,21 +7709,46 @@ function extractCodexText(content) {
|
|
|
6609
7709
|
return "";
|
|
6610
7710
|
}).filter(Boolean).join("").trim();
|
|
6611
7711
|
}
|
|
6612
|
-
|
|
7712
|
+
var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
|
|
7713
|
+
function classifyCodexLine(line) {
|
|
6613
7714
|
let entry;
|
|
6614
7715
|
try {
|
|
6615
7716
|
entry = JSON.parse(line);
|
|
6616
7717
|
} catch {
|
|
6617
|
-
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` };
|
|
6618
7729
|
}
|
|
6619
|
-
if (entry.type !== "response_item") return null;
|
|
6620
7730
|
const payload = entry.payload;
|
|
6621
|
-
if (payload?.type !== "message")
|
|
7731
|
+
if (payload?.type !== "message") {
|
|
7732
|
+
return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
|
|
7733
|
+
}
|
|
6622
7734
|
const role = payload.role;
|
|
6623
|
-
if (role !== "user" && role !== "assistant")
|
|
7735
|
+
if (role !== "user" && role !== "assistant") {
|
|
7736
|
+
return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
|
|
7737
|
+
}
|
|
6624
7738
|
const text = extractCodexText(payload.content);
|
|
6625
|
-
if (!text)
|
|
6626
|
-
|
|
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) {
|
|
6627
7752
|
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6628
7753
|
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
6629
7754
|
return JSON.stringify({
|
|
@@ -6828,6 +7953,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
|
|
|
6828
7953
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
6829
7954
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6830
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;
|
|
6831
7958
|
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6832
7959
|
var DISCOVERY_TTL_MS = 15e3;
|
|
6833
7960
|
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
@@ -6884,6 +8011,11 @@ var StreamerServer = class {
|
|
|
6884
8011
|
// to pendingQuestions; mobile answers it by sending the option index via
|
|
6885
8012
|
// /input { keys }. Cleared when the gate closes.
|
|
6886
8013
|
pendingPermission = /* @__PURE__ */ new Map();
|
|
8014
|
+
// Content key (prompt + detail + options + cursor) of the permission gate
|
|
8015
|
+
// currently broadcast for a session — mirrors pendingQuestionKey so a PTY
|
|
8016
|
+
// repaint of the same gate doesn't re-broadcast on every tick. Cleared
|
|
8017
|
+
// alongside pendingPermission.
|
|
8018
|
+
pendingPermissionKey = /* @__PURE__ */ new Map();
|
|
6887
8019
|
scanner = null;
|
|
6888
8020
|
// Set when better-sqlite3 is unusable (e.g. node ABI mismatch made
|
|
6889
8021
|
// ConversationCache.open throw), or when config.scannerPersistent is false
|
|
@@ -6944,6 +8076,16 @@ var StreamerServer = class {
|
|
|
6944
8076
|
defaultPermissionMode;
|
|
6945
8077
|
defaultModel;
|
|
6946
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;
|
|
6947
8089
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6948
8090
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6949
8091
|
// Consecutive grace-timer defers for a still-`running` session (see
|
|
@@ -6951,6 +8093,18 @@ var StreamerServer = class {
|
|
|
6951
8093
|
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6952
8094
|
// Map of sessionId → set of subscribed WS clients
|
|
6953
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;
|
|
6954
8108
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
6955
8109
|
clientIdToWs = /* @__PURE__ */ new Map();
|
|
6956
8110
|
// Reverse map for cleanup on close
|
|
@@ -6960,7 +8114,20 @@ var StreamerServer = class {
|
|
|
6960
8114
|
projectsRepo = null;
|
|
6961
8115
|
conversationsRepo = null;
|
|
6962
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)();
|
|
6963
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;
|
|
6964
8131
|
discoveryCache = null;
|
|
6965
8132
|
cacheDir;
|
|
6966
8133
|
tailSize;
|
|
@@ -6999,6 +8166,9 @@ var StreamerServer = class {
|
|
|
6999
8166
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
7000
8167
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
7001
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();
|
|
7002
8172
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os9.homedir)(), ".threadbase", "cache");
|
|
7003
8173
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
7004
8174
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
@@ -7123,6 +8293,7 @@ var StreamerServer = class {
|
|
|
7123
8293
|
this.ptyManager = new LiveSessionManager({
|
|
7124
8294
|
logger: getLogger("pty"),
|
|
7125
8295
|
onOutput: (sessionId, data) => {
|
|
8296
|
+
this.lastAgentChunkAt.set(sessionId, Date.now());
|
|
7126
8297
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
7127
8298
|
},
|
|
7128
8299
|
onUserMessage: (sessionId, text, ts) => {
|
|
@@ -7151,6 +8322,17 @@ var StreamerServer = class {
|
|
|
7151
8322
|
completedAt: session.completedAt,
|
|
7152
8323
|
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
|
|
7153
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
|
+
);
|
|
7154
8336
|
if (session.status === "waiting_input" || session.status === "idle") {
|
|
7155
8337
|
const filePath = this.sessionFileMap.get(session.id);
|
|
7156
8338
|
if (filePath) {
|
|
@@ -7181,6 +8363,7 @@ var StreamerServer = class {
|
|
|
7181
8363
|
this.cancelPendingQuestion(session.id);
|
|
7182
8364
|
}
|
|
7183
8365
|
this.pendingPermission.delete(session.id);
|
|
8366
|
+
this.pendingPermissionKey.delete(session.id);
|
|
7184
8367
|
this.contendedSessions.delete(session.id);
|
|
7185
8368
|
this.rememberSelfPtyEnded(session.id);
|
|
7186
8369
|
}
|
|
@@ -7221,6 +8404,8 @@ var StreamerServer = class {
|
|
|
7221
8404
|
localNoAuth: this.localNoAuth,
|
|
7222
8405
|
logMenubarRequests: this.logMenubarRequests,
|
|
7223
8406
|
rotateApiKey: () => this.rotateApiKey(),
|
|
8407
|
+
claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
|
|
8408
|
+
setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
|
|
7224
8409
|
publicUrl: this.publicUrl,
|
|
7225
8410
|
browseRoot: this.browseRoot,
|
|
7226
8411
|
browserCors: this.browserCors,
|
|
@@ -7229,6 +8414,8 @@ var StreamerServer = class {
|
|
|
7229
8414
|
wsHub: this.wsHub,
|
|
7230
8415
|
cache: () => this.cache,
|
|
7231
8416
|
cacheMonitor: () => this.cacheMonitor,
|
|
8417
|
+
pushRepo: () => this.pushRepo,
|
|
8418
|
+
devicesRepo: () => this.devicesRepo,
|
|
7232
8419
|
projectsRepo: () => this.projectsRepo,
|
|
7233
8420
|
conversationsRepo: () => this.conversationsRepo,
|
|
7234
8421
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -7262,7 +8449,9 @@ var StreamerServer = class {
|
|
|
7262
8449
|
handleMkdir: (req, res) => this.handleMkdir(req, res),
|
|
7263
8450
|
handleWsOpen: (ws) => {
|
|
7264
8451
|
this.wsHub.addClient(ws);
|
|
7265
|
-
const sessions = this.
|
|
8452
|
+
const sessions = this.withReconciledLifecycle(
|
|
8453
|
+
this.sessionStore.list(this.ptyAttachedIds())
|
|
8454
|
+
);
|
|
7266
8455
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
7267
8456
|
if (!this.currentWarmupState()) {
|
|
7268
8457
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
@@ -7338,11 +8527,8 @@ var StreamerServer = class {
|
|
|
7338
8527
|
this.clientIdToWs.delete(clientId);
|
|
7339
8528
|
this.wsToClientId.delete(ws);
|
|
7340
8529
|
}
|
|
7341
|
-
for (const
|
|
8530
|
+
for (const subscribers of this.sessionSubscribers.values()) {
|
|
7342
8531
|
subscribers.delete(ws);
|
|
7343
|
-
if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
|
|
7344
|
-
this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
|
|
7345
|
-
}
|
|
7346
8532
|
}
|
|
7347
8533
|
},
|
|
7348
8534
|
agentClient,
|
|
@@ -7405,7 +8591,7 @@ var StreamerServer = class {
|
|
|
7405
8591
|
const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
|
|
7406
8592
|
const payload = {
|
|
7407
8593
|
type: "session_list",
|
|
7408
|
-
sessions: this.sessionStore.list(this.ptyAttachedIds())
|
|
8594
|
+
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
7409
8595
|
};
|
|
7410
8596
|
if (ws) {
|
|
7411
8597
|
this.wsHub.unicast(ws, payload);
|
|
@@ -7413,6 +8599,29 @@ var StreamerServer = class {
|
|
|
7413
8599
|
this.wsHub.broadcast(payload);
|
|
7414
8600
|
}
|
|
7415
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
|
+
}
|
|
7416
8625
|
addSessionSubscriber(sessionId, ws) {
|
|
7417
8626
|
let subs = this.sessionSubscribers.get(sessionId);
|
|
7418
8627
|
if (!subs) {
|
|
@@ -7427,6 +8636,181 @@ var StreamerServer = class {
|
|
|
7427
8636
|
}
|
|
7428
8637
|
this.ptyGraceDeferCounts.delete(sessionId);
|
|
7429
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
|
+
}
|
|
7430
8814
|
startGraceTimer(sessionId, delayMs) {
|
|
7431
8815
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
7432
8816
|
if (existing) clearTimeout(existing);
|
|
@@ -7521,6 +8905,8 @@ var StreamerServer = class {
|
|
|
7521
8905
|
this.log.info("Database migrations applied", { event: "db.migrations_applied" });
|
|
7522
8906
|
}
|
|
7523
8907
|
await this.bindWithRetry(port);
|
|
8908
|
+
this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
|
|
8909
|
+
this.idleReaperTimer.unref?.();
|
|
7524
8910
|
const warmUp = new Promise((resolveWarm) => {
|
|
7525
8911
|
{
|
|
7526
8912
|
this.log.info(`Streamer server listening on port ${port}`, {
|
|
@@ -7554,7 +8940,11 @@ var StreamerServer = class {
|
|
|
7554
8940
|
this.projectsRepo = new ProjectsRepository(db);
|
|
7555
8941
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
7556
8942
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
8943
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(db);
|
|
8944
|
+
void this.reconcilePreviousSessions();
|
|
7557
8945
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
8946
|
+
this.pushRepo = new PushRepository(db);
|
|
8947
|
+
this.devicesRepo = new DevicesRepository(db);
|
|
7558
8948
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7559
8949
|
this.cache,
|
|
7560
8950
|
this.wsHub,
|
|
@@ -7772,6 +9162,12 @@ var StreamerServer = class {
|
|
|
7772
9162
|
async close() {
|
|
7773
9163
|
for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
|
|
7774
9164
|
this.ptyGraceTimers.clear();
|
|
9165
|
+
if (this.idleReaperTimer) {
|
|
9166
|
+
clearInterval(this.idleReaperTimer);
|
|
9167
|
+
this.idleReaperTimer = null;
|
|
9168
|
+
}
|
|
9169
|
+
this.lastAgentChunkAt.clear();
|
|
9170
|
+
this.recordShutdownState();
|
|
7775
9171
|
this.markScannerStaleDebounced.cancel();
|
|
7776
9172
|
await Promise.all([...this.inFlightCacheWrites]);
|
|
7777
9173
|
await Promise.all([...this.allScanners].map((s) => s.close()));
|
|
@@ -7861,12 +9257,28 @@ var StreamerServer = class {
|
|
|
7861
9257
|
ip,
|
|
7862
9258
|
ts
|
|
7863
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
|
+
}
|
|
7864
9271
|
json(res, 200, {
|
|
7865
9272
|
ciphertext: sealed.ciphertext,
|
|
7866
9273
|
nonce: sealed.nonce,
|
|
7867
9274
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
7868
9275
|
publicUrl: this.publicUrl,
|
|
7869
|
-
machineName: hostname2()
|
|
9276
|
+
machineName: hostname2(),
|
|
9277
|
+
...device && {
|
|
9278
|
+
deviceId: device.deviceId,
|
|
9279
|
+
deviceToken: device.deviceToken,
|
|
9280
|
+
capabilities: device.capabilities
|
|
9281
|
+
}
|
|
7870
9282
|
});
|
|
7871
9283
|
}
|
|
7872
9284
|
rotateApiKey() {
|
|
@@ -7883,6 +9295,48 @@ var StreamerServer = class {
|
|
|
7883
9295
|
});
|
|
7884
9296
|
return { newKey, persisted };
|
|
7885
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
|
+
}
|
|
7886
9340
|
checkRateLimit(map, key, limit, windowMs) {
|
|
7887
9341
|
const now = Date.now();
|
|
7888
9342
|
const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
@@ -8900,7 +10354,13 @@ var StreamerServer = class {
|
|
|
8900
10354
|
}
|
|
8901
10355
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
8902
10356
|
if (!hasPaginationParams) {
|
|
8903
|
-
json(
|
|
10357
|
+
json(
|
|
10358
|
+
res,
|
|
10359
|
+
200,
|
|
10360
|
+
this.withExternalActivity(
|
|
10361
|
+
this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
10362
|
+
)
|
|
10363
|
+
);
|
|
8904
10364
|
return;
|
|
8905
10365
|
}
|
|
8906
10366
|
const parsed = parseSessionListQuery(url);
|
|
@@ -8910,7 +10370,7 @@ var StreamerServer = class {
|
|
|
8910
10370
|
}
|
|
8911
10371
|
try {
|
|
8912
10372
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8913
|
-
page.sessions = this.withExternalActivity(page.sessions);
|
|
10373
|
+
page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
|
|
8914
10374
|
json(res, 200, page);
|
|
8915
10375
|
} catch (err) {
|
|
8916
10376
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -8927,6 +10387,9 @@ var StreamerServer = class {
|
|
|
8927
10387
|
if (!(0, import_fs18.existsSync)(session.projectPath)) {
|
|
8928
10388
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
8929
10389
|
}
|
|
10390
|
+
const reconciled = this.withReconciledLifecycle([session])[0];
|
|
10391
|
+
session.lifecycle = reconciled.lifecycle;
|
|
10392
|
+
session.lifecycleSource = reconciled.lifecycleSource;
|
|
8930
10393
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
8931
10394
|
try {
|
|
8932
10395
|
const lines = await this.ptyManager.getOutputLines(sessionId, 10);
|
|
@@ -9024,10 +10487,13 @@ var StreamerServer = class {
|
|
|
9024
10487
|
projectName: body.projectName,
|
|
9025
10488
|
branch: body.branch,
|
|
9026
10489
|
permissionMode: this.defaultPermissionMode,
|
|
10490
|
+
claudeFlags: this.claudeFlags,
|
|
10491
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9027
10492
|
model: this.defaultModel,
|
|
9028
10493
|
effort: this.defaultEffort
|
|
9029
10494
|
});
|
|
9030
10495
|
this.sessionStore.addManaged(session);
|
|
10496
|
+
this.recordSessionSpawn(session);
|
|
9031
10497
|
void this.watchConversationFile(sessionId);
|
|
9032
10498
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
9033
10499
|
this.broadcastOrUnicastSessionList(req);
|
|
@@ -9100,6 +10566,24 @@ var StreamerServer = class {
|
|
|
9100
10566
|
}
|
|
9101
10567
|
const body = await readBody(req);
|
|
9102
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
|
+
}
|
|
9103
10587
|
if (typeof keys === "string") {
|
|
9104
10588
|
try {
|
|
9105
10589
|
this.ptyManager.sendKeys(sessionId, keys);
|
|
@@ -9107,7 +10591,9 @@ var StreamerServer = class {
|
|
|
9107
10591
|
if (updated) {
|
|
9108
10592
|
this.wsHub.broadcast({ type: "session_update", session: updated });
|
|
9109
10593
|
}
|
|
9110
|
-
|
|
10594
|
+
const result = { status: 200, body: { ok: true } };
|
|
10595
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10596
|
+
json(res, result.status, result.body);
|
|
9111
10597
|
} catch (err) {
|
|
9112
10598
|
const message = err instanceof Error ? err.message : "Failed to send keys";
|
|
9113
10599
|
json(res, 400, { error: message });
|
|
@@ -9145,7 +10631,9 @@ var StreamerServer = class {
|
|
|
9145
10631
|
});
|
|
9146
10632
|
});
|
|
9147
10633
|
}
|
|
9148
|
-
|
|
10634
|
+
const result = { status: 200, body: { ok: true } };
|
|
10635
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10636
|
+
json(res, result.status, result.body);
|
|
9149
10637
|
} catch (err) {
|
|
9150
10638
|
const message = err instanceof Error ? err.message : "Failed to send input";
|
|
9151
10639
|
json(res, 400, { error: message });
|
|
@@ -9217,10 +10705,14 @@ var StreamerServer = class {
|
|
|
9217
10705
|
if (gate === null) {
|
|
9218
10706
|
if (!this.pendingPermission.has(sessionId)) return;
|
|
9219
10707
|
this.pendingPermission.delete(sessionId);
|
|
10708
|
+
this.pendingPermissionKey.delete(sessionId);
|
|
9220
10709
|
this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
|
|
9221
10710
|
return;
|
|
9222
10711
|
}
|
|
10712
|
+
const key = permissionContentKey(gate);
|
|
10713
|
+
if (this.pendingPermissionKey.get(sessionId) === key) return;
|
|
9223
10714
|
this.pendingPermission.set(sessionId, gate);
|
|
10715
|
+
this.pendingPermissionKey.set(sessionId, key);
|
|
9224
10716
|
const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
|
|
9225
10717
|
this.log.info(
|
|
9226
10718
|
`[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
|
|
@@ -9446,10 +10938,13 @@ var StreamerServer = class {
|
|
|
9446
10938
|
projectName,
|
|
9447
10939
|
branch,
|
|
9448
10940
|
permissionMode: this.defaultPermissionMode,
|
|
10941
|
+
claudeFlags: this.claudeFlags,
|
|
10942
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9449
10943
|
model: this.defaultModel,
|
|
9450
10944
|
effort: this.defaultEffort
|
|
9451
10945
|
});
|
|
9452
10946
|
this.sessionStore.addManaged(session);
|
|
10947
|
+
this.recordSessionSpawn(session);
|
|
9453
10948
|
void this.watchConversationFile(session.id);
|
|
9454
10949
|
this.wsHub.broadcast({
|
|
9455
10950
|
type: "session_list",
|
|
@@ -9519,10 +11014,13 @@ var StreamerServer = class {
|
|
|
9519
11014
|
projectName: body.projectName,
|
|
9520
11015
|
systemPrompt: systemPromptParts.join("\n"),
|
|
9521
11016
|
permissionMode: this.defaultPermissionMode,
|
|
11017
|
+
claudeFlags: this.claudeFlags,
|
|
11018
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9522
11019
|
model: this.defaultModel,
|
|
9523
11020
|
effort: this.defaultEffort
|
|
9524
11021
|
});
|
|
9525
11022
|
this.sessionStore.addManaged(session);
|
|
11023
|
+
this.recordSessionSpawn(session);
|
|
9526
11024
|
const readyOrFailed = new Promise((resolve2) => {
|
|
9527
11025
|
const handler = (status) => {
|
|
9528
11026
|
if (status === "waiting_input" || status === "idle") {
|
|
@@ -10044,6 +11542,7 @@ function readBody(req) {
|
|
|
10044
11542
|
SessionStore,
|
|
10045
11543
|
StreamerServer,
|
|
10046
11544
|
WSHub,
|
|
11545
|
+
confidenceForSource,
|
|
10047
11546
|
createAgentClient,
|
|
10048
11547
|
createConversationWriter,
|
|
10049
11548
|
createPool,
|