@threadbase-sh/streamer 1.36.4 → 1.38.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 +27606 -25177
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +2568 -205
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +503 -4
- package/dist/index.d.ts +503 -4
- package/dist/index.js +2570 -208
- package/dist/index.js.map +1 -1
- package/dist/launchd-entry.cjs +113 -30
- package/dist/launchd-entry.cjs.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/dist/migrations/013_add_push_token_kind.sql +63 -0
- package/dist/pg-migrations/007_create_push_tokens.sql +93 -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,217 @@ 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/feature-flags.ts
|
|
492
|
+
var FEATURE_FLAGS = [
|
|
493
|
+
{
|
|
494
|
+
id: "codexSystemPrompt",
|
|
495
|
+
description: "Send the built system prompt to fresh Codex sessions. Off by default: Codex has no --system-prompt flag, so the prompt goes in the positional [PROMPT] argument, which Codex treats as the user's opening turn rather than a system-level instruction.",
|
|
496
|
+
default: false,
|
|
497
|
+
env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
|
|
498
|
+
}
|
|
499
|
+
];
|
|
500
|
+
function findFeatureFlag(id) {
|
|
501
|
+
return FEATURE_FLAGS.find((f) => f.id === id);
|
|
502
|
+
}
|
|
503
|
+
function parseBooleanEnv(raw) {
|
|
504
|
+
if (raw === void 0) return void 0;
|
|
505
|
+
const v = raw.trim().toLowerCase();
|
|
506
|
+
if (v === "") return false;
|
|
507
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
508
|
+
}
|
|
509
|
+
function validateFeatureFlagValues(raw) {
|
|
510
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
511
|
+
const out = {};
|
|
512
|
+
const dropped = [];
|
|
513
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
514
|
+
if (!findFeatureFlag(id) || typeof value !== "boolean") {
|
|
515
|
+
dropped.push(id);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
out[id] = value;
|
|
519
|
+
}
|
|
520
|
+
if (dropped.length > 0) {
|
|
521
|
+
getLogger("feature-flags").warn(
|
|
522
|
+
`Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
|
|
523
|
+
{
|
|
524
|
+
event: "config.feature_flags_dropped",
|
|
525
|
+
dropped
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
function resolveFeatureFlags(opts) {
|
|
532
|
+
const env = opts?.env ?? process.env;
|
|
533
|
+
const out = {};
|
|
534
|
+
for (const def of FEATURE_FLAGS) {
|
|
535
|
+
out[def.id] = parseBooleanEnv(env[def.env]) ?? opts?.cli?.[def.id] ?? opts?.yaml?.[def.id] ?? def.default;
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
function nonDefaultFeatureFlags(values) {
|
|
540
|
+
return FEATURE_FLAGS.filter((f) => values[f.id] !== f.default).map((f) => f.id);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/auth.ts
|
|
332
544
|
function configDir() {
|
|
333
545
|
return process.env.THREADBASE_CONFIG_DIR ?? (0, import_path.join)((0, import_os.homedir)(), ".threadbase");
|
|
334
546
|
}
|
|
@@ -407,11 +619,94 @@ function loadDefaultPermissionMode() {
|
|
|
407
619
|
const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
|
|
408
620
|
const match = content.match(/default_permission_mode:\s*(\S+)/);
|
|
409
621
|
const value = match?.[1]?.trim();
|
|
410
|
-
if (value
|
|
622
|
+
if (isPermissionMode(value)) return value;
|
|
623
|
+
} catch {
|
|
624
|
+
}
|
|
625
|
+
return void 0;
|
|
626
|
+
}
|
|
627
|
+
function setConfigValue(key, value) {
|
|
628
|
+
const file = configFile();
|
|
629
|
+
(0, import_fs.mkdirSync)(configDir(), { recursive: true });
|
|
630
|
+
let content = "";
|
|
631
|
+
try {
|
|
632
|
+
content = (0, import_fs.readFileSync)(file, "utf-8");
|
|
633
|
+
} catch (err) {
|
|
634
|
+
if (err.code !== "ENOENT") throw err;
|
|
635
|
+
}
|
|
636
|
+
const lineRe = new RegExp(`^${key}:\\s*.*$\\n?`, "m");
|
|
637
|
+
let updated;
|
|
638
|
+
if (value === void 0) {
|
|
639
|
+
updated = content.replace(lineRe, "");
|
|
640
|
+
} else {
|
|
641
|
+
const line = `${key}: ${value}`;
|
|
642
|
+
if (lineRe.test(content)) {
|
|
643
|
+
updated = content.replace(lineRe, `${line}
|
|
644
|
+
`);
|
|
645
|
+
} else if (content.length === 0 || content.endsWith("\n")) {
|
|
646
|
+
updated = `${content}${line}
|
|
647
|
+
`;
|
|
648
|
+
} else {
|
|
649
|
+
updated = `${content}
|
|
650
|
+
${line}
|
|
651
|
+
`;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const tmpFile = `${file}.tmp`;
|
|
655
|
+
(0, import_fs.writeFileSync)(tmpFile, updated, { encoding: "utf-8", mode: 384 });
|
|
656
|
+
(0, import_fs.chmodSync)(tmpFile, 384);
|
|
657
|
+
(0, import_fs.renameSync)(tmpFile, file);
|
|
658
|
+
}
|
|
659
|
+
function loadClaudeFlags() {
|
|
660
|
+
try {
|
|
661
|
+
const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
|
|
662
|
+
const match = content.match(/^claude_flags:\s*(.+)$/m);
|
|
663
|
+
if (!match?.[1]) return {};
|
|
664
|
+
return validateFlagValues(JSON.parse(match[1].trim()));
|
|
665
|
+
} catch (err) {
|
|
666
|
+
if (err.code !== "ENOENT") {
|
|
667
|
+
getLogger("auth").warn(`Ignoring unreadable claude_flags in server.yaml: ${String(err)}`, {
|
|
668
|
+
event: "config.claude_flags_parse_failed"
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return {};
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function setClaudeFlags(values) {
|
|
675
|
+
const safe = validateFlagValues(values);
|
|
676
|
+
setConfigValue("claude_flags", Object.keys(safe).length === 0 ? void 0 : JSON.stringify(safe));
|
|
677
|
+
}
|
|
678
|
+
function loadClaudeExtraArgs() {
|
|
679
|
+
try {
|
|
680
|
+
const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
|
|
681
|
+
const match = content.match(/^claude_extra_args:\s*(.+)$/m);
|
|
682
|
+
const value = match?.[1]?.trim();
|
|
683
|
+
return value && value.length > 0 ? value : void 0;
|
|
411
684
|
} catch {
|
|
412
685
|
}
|
|
413
686
|
return void 0;
|
|
414
687
|
}
|
|
688
|
+
function setClaudeExtraArgs(text) {
|
|
689
|
+
const trimmed = text?.trim();
|
|
690
|
+
if (trimmed && /[\r\n]/.test(trimmed)) {
|
|
691
|
+
throw new Error("claude_extra_args must not contain newlines");
|
|
692
|
+
}
|
|
693
|
+
setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
|
|
694
|
+
}
|
|
695
|
+
function loadFeatureFlags() {
|
|
696
|
+
try {
|
|
697
|
+
const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
|
|
698
|
+
const match = content.match(/^feature_flags:\s*(.+)$/m);
|
|
699
|
+
if (!match?.[1]) return {};
|
|
700
|
+
return validateFeatureFlagValues(JSON.parse(match[1].trim()));
|
|
701
|
+
} catch (err) {
|
|
702
|
+
if (err.code !== "ENOENT") {
|
|
703
|
+
getLogger("auth").warn(`Ignoring unreadable feature_flags in server.yaml: ${String(err)}`, {
|
|
704
|
+
event: "config.feature_flags_parse_failed"
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
return {};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
415
710
|
function validatePublicUrl(raw) {
|
|
416
711
|
let parsed;
|
|
417
712
|
try {
|
|
@@ -560,42 +855,6 @@ var import_crypto2 = require("crypto");
|
|
|
560
855
|
var import_fs5 = require("fs");
|
|
561
856
|
var import_path5 = require("path");
|
|
562
857
|
|
|
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
858
|
// src/platform.ts
|
|
600
859
|
var import_child_process = require("child_process");
|
|
601
860
|
var import_fs3 = require("fs");
|
|
@@ -947,6 +1206,8 @@ var CodexPtyRunner = class {
|
|
|
947
1206
|
projectName,
|
|
948
1207
|
branch: options.branch ?? "",
|
|
949
1208
|
status: "running",
|
|
1209
|
+
statusSource: "spawn",
|
|
1210
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
950
1211
|
startedAt: /* @__PURE__ */ new Date(),
|
|
951
1212
|
completedAt: null,
|
|
952
1213
|
promptCount: 0,
|
|
@@ -994,6 +1255,8 @@ var CodexPtyRunner = class {
|
|
|
994
1255
|
projectName,
|
|
995
1256
|
branch: "",
|
|
996
1257
|
status: "running",
|
|
1258
|
+
statusSource: "spawn",
|
|
1259
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
997
1260
|
startedAt: /* @__PURE__ */ new Date(),
|
|
998
1261
|
completedAt: null,
|
|
999
1262
|
promptCount: 0,
|
|
@@ -1024,7 +1287,7 @@ var CodexPtyRunner = class {
|
|
|
1024
1287
|
this.readyFallbackTimers.delete(sessionId);
|
|
1025
1288
|
const session = this.sessions.get(sessionId);
|
|
1026
1289
|
if (session?.status === "running" && this.pendingReady.has(sessionId)) {
|
|
1027
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
1290
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
1028
1291
|
}
|
|
1029
1292
|
}, CODEX_READY_FALLBACK_MS);
|
|
1030
1293
|
timer.unref?.();
|
|
@@ -1039,6 +1302,8 @@ var CodexPtyRunner = class {
|
|
|
1039
1302
|
}
|
|
1040
1303
|
if (session.status === "waiting_input") {
|
|
1041
1304
|
session.status = "running";
|
|
1305
|
+
session.statusSource = "user-input";
|
|
1306
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1042
1307
|
this.onStatusChange?.(toPublicSession(session));
|
|
1043
1308
|
}
|
|
1044
1309
|
const gate = this.openGate.get(sessionId);
|
|
@@ -1108,6 +1373,8 @@ var CodexPtyRunner = class {
|
|
|
1108
1373
|
}
|
|
1109
1374
|
if (session.status === "waiting_input") {
|
|
1110
1375
|
session.status = "running";
|
|
1376
|
+
session.statusSource = "user-input";
|
|
1377
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1111
1378
|
this.onStatusChange?.(toPublicSession(session));
|
|
1112
1379
|
}
|
|
1113
1380
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -1208,6 +1475,8 @@ var CodexPtyRunner = class {
|
|
|
1208
1475
|
} catch {
|
|
1209
1476
|
}
|
|
1210
1477
|
session.status = "idle";
|
|
1478
|
+
session.statusSource = "shutdown";
|
|
1479
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1211
1480
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1212
1481
|
session.screen.dispose();
|
|
1213
1482
|
this.sessions.delete(sessionId);
|
|
@@ -1252,6 +1521,11 @@ var CodexPtyRunner = class {
|
|
|
1252
1521
|
getInputHistory(sessionId) {
|
|
1253
1522
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
1254
1523
|
}
|
|
1524
|
+
// OS pid of the spawned agent, or null if the session isn't live here.
|
|
1525
|
+
// Mirrors PTYManager.getPid — see there for why the registry records it.
|
|
1526
|
+
getPid(sessionId) {
|
|
1527
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
1528
|
+
}
|
|
1255
1529
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
1256
1530
|
// Called from writeSubmit (direct and flush paths) — never from sendKeys.
|
|
1257
1531
|
recordUserMessage(session, text) {
|
|
@@ -1353,9 +1627,9 @@ var CodexPtyRunner = class {
|
|
|
1353
1627
|
if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
|
|
1354
1628
|
const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
|
|
1355
1629
|
if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
|
|
1356
|
-
this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1630
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1357
1631
|
} else if (trigger === "quiet") {
|
|
1358
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
1632
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
1359
1633
|
}
|
|
1360
1634
|
}
|
|
1361
1635
|
// Answer a gate from the persisted remember-store, or surface it as a
|
|
@@ -1386,9 +1660,11 @@ var CodexPtyRunner = class {
|
|
|
1386
1660
|
});
|
|
1387
1661
|
this.onPermissionChange?.(sessionId, card);
|
|
1388
1662
|
}
|
|
1389
|
-
markReady(sessionId, session, reason) {
|
|
1663
|
+
markReady(sessionId, session, source, reason) {
|
|
1390
1664
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
1391
1665
|
session.status = "waiting_input";
|
|
1666
|
+
session.statusSource = source;
|
|
1667
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1392
1668
|
this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
|
|
1393
1669
|
event: "codex.ready",
|
|
1394
1670
|
sessionId,
|
|
@@ -1406,6 +1682,8 @@ var CodexPtyRunner = class {
|
|
|
1406
1682
|
if (!session) return;
|
|
1407
1683
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1408
1684
|
session.status = "idle";
|
|
1685
|
+
session.statusSource = "process-exit";
|
|
1686
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1409
1687
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
1410
1688
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
1411
1689
|
if (!(0, import_fs5.existsSync)(session.projectPath)) {
|
|
@@ -1435,6 +1713,8 @@ function toPublicSession(s) {
|
|
|
1435
1713
|
lastOutput: s.lastOutput,
|
|
1436
1714
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
1437
1715
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
1716
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
1717
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
1438
1718
|
...s.filePath != null && { filePath: s.filePath }
|
|
1439
1719
|
};
|
|
1440
1720
|
}
|
|
@@ -1763,16 +2043,17 @@ var PTYManager = class {
|
|
|
1763
2043
|
}
|
|
1764
2044
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
1765
2045
|
//
|
|
1766
|
-
// options.permissionMode defaults to `acceptEdits`
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
//
|
|
1770
|
-
//
|
|
1771
|
-
//
|
|
1772
|
-
//
|
|
1773
|
-
//
|
|
1774
|
-
// `
|
|
1775
|
-
//
|
|
2046
|
+
// options.permissionMode defaults to `acceptEdits` — the safe default that
|
|
2047
|
+
// auto-approves file edits while still prompting for shell commands. All six
|
|
2048
|
+
// Claude CLI modes are accepted (see PERMISSION_MODES in claude-flags.ts).
|
|
2049
|
+
//
|
|
2050
|
+
// On the bypass modes: `bypassPermissions`/`dontAsk` DO trigger a blocking
|
|
2051
|
+
// "Bypass Permissions mode" warning menu at boot ("1. No, exit" /
|
|
2052
|
+
// "2. Yes, I accept") which would strand the PTY and leave mobile on an empty
|
|
2053
|
+
// screen. buildSettingsJson() suppresses it by adding
|
|
2054
|
+
// `skipDangerousModePermissionPrompt` to the `--settings` blob for exactly
|
|
2055
|
+
// those modes — probe-verified on Claude Code v2.1.218. We never pass
|
|
2056
|
+
// `--dangerously-skip-permissions`; bypass is requested via --permission-mode.
|
|
1776
2057
|
// (The other first-run gates — onboarding/theme, workspace trust,
|
|
1777
2058
|
// custom-API-key — are cleared by the seeded ~/.claude.json in
|
|
1778
2059
|
// docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
|
|
@@ -1790,28 +2071,27 @@ var PTYManager = class {
|
|
|
1790
2071
|
async doStart(sessionId, options) {
|
|
1791
2072
|
const nodePty = await loadPty2();
|
|
1792
2073
|
const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
);
|
|
2074
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
2075
|
+
const args = [
|
|
2076
|
+
"--permission-mode",
|
|
2077
|
+
permissionMode,
|
|
2078
|
+
"--settings",
|
|
2079
|
+
buildSettingsJson(permissionMode),
|
|
2080
|
+
"--model",
|
|
2081
|
+
options.model ?? "sonnet",
|
|
2082
|
+
"--effort",
|
|
2083
|
+
options.effort ?? "low",
|
|
2084
|
+
"--resume",
|
|
2085
|
+
sessionId
|
|
2086
|
+
];
|
|
2087
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
2088
|
+
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
2089
|
+
name: "xterm-256color",
|
|
2090
|
+
cols: 120,
|
|
2091
|
+
rows: 40,
|
|
2092
|
+
cwd: options.projectPath,
|
|
2093
|
+
env: buildSpawnEnv()
|
|
2094
|
+
});
|
|
1815
2095
|
const session = {
|
|
1816
2096
|
id: sessionId,
|
|
1817
2097
|
provider: CLAUDE_CODE_PROVIDER,
|
|
@@ -1819,6 +2099,8 @@ var PTYManager = class {
|
|
|
1819
2099
|
projectName,
|
|
1820
2100
|
branch: options.branch ?? "",
|
|
1821
2101
|
status: "running",
|
|
2102
|
+
statusSource: "spawn",
|
|
2103
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1822
2104
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1823
2105
|
completedAt: null,
|
|
1824
2106
|
promptCount: 0,
|
|
@@ -1846,11 +2128,12 @@ var PTYManager = class {
|
|
|
1846
2128
|
const nodePty = await loadPty2();
|
|
1847
2129
|
const sessionId = (0, import_crypto3.randomUUID)();
|
|
1848
2130
|
const projectName = options.projectName ?? (0, import_path6.basename)(options.projectPath);
|
|
2131
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
1849
2132
|
const args = [
|
|
1850
2133
|
"--permission-mode",
|
|
1851
|
-
|
|
2134
|
+
permissionMode,
|
|
1852
2135
|
"--settings",
|
|
1853
|
-
|
|
2136
|
+
buildSettingsJson(permissionMode),
|
|
1854
2137
|
"--model",
|
|
1855
2138
|
options.model ?? "sonnet",
|
|
1856
2139
|
"--effort",
|
|
@@ -1861,6 +2144,7 @@ var PTYManager = class {
|
|
|
1861
2144
|
if (options.systemPrompt) {
|
|
1862
2145
|
args.push("--system-prompt", options.systemPrompt);
|
|
1863
2146
|
}
|
|
2147
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
1864
2148
|
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
1865
2149
|
name: "xterm-256color",
|
|
1866
2150
|
cols: 120,
|
|
@@ -1875,6 +2159,8 @@ var PTYManager = class {
|
|
|
1875
2159
|
projectName,
|
|
1876
2160
|
branch: "",
|
|
1877
2161
|
status: "running",
|
|
2162
|
+
statusSource: "spawn",
|
|
2163
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1878
2164
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1879
2165
|
completedAt: null,
|
|
1880
2166
|
promptCount: 0,
|
|
@@ -1905,6 +2191,8 @@ var PTYManager = class {
|
|
|
1905
2191
|
}
|
|
1906
2192
|
if (session.status === "waiting_input") {
|
|
1907
2193
|
session.status = "running";
|
|
2194
|
+
session.statusSource = "user-input";
|
|
2195
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1908
2196
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1909
2197
|
}
|
|
1910
2198
|
this.log.info(
|
|
@@ -1940,6 +2228,8 @@ var PTYManager = class {
|
|
|
1940
2228
|
}
|
|
1941
2229
|
if (session.status === "waiting_input") {
|
|
1942
2230
|
session.status = "running";
|
|
2231
|
+
session.statusSource = "user-input";
|
|
2232
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1943
2233
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1944
2234
|
}
|
|
1945
2235
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -2061,6 +2351,8 @@ var PTYManager = class {
|
|
|
2061
2351
|
} catch {
|
|
2062
2352
|
}
|
|
2063
2353
|
session.status = "idle";
|
|
2354
|
+
session.statusSource = "shutdown";
|
|
2355
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2064
2356
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2065
2357
|
session.screen.dispose();
|
|
2066
2358
|
this.sessions.delete(sessionId);
|
|
@@ -2096,6 +2388,13 @@ var PTYManager = class {
|
|
|
2096
2388
|
getInputHistory(sessionId) {
|
|
2097
2389
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
2098
2390
|
}
|
|
2391
|
+
// OS pid of the spawned agent, or null if the session isn't live here. The
|
|
2392
|
+
// durable registry records this so a later streamer run can probe whether the
|
|
2393
|
+
// process outlived it. Liveness alone is never identity — a recycled pid is
|
|
2394
|
+
// why the registry stores a cmdline alongside it.
|
|
2395
|
+
getPid(sessionId) {
|
|
2396
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
2397
|
+
}
|
|
2099
2398
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
2100
2399
|
// Called from writeSubmit (both direct and flush paths) — never from
|
|
2101
2400
|
// sendKeys, so raw keystrokes aren't logged as messages.
|
|
@@ -2172,9 +2471,9 @@ var PTYManager = class {
|
|
|
2172
2471
|
session.lastOutput = stripped;
|
|
2173
2472
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
|
|
2174
2473
|
if (session.status === "running" && matchedMarker) {
|
|
2175
|
-
this.markReady(sessionId, session, `marker:${matchedMarker}`);
|
|
2474
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
|
|
2176
2475
|
} else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
|
|
2177
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
2476
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
2178
2477
|
}
|
|
2179
2478
|
this.onOutput?.(sessionId, data);
|
|
2180
2479
|
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
@@ -2274,7 +2573,7 @@ var PTYManager = class {
|
|
|
2274
2573
|
const session = this.sessions.get(sessionId);
|
|
2275
2574
|
if (session?.status !== "running") return;
|
|
2276
2575
|
if (this.pendingReady.has(sessionId)) {
|
|
2277
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
2576
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
2278
2577
|
} else {
|
|
2279
2578
|
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2280
2579
|
this.log.warn("[pty.ready] screen recheck failed", {
|
|
@@ -2303,14 +2602,16 @@ var PTYManager = class {
|
|
|
2303
2602
|
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2304
2603
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2305
2604
|
if (matchedMarker && session.status === "running") {
|
|
2306
|
-
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2605
|
+
this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
|
|
2307
2606
|
}
|
|
2308
2607
|
}
|
|
2309
2608
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2310
2609
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2311
|
-
markReady(sessionId, session, reason) {
|
|
2610
|
+
markReady(sessionId, session, source, reason) {
|
|
2312
2611
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2313
2612
|
session.status = "waiting_input";
|
|
2613
|
+
session.statusSource = source;
|
|
2614
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2314
2615
|
const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
|
|
2315
2616
|
this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
|
|
2316
2617
|
event: "pty.ready",
|
|
@@ -2330,6 +2631,8 @@ var PTYManager = class {
|
|
|
2330
2631
|
if (!session) return;
|
|
2331
2632
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2332
2633
|
session.status = "idle";
|
|
2634
|
+
session.statusSource = "process-exit";
|
|
2635
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2333
2636
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
2334
2637
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
2335
2638
|
if (!(0, import_fs6.existsSync)(session.projectPath)) {
|
|
@@ -2364,6 +2667,8 @@ function toPublicSession2(s) {
|
|
|
2364
2667
|
lastOutput: s.lastOutput,
|
|
2365
2668
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
2366
2669
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2670
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2671
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2367
2672
|
...s.filePath != null && { filePath: s.filePath }
|
|
2368
2673
|
};
|
|
2369
2674
|
}
|
|
@@ -2436,6 +2741,16 @@ var LiveSessionManager = class {
|
|
|
2436
2741
|
}
|
|
2437
2742
|
return null;
|
|
2438
2743
|
}
|
|
2744
|
+
// Scans rather than using runnerFor(): the registry records a pid on a
|
|
2745
|
+
// best-effort basis, so an unknown session must return null rather than
|
|
2746
|
+
// throw the way the input-routing methods do.
|
|
2747
|
+
getPid(sessionId) {
|
|
2748
|
+
for (const runner of this.runners.values()) {
|
|
2749
|
+
const pid = runner.getPid(sessionId);
|
|
2750
|
+
if (pid != null) return pid;
|
|
2751
|
+
}
|
|
2752
|
+
return null;
|
|
2753
|
+
}
|
|
2439
2754
|
hasSession(sessionId) {
|
|
2440
2755
|
for (const runner of this.runners.values()) {
|
|
2441
2756
|
if (runner.hasSession(sessionId)) return true;
|
|
@@ -2612,6 +2927,18 @@ async function getProcessCwdUnix(pid) {
|
|
|
2612
2927
|
async function getProcessArgsUnix(pid) {
|
|
2613
2928
|
return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
|
|
2614
2929
|
}
|
|
2930
|
+
async function getProcessArgs(pid) {
|
|
2931
|
+
if (!Number.isInteger(pid) || pid < 1) return "";
|
|
2932
|
+
try {
|
|
2933
|
+
if ((0, import_os4.platform)() === "win32") {
|
|
2934
|
+
const info = await getProcessInfoWindows(pid);
|
|
2935
|
+
return info?.args ?? "";
|
|
2936
|
+
}
|
|
2937
|
+
return await getProcessArgsUnix(pid);
|
|
2938
|
+
} catch {
|
|
2939
|
+
return "";
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2615
2942
|
async function getProcessStartTimeUnix(pid) {
|
|
2616
2943
|
const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
|
|
2617
2944
|
const d = new Date(raw);
|
|
@@ -2727,6 +3054,7 @@ async function readGitBranch(dir) {
|
|
|
2727
3054
|
var import_node_ws = require("@hono/node-ws");
|
|
2728
3055
|
var import_client = require("@temporalio/client");
|
|
2729
3056
|
var import_scanner3 = require("@threadbase-sh/scanner");
|
|
3057
|
+
var import_crypto11 = require("crypto");
|
|
2730
3058
|
var import_events = require("events");
|
|
2731
3059
|
var import_fs18 = require("fs");
|
|
2732
3060
|
var import_promises7 = require("fs/promises");
|
|
@@ -2991,7 +3319,202 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2991
3319
|
}
|
|
2992
3320
|
|
|
2993
3321
|
// src/api/app.ts
|
|
2994
|
-
var
|
|
3322
|
+
var import_hono16 = require("hono");
|
|
3323
|
+
|
|
3324
|
+
// src/db/repositories/devices.repository.ts
|
|
3325
|
+
var import_crypto5 = require("crypto");
|
|
3326
|
+
|
|
3327
|
+
// src/services/security/capabilities.ts
|
|
3328
|
+
var CAPABILITIES = [
|
|
3329
|
+
"history:read",
|
|
3330
|
+
// read conversations, search
|
|
3331
|
+
"session:control",
|
|
3332
|
+
// start, resume, send input, interrupt
|
|
3333
|
+
"fs:browse",
|
|
3334
|
+
// browse the project tree
|
|
3335
|
+
"fs:upload",
|
|
3336
|
+
// upload files into a project
|
|
3337
|
+
"notifications",
|
|
3338
|
+
// register for push
|
|
3339
|
+
"admin"
|
|
3340
|
+
// rotate keys, manage devices
|
|
3341
|
+
];
|
|
3342
|
+
function isCapability(value) {
|
|
3343
|
+
return typeof value === "string" && CAPABILITIES.includes(value);
|
|
3344
|
+
}
|
|
3345
|
+
var FULL_CAPABILITIES = [
|
|
3346
|
+
"history:read",
|
|
3347
|
+
"session:control",
|
|
3348
|
+
"fs:browse",
|
|
3349
|
+
"fs:upload",
|
|
3350
|
+
"notifications"
|
|
3351
|
+
];
|
|
3352
|
+
var READ_ONLY_CAPABILITIES = ["history:read"];
|
|
3353
|
+
function capabilitiesForPreset(preset) {
|
|
3354
|
+
return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
|
|
3355
|
+
}
|
|
3356
|
+
function legacyPrincipal() {
|
|
3357
|
+
return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
|
|
3358
|
+
}
|
|
3359
|
+
function hasCapability(principal, required) {
|
|
3360
|
+
return principal.capabilities.includes(required);
|
|
3361
|
+
}
|
|
3362
|
+
var ROUTE_CAPABILITIES = [
|
|
3363
|
+
// Most specific first for readability; matching sorts by length anyway.
|
|
3364
|
+
["/api/sessions/", "session:control"],
|
|
3365
|
+
["/api/sessions", "history:read"],
|
|
3366
|
+
// listing sessions is a read
|
|
3367
|
+
["/api/conversations", "history:read"],
|
|
3368
|
+
["/api/projects", "history:read"],
|
|
3369
|
+
["/api/search", "history:read"],
|
|
3370
|
+
["/api/providers", "history:read"],
|
|
3371
|
+
["/api/browse", "fs:browse"],
|
|
3372
|
+
["/api/upload", "fs:upload"],
|
|
3373
|
+
["/api/push", "notifications"],
|
|
3374
|
+
["/api/devices", "admin"],
|
|
3375
|
+
["/api/config", "admin"],
|
|
3376
|
+
["/api/auth/rotate", "admin"],
|
|
3377
|
+
["/api/backup", "admin"],
|
|
3378
|
+
// Server identity and capability discovery. A read-only device must be able
|
|
3379
|
+
// to see WHICH server it is talking to and what it supports, or it cannot
|
|
3380
|
+
// render anything at all.
|
|
3381
|
+
["/api/info", "history:read"],
|
|
3382
|
+
["/api/profiles", "history:read"],
|
|
3383
|
+
["/api/diagnostics", "history:read"],
|
|
3384
|
+
["/api/cache/alert", "history:read"],
|
|
3385
|
+
// Client log shipping: any authenticated client may report its own errors.
|
|
3386
|
+
// Gating this behind a capability would silence diagnostics from exactly the
|
|
3387
|
+
// devices most likely to be misbehaving.
|
|
3388
|
+
["/api/__client-log", "history:read"],
|
|
3389
|
+
// Logs viewer is localhost-only and already bypasses this middleware; the
|
|
3390
|
+
// mapping exists so a remote request is classified rather than denied as
|
|
3391
|
+
// unclassified.
|
|
3392
|
+
["/api/logs", "admin"],
|
|
3393
|
+
// Pairing routes other than the public exchange (e.g. minting a token).
|
|
3394
|
+
["/api/pair", "admin"],
|
|
3395
|
+
// The live WebSocket. Subscribing is a read — terminal output, session
|
|
3396
|
+
// updates, conversation events. Control still flows through the HTTP input
|
|
3397
|
+
// routes, which carry their own capability check, so a read-only device can
|
|
3398
|
+
// watch a session stream without being able to drive it.
|
|
3399
|
+
["/ws", "history:read"],
|
|
3400
|
+
// Progress webhook (multi-agent). Authenticated by HMAC in the handler and
|
|
3401
|
+
// already skipped by the middleware; classified so a stray request is denied
|
|
3402
|
+
// by rule rather than as "unclassified".
|
|
3403
|
+
["/internal/sessions", "admin"]
|
|
3404
|
+
];
|
|
3405
|
+
function requiredCapability(path, method) {
|
|
3406
|
+
if (path.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
|
|
3407
|
+
return "history:read";
|
|
3408
|
+
}
|
|
3409
|
+
let best = null;
|
|
3410
|
+
for (const [prefix, cap] of ROUTE_CAPABILITIES) {
|
|
3411
|
+
if (path.startsWith(prefix) && (best === null || prefix.length > best.len)) {
|
|
3412
|
+
best = { len: prefix.length, cap };
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
return best?.cap ?? null;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
// src/db/repositories/devices.repository.ts
|
|
3419
|
+
function generateDeviceToken() {
|
|
3420
|
+
return (0, import_crypto5.randomBytes)(32).toString("base64url");
|
|
3421
|
+
}
|
|
3422
|
+
function hashDeviceToken(token) {
|
|
3423
|
+
return (0, import_crypto5.createHash)("sha256").update(token).digest("hex");
|
|
3424
|
+
}
|
|
3425
|
+
function safeHashEquals(a, b) {
|
|
3426
|
+
if (a.length !== b.length) return false;
|
|
3427
|
+
return (0, import_crypto5.timingSafeEqual)(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
3428
|
+
}
|
|
3429
|
+
function parseCapabilities(raw) {
|
|
3430
|
+
try {
|
|
3431
|
+
const parsed = JSON.parse(raw);
|
|
3432
|
+
if (!Array.isArray(parsed)) return [];
|
|
3433
|
+
return parsed.filter(isCapability);
|
|
3434
|
+
} catch {
|
|
3435
|
+
return [];
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
function toDeviceView(row) {
|
|
3439
|
+
return {
|
|
3440
|
+
deviceId: row.device_id,
|
|
3441
|
+
name: row.name,
|
|
3442
|
+
capabilities: parseCapabilities(row.capabilities),
|
|
3443
|
+
createdAt: row.created_at,
|
|
3444
|
+
lastSeenAt: row.last_seen_at,
|
|
3445
|
+
revokedAt: row.revoked_at
|
|
3446
|
+
};
|
|
3447
|
+
}
|
|
3448
|
+
var DevicesRepository = class {
|
|
3449
|
+
insertStmt;
|
|
3450
|
+
byTokenHashStmt;
|
|
3451
|
+
byIdStmt;
|
|
3452
|
+
listStmt;
|
|
3453
|
+
revokeStmt;
|
|
3454
|
+
touchStmt;
|
|
3455
|
+
constructor(db) {
|
|
3456
|
+
this.insertStmt = db.prepare(`
|
|
3457
|
+
INSERT INTO devices (
|
|
3458
|
+
device_id, public_key, token_hash, name, capabilities, created_at
|
|
3459
|
+
) VALUES (
|
|
3460
|
+
@device_id, @public_key, @token_hash, @name, @capabilities, @created_at
|
|
3461
|
+
)
|
|
3462
|
+
`);
|
|
3463
|
+
this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
|
|
3464
|
+
this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
|
|
3465
|
+
this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
|
|
3466
|
+
this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
|
|
3467
|
+
this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* Record a newly paired device and mint its token.
|
|
3471
|
+
*
|
|
3472
|
+
* The raw token is returned to the caller and never stored — this is the only
|
|
3473
|
+
* moment it exists outside the client.
|
|
3474
|
+
*/
|
|
3475
|
+
register(args) {
|
|
3476
|
+
const deviceId = (0, import_crypto5.randomUUID)();
|
|
3477
|
+
const deviceToken = generateDeviceToken();
|
|
3478
|
+
const capabilities = capabilitiesForPreset(args.preset ?? "full");
|
|
3479
|
+
this.insertStmt.run({
|
|
3480
|
+
device_id: deviceId,
|
|
3481
|
+
public_key: args.publicKey,
|
|
3482
|
+
token_hash: hashDeviceToken(deviceToken),
|
|
3483
|
+
name: args.name ?? null,
|
|
3484
|
+
capabilities: JSON.stringify(capabilities),
|
|
3485
|
+
created_at: args.now ?? Date.now()
|
|
3486
|
+
});
|
|
3487
|
+
return { deviceId, deviceToken, capabilities };
|
|
3488
|
+
}
|
|
3489
|
+
/**
|
|
3490
|
+
* Resolve a presented token to a device, or null.
|
|
3491
|
+
*
|
|
3492
|
+
* Returns null for a revoked device, so revocation takes effect on the very
|
|
3493
|
+
* next request with no cache to go stale.
|
|
3494
|
+
*/
|
|
3495
|
+
authenticate(token) {
|
|
3496
|
+
const hash = hashDeviceToken(token);
|
|
3497
|
+
const row = this.byTokenHashStmt.get(hash);
|
|
3498
|
+
if (!row) return null;
|
|
3499
|
+
if (!safeHashEquals(row.token_hash, hash)) return null;
|
|
3500
|
+
if (row.revoked_at != null) return null;
|
|
3501
|
+
return row;
|
|
3502
|
+
}
|
|
3503
|
+
get(deviceId) {
|
|
3504
|
+
return this.byIdStmt.get(deviceId) ?? null;
|
|
3505
|
+
}
|
|
3506
|
+
/** All devices, including revoked ones — an audit surface needs the history. */
|
|
3507
|
+
list() {
|
|
3508
|
+
return this.listStmt.all().map(toDeviceView);
|
|
3509
|
+
}
|
|
3510
|
+
/** Revoke one device. Others are untouched — no key rotation, no collateral. */
|
|
3511
|
+
revoke(deviceId, now = Date.now()) {
|
|
3512
|
+
return this.revokeStmt.run(now, deviceId).changes > 0;
|
|
3513
|
+
}
|
|
3514
|
+
touch(deviceId, now = Date.now()) {
|
|
3515
|
+
this.touchStmt.run(now, deviceId);
|
|
3516
|
+
}
|
|
3517
|
+
};
|
|
2995
3518
|
|
|
2996
3519
|
// src/api/middleware/auth.middleware.ts
|
|
2997
3520
|
function isLocalRequest(remoteAddr) {
|
|
@@ -3022,19 +3545,40 @@ var authMiddleware = (deps) => async (c, next) => {
|
|
|
3022
3545
|
}
|
|
3023
3546
|
}
|
|
3024
3547
|
const authorization = c.req.header("authorization");
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3548
|
+
const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : void 0;
|
|
3549
|
+
const queryKey = c.req.query("key") ?? void 0;
|
|
3550
|
+
const presented = bearer ?? queryKey;
|
|
3551
|
+
if (!presented) {
|
|
3552
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3553
|
+
}
|
|
3554
|
+
let principal = null;
|
|
3555
|
+
const device = deps.devicesRepo()?.authenticate(presented) ?? null;
|
|
3556
|
+
if (device) {
|
|
3557
|
+
principal = {
|
|
3558
|
+
kind: "device",
|
|
3559
|
+
deviceId: device.device_id,
|
|
3560
|
+
capabilities: parseCapabilities(device.capabilities)
|
|
3561
|
+
};
|
|
3562
|
+
try {
|
|
3563
|
+
deps.devicesRepo()?.touch(device.device_id);
|
|
3564
|
+
} catch {
|
|
3030
3565
|
}
|
|
3566
|
+
} else if (validateApiKey(presented, deps.apiKey)) {
|
|
3567
|
+
principal = legacyPrincipal();
|
|
3031
3568
|
}
|
|
3032
|
-
|
|
3033
|
-
|
|
3569
|
+
if (!principal) {
|
|
3570
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3571
|
+
}
|
|
3572
|
+
const required = requiredCapability(path, method);
|
|
3573
|
+
if (required === null) {
|
|
3034
3574
|
await next();
|
|
3035
3575
|
return;
|
|
3036
3576
|
}
|
|
3037
|
-
|
|
3577
|
+
if (!hasCapability(principal, required)) {
|
|
3578
|
+
return c.json({ error: "Forbidden", code: "MISSING_CAPABILITY", required }, 403);
|
|
3579
|
+
}
|
|
3580
|
+
c.set("principal", principal);
|
|
3581
|
+
await next();
|
|
3038
3582
|
};
|
|
3039
3583
|
|
|
3040
3584
|
// src/api/middleware/cors.middleware.ts
|
|
@@ -3173,12 +3717,68 @@ var createCacheAlertRoutes = (deps) => {
|
|
|
3173
3717
|
return app;
|
|
3174
3718
|
};
|
|
3175
3719
|
|
|
3176
|
-
// src/api/routes/
|
|
3720
|
+
// src/api/routes/config.routes.ts
|
|
3177
3721
|
var import_hono4 = require("hono");
|
|
3722
|
+
|
|
3723
|
+
// src/schemas/claudeFlags.schema.ts
|
|
3724
|
+
var import_zod2 = require("zod");
|
|
3725
|
+
var ClaudeFlagsBodySchema = import_zod2.z.object({
|
|
3726
|
+
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({}),
|
|
3727
|
+
// A newline would corrupt the flat one-line-per-key server.yaml, so reject
|
|
3728
|
+
// it here with a field error instead of silently stripping it.
|
|
3729
|
+
extraArgs: import_zod2.z.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
|
|
3730
|
+
}).strict();
|
|
3731
|
+
|
|
3732
|
+
// src/api/routes/config.routes.ts
|
|
3733
|
+
function readRawBody3(req) {
|
|
3734
|
+
return new Promise((resolve2, reject) => {
|
|
3735
|
+
const chunks = [];
|
|
3736
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3737
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
3738
|
+
req.on("error", reject);
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
var createConfigRoutes = (deps) => {
|
|
3742
|
+
const app = new import_hono4.Hono();
|
|
3743
|
+
app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
|
|
3744
|
+
app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
|
|
3745
|
+
app.put("/claude-flags", async (c) => {
|
|
3746
|
+
if (deps.localNoAuth) {
|
|
3747
|
+
return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
|
|
3748
|
+
}
|
|
3749
|
+
let body;
|
|
3750
|
+
try {
|
|
3751
|
+
const incoming = c.env?.incoming;
|
|
3752
|
+
const raw = incoming ? await readRawBody3(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
|
|
3753
|
+
body = raw ? JSON.parse(raw) : {};
|
|
3754
|
+
} catch {
|
|
3755
|
+
return c.json({ error: "invalid json" }, 400);
|
|
3756
|
+
}
|
|
3757
|
+
const parsed = ClaudeFlagsBodySchema.safeParse(body);
|
|
3758
|
+
if (!parsed.success) {
|
|
3759
|
+
return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
|
|
3760
|
+
}
|
|
3761
|
+
try {
|
|
3762
|
+
const result = deps.setClaudeFlagsConfig(parsed.data.values, parsed.data.extraArgs);
|
|
3763
|
+
return c.json({
|
|
3764
|
+
...result,
|
|
3765
|
+
...result.persisted ? {} : {
|
|
3766
|
+
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."
|
|
3767
|
+
}
|
|
3768
|
+
});
|
|
3769
|
+
} catch (err) {
|
|
3770
|
+
return c.json({ error: err instanceof Error ? err.message : "could not apply flags" }, 400);
|
|
3771
|
+
}
|
|
3772
|
+
});
|
|
3773
|
+
return app;
|
|
3774
|
+
};
|
|
3775
|
+
|
|
3776
|
+
// src/api/routes/conversations.routes.ts
|
|
3777
|
+
var import_hono5 = require("hono");
|
|
3178
3778
|
var ALREADY_HANDLED2 = 597;
|
|
3179
3779
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
3180
3780
|
var createConversationRoutes = (deps) => {
|
|
3181
|
-
const app = new
|
|
3781
|
+
const app = new import_hono5.Hono();
|
|
3182
3782
|
app.get("/count", async (c) => {
|
|
3183
3783
|
const url = new URL(c.req.url);
|
|
3184
3784
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -3204,8 +3804,34 @@ var createConversationRoutes = (deps) => {
|
|
|
3204
3804
|
return app;
|
|
3205
3805
|
};
|
|
3206
3806
|
|
|
3807
|
+
// src/api/routes/devices.routes.ts
|
|
3808
|
+
var import_hono6 = require("hono");
|
|
3809
|
+
var createDeviceRoutes = (deps) => {
|
|
3810
|
+
const app = new import_hono6.Hono();
|
|
3811
|
+
app.get("/", (c) => {
|
|
3812
|
+
const repo = deps.devicesRepo();
|
|
3813
|
+
if (!repo) return c.json({ devices: [], available: false });
|
|
3814
|
+
return c.json({ devices: repo.list(), available: true });
|
|
3815
|
+
});
|
|
3816
|
+
app.post("/:id/revoke", (c) => {
|
|
3817
|
+
const repo = deps.devicesRepo();
|
|
3818
|
+
if (!repo) {
|
|
3819
|
+
return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
3820
|
+
}
|
|
3821
|
+
const id = c.req.param("id");
|
|
3822
|
+
const existing = repo.get(id);
|
|
3823
|
+
if (!existing) return c.json({ error: "Device not found" }, 404);
|
|
3824
|
+
if (existing.revoked_at != null) {
|
|
3825
|
+
return c.json({ ok: true, alreadyRevoked: true });
|
|
3826
|
+
}
|
|
3827
|
+
repo.revoke(id);
|
|
3828
|
+
return c.json({ ok: true, alreadyRevoked: false });
|
|
3829
|
+
});
|
|
3830
|
+
return app;
|
|
3831
|
+
};
|
|
3832
|
+
|
|
3207
3833
|
// src/api/routes/health.routes.ts
|
|
3208
|
-
var
|
|
3834
|
+
var import_hono7 = require("hono");
|
|
3209
3835
|
|
|
3210
3836
|
// src/version.ts
|
|
3211
3837
|
var import_node_fs2 = require("fs");
|
|
@@ -3242,7 +3868,7 @@ function resolveVersion() {
|
|
|
3242
3868
|
|
|
3243
3869
|
// src/api/routes/health.routes.ts
|
|
3244
3870
|
var createHealthRoutes = (deps) => {
|
|
3245
|
-
const app = new
|
|
3871
|
+
const app = new import_hono7.Hono();
|
|
3246
3872
|
app.get("/", (c) => {
|
|
3247
3873
|
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3248
3874
|
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
@@ -3253,7 +3879,7 @@ var createHealthRoutes = (deps) => {
|
|
|
3253
3879
|
// src/api/routes/logs.routes.ts
|
|
3254
3880
|
var import_node_fs3 = require("fs");
|
|
3255
3881
|
var import_node_path5 = require("path");
|
|
3256
|
-
var
|
|
3882
|
+
var import_hono8 = require("hono");
|
|
3257
3883
|
|
|
3258
3884
|
// src/lifecycle/constants.ts
|
|
3259
3885
|
var import_node_os = require("os");
|
|
@@ -3311,7 +3937,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3311
3937
|
}
|
|
3312
3938
|
}
|
|
3313
3939
|
function createLogsRoutes() {
|
|
3314
|
-
const app = new
|
|
3940
|
+
const app = new import_hono8.Hono();
|
|
3315
3941
|
app.get("/", (c) => {
|
|
3316
3942
|
try {
|
|
3317
3943
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3382,7 +4008,7 @@ function createLogsRoutes() {
|
|
|
3382
4008
|
// src/api/routes/misc.routes.ts
|
|
3383
4009
|
var import_node_child_process = require("child_process");
|
|
3384
4010
|
var import_node_crypto2 = require("crypto");
|
|
3385
|
-
var
|
|
4011
|
+
var import_hono9 = require("hono");
|
|
3386
4012
|
var import_os5 = require("os");
|
|
3387
4013
|
|
|
3388
4014
|
// src/config/update-config.ts
|
|
@@ -3392,15 +4018,15 @@ var import_node_path6 = require("path");
|
|
|
3392
4018
|
var import_yaml = require("yaml");
|
|
3393
4019
|
|
|
3394
4020
|
// src/schemas/updateConfig.schema.ts
|
|
3395
|
-
var
|
|
3396
|
-
var UpdateConfigSchema =
|
|
3397
|
-
auto_update:
|
|
3398
|
-
channel:
|
|
3399
|
-
allow:
|
|
3400
|
-
poll_interval_minutes:
|
|
3401
|
-
defer_if_active_sessions:
|
|
3402
|
-
github_repo:
|
|
3403
|
-
webhook_secret:
|
|
4021
|
+
var import_zod3 = require("zod");
|
|
4022
|
+
var UpdateConfigSchema = import_zod3.z.object({
|
|
4023
|
+
auto_update: import_zod3.z.boolean().default(false),
|
|
4024
|
+
channel: import_zod3.z.enum(["stable", "next"]).default("stable"),
|
|
4025
|
+
allow: import_zod3.z.array(import_zod3.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
|
|
4026
|
+
poll_interval_minutes: import_zod3.z.number().int().min(0).default(1440),
|
|
4027
|
+
defer_if_active_sessions: import_zod3.z.boolean().default(true),
|
|
4028
|
+
github_repo: import_zod3.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
|
|
4029
|
+
webhook_secret: import_zod3.z.string().min(1).nullable().default(null)
|
|
3404
4030
|
}).strict();
|
|
3405
4031
|
|
|
3406
4032
|
// src/config/update-config.ts
|
|
@@ -3421,7 +4047,264 @@ function loadUpdateConfig(opts = {}) {
|
|
|
3421
4047
|
return UpdateConfigSchema.parse(parsed);
|
|
3422
4048
|
}
|
|
3423
4049
|
|
|
4050
|
+
// src/db/repositories/push.repository.ts
|
|
4051
|
+
var FAILURE_STREAK_LIMIT = 5;
|
|
4052
|
+
var PUSH_TOKEN_KINDS = ["expo", "liveactivity_start", "liveactivity_update"];
|
|
4053
|
+
var DEFAULT_PUSH_TOKEN_KIND = "expo";
|
|
4054
|
+
function isPushTokenKind(value) {
|
|
4055
|
+
return typeof value === "string" && PUSH_TOKEN_KINDS.includes(value);
|
|
4056
|
+
}
|
|
4057
|
+
function tokenState(row, now = Date.now()) {
|
|
4058
|
+
if (row.revoked_at != null) return "revoked";
|
|
4059
|
+
if (row.expires_at != null && row.expires_at <= now) return "expired";
|
|
4060
|
+
if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
|
|
4061
|
+
if (row.failure_streak > 0) return "failing";
|
|
4062
|
+
if (row.last_success_at == null) return "never-delivered";
|
|
4063
|
+
return "healthy";
|
|
4064
|
+
}
|
|
4065
|
+
function toHealth(row, now = Date.now()) {
|
|
4066
|
+
return {
|
|
4067
|
+
platform: row.platform,
|
|
4068
|
+
deviceId: row.device_id,
|
|
4069
|
+
registeredAt: row.registered_at,
|
|
4070
|
+
lastSuccessAt: row.last_success_at,
|
|
4071
|
+
lastFailureAt: row.last_failure_at,
|
|
4072
|
+
lastFailureCode: row.last_failure_code,
|
|
4073
|
+
failureStreak: row.failure_streak,
|
|
4074
|
+
revokedAt: row.revoked_at,
|
|
4075
|
+
state: tokenState(row, now),
|
|
4076
|
+
kind: row.kind,
|
|
4077
|
+
activityId: row.activity_id,
|
|
4078
|
+
sessionId: row.session_id,
|
|
4079
|
+
expiresAt: row.expires_at
|
|
4080
|
+
};
|
|
4081
|
+
}
|
|
4082
|
+
var PushRepository = class {
|
|
4083
|
+
upsertStmt;
|
|
4084
|
+
getStmt;
|
|
4085
|
+
listActiveStmt;
|
|
4086
|
+
listAllStmt;
|
|
4087
|
+
successStmt;
|
|
4088
|
+
failureStmt;
|
|
4089
|
+
revokeStmt;
|
|
4090
|
+
claimEventStmt;
|
|
4091
|
+
markDeliveredStmt;
|
|
4092
|
+
listByKindSessionStmt;
|
|
4093
|
+
listByKindStmt;
|
|
4094
|
+
listRenewableStmt;
|
|
4095
|
+
claimRenewalStmt;
|
|
4096
|
+
expireStmt;
|
|
4097
|
+
expireSessionActivitiesStmt;
|
|
4098
|
+
constructor(db) {
|
|
4099
|
+
this.upsertStmt = db.prepare(`
|
|
4100
|
+
INSERT INTO push_tokens (
|
|
4101
|
+
token, platform, device_id, registered_at,
|
|
4102
|
+
kind, activity_id, session_id, expires_at, stale_date, started_at
|
|
4103
|
+
)
|
|
4104
|
+
VALUES (
|
|
4105
|
+
@token, @platform, @device_id, @registered_at,
|
|
4106
|
+
@kind, @activity_id, @session_id, @expires_at, @stale_date, @started_at
|
|
4107
|
+
)
|
|
4108
|
+
ON CONFLICT(token) DO UPDATE SET
|
|
4109
|
+
platform = excluded.platform,
|
|
4110
|
+
device_id = COALESCE(excluded.device_id, push_tokens.device_id),
|
|
4111
|
+
registered_at = excluded.registered_at,
|
|
4112
|
+
kind = excluded.kind,
|
|
4113
|
+
activity_id = COALESCE(excluded.activity_id, push_tokens.activity_id),
|
|
4114
|
+
session_id = COALESCE(excluded.session_id, push_tokens.session_id),
|
|
4115
|
+
expires_at = excluded.expires_at,
|
|
4116
|
+
stale_date = excluded.stale_date,
|
|
4117
|
+
-- Preserve the ORIGINAL start across a re-registration. iOS renders its
|
|
4118
|
+
-- own ticking timer from started_at, so overwriting it with a fresh
|
|
4119
|
+
-- value visibly resets the user's elapsed time to zero.
|
|
4120
|
+
started_at = COALESCE(push_tokens.started_at, excluded.started_at),
|
|
4121
|
+
-- A fresh registration clears prior failure state and any revocation:
|
|
4122
|
+
-- the client is telling us this token is live again. renewed_at clears
|
|
4123
|
+
-- too \u2014 this is a new activity generation, so it is renewable again.
|
|
4124
|
+
failure_streak = 0,
|
|
4125
|
+
last_failure_at = NULL,
|
|
4126
|
+
last_failure_code = NULL,
|
|
4127
|
+
revoked_at = NULL,
|
|
4128
|
+
renewed_at = NULL
|
|
4129
|
+
`);
|
|
4130
|
+
this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
|
|
4131
|
+
this.listActiveStmt = db.prepare(`
|
|
4132
|
+
SELECT * FROM push_tokens
|
|
4133
|
+
WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4134
|
+
AND kind = 'expo'
|
|
4135
|
+
ORDER BY registered_at ASC
|
|
4136
|
+
`);
|
|
4137
|
+
this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
|
|
4138
|
+
this.successStmt = db.prepare(`
|
|
4139
|
+
UPDATE push_tokens
|
|
4140
|
+
SET last_success_at = @at, failure_streak = 0,
|
|
4141
|
+
last_failure_code = NULL
|
|
4142
|
+
WHERE token = @token
|
|
4143
|
+
`);
|
|
4144
|
+
this.failureStmt = db.prepare(`
|
|
4145
|
+
UPDATE push_tokens
|
|
4146
|
+
SET last_failure_at = @at, last_failure_code = @code,
|
|
4147
|
+
failure_streak = failure_streak + 1
|
|
4148
|
+
WHERE token = @token
|
|
4149
|
+
`);
|
|
4150
|
+
this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
|
|
4151
|
+
this.listByKindSessionStmt = db.prepare(`
|
|
4152
|
+
SELECT * FROM push_tokens
|
|
4153
|
+
WHERE kind = @kind AND session_id = @session_id
|
|
4154
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4155
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
4156
|
+
ORDER BY registered_at ASC
|
|
4157
|
+
`);
|
|
4158
|
+
this.listByKindStmt = db.prepare(`
|
|
4159
|
+
SELECT * FROM push_tokens
|
|
4160
|
+
WHERE kind = @kind
|
|
4161
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4162
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
4163
|
+
ORDER BY registered_at ASC
|
|
4164
|
+
`);
|
|
4165
|
+
this.listRenewableStmt = db.prepare(`
|
|
4166
|
+
SELECT * FROM push_tokens
|
|
4167
|
+
WHERE kind = 'liveactivity_update'
|
|
4168
|
+
AND stale_date IS NOT NULL AND renewed_at IS NULL
|
|
4169
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4170
|
+
ORDER BY stale_date ASC
|
|
4171
|
+
`);
|
|
4172
|
+
this.claimRenewalStmt = db.prepare(`
|
|
4173
|
+
UPDATE push_tokens SET renewed_at = @at
|
|
4174
|
+
WHERE token = @token AND renewed_at IS NULL
|
|
4175
|
+
`);
|
|
4176
|
+
this.expireStmt = db.prepare("UPDATE push_tokens SET expires_at = ? WHERE token = ?");
|
|
4177
|
+
this.expireSessionActivitiesStmt = db.prepare(`
|
|
4178
|
+
UPDATE push_tokens SET expires_at = @at
|
|
4179
|
+
WHERE session_id = @session_id AND kind = 'liveactivity_update'
|
|
4180
|
+
AND (expires_at IS NULL OR expires_at > @at)
|
|
4181
|
+
`);
|
|
4182
|
+
this.claimEventStmt = db.prepare(`
|
|
4183
|
+
INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
|
|
4184
|
+
VALUES (@event_id, @session_id, @created_at)
|
|
4185
|
+
`);
|
|
4186
|
+
this.markDeliveredStmt = db.prepare(
|
|
4187
|
+
"UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
|
|
4188
|
+
);
|
|
4189
|
+
}
|
|
4190
|
+
/**
|
|
4191
|
+
* Register or refresh a token.
|
|
4192
|
+
*
|
|
4193
|
+
* `kind` defaults to Expo so a released client posting `{ token, platform }`
|
|
4194
|
+
* keeps working — tb-mobile cannot be force-updated, and every client
|
|
4195
|
+
* predating Live Activities is registering an Expo relay token.
|
|
4196
|
+
*
|
|
4197
|
+
* Several rows per device is normal and intended: a device runs one activity
|
|
4198
|
+
* per live session, each with its own update token. The token itself is the
|
|
4199
|
+
* primary key, so distinct activities never collide.
|
|
4200
|
+
*/
|
|
4201
|
+
register(args) {
|
|
4202
|
+
this.upsertStmt.run({
|
|
4203
|
+
token: args.token,
|
|
4204
|
+
platform: args.platform,
|
|
4205
|
+
device_id: args.deviceId ?? null,
|
|
4206
|
+
registered_at: args.now ?? Date.now(),
|
|
4207
|
+
kind: args.kind ?? DEFAULT_PUSH_TOKEN_KIND,
|
|
4208
|
+
activity_id: args.activityId ?? null,
|
|
4209
|
+
session_id: args.sessionId ?? null,
|
|
4210
|
+
expires_at: args.expiresAt ?? null,
|
|
4211
|
+
stale_date: args.staleDate ?? null,
|
|
4212
|
+
started_at: args.startedAt ?? null
|
|
4213
|
+
});
|
|
4214
|
+
}
|
|
4215
|
+
get(token) {
|
|
4216
|
+
return this.getStmt.get(token) ?? null;
|
|
4217
|
+
}
|
|
4218
|
+
/**
|
|
4219
|
+
* Expo tokens eligible for delivery — not revoked, not past the failure limit.
|
|
4220
|
+
*
|
|
4221
|
+
* Deliberately Expo-only. ActivityKit tokens go over direct APNs with a
|
|
4222
|
+
* different topic and are rejected by Expo's relay, so the ordinary
|
|
4223
|
+
* notification fan-out must not see them.
|
|
4224
|
+
*/
|
|
4225
|
+
listDeliverable() {
|
|
4226
|
+
return this.listActiveStmt.all();
|
|
4227
|
+
}
|
|
4228
|
+
/** Live-activity tokens for one session, eligible for delivery. */
|
|
4229
|
+
listForSession(kind, sessionId, now = Date.now()) {
|
|
4230
|
+
return this.listByKindSessionStmt.all({
|
|
4231
|
+
kind,
|
|
4232
|
+
session_id: sessionId,
|
|
4233
|
+
now
|
|
4234
|
+
});
|
|
4235
|
+
}
|
|
4236
|
+
/**
|
|
4237
|
+
* Every deliverable token of one kind.
|
|
4238
|
+
*
|
|
4239
|
+
* Used for push-to-start, which is app-wide rather than session-scoped: the
|
|
4240
|
+
* activity does not exist yet, so there is no per-activity token to look up.
|
|
4241
|
+
*/
|
|
4242
|
+
listByKind(kind, now = Date.now()) {
|
|
4243
|
+
return this.listByKindStmt.all({ kind, now });
|
|
4244
|
+
}
|
|
4245
|
+
/** Unrenewed activities with a renewal deadline, soonest first. */
|
|
4246
|
+
listRenewable() {
|
|
4247
|
+
return this.listRenewableStmt.all();
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Claim a row for renewal.
|
|
4251
|
+
*
|
|
4252
|
+
* Returns true exactly once per row. A restart re-arms timers from the
|
|
4253
|
+
* persisted deadline, so the same renewal can be attempted twice; the loser
|
|
4254
|
+
* gets false and must not send. Doing this as a conditional UPDATE rather
|
|
4255
|
+
* than read-then-write avoids the race where both attempts observe
|
|
4256
|
+
* "not yet renewed".
|
|
4257
|
+
*/
|
|
4258
|
+
claimRenewal(token, now = Date.now()) {
|
|
4259
|
+
return this.claimRenewalStmt.run({ token, at: now }).changes > 0;
|
|
4260
|
+
}
|
|
4261
|
+
/** Mark one token expired, so it stops being a delivery target. */
|
|
4262
|
+
expire(token, now = Date.now()) {
|
|
4263
|
+
this.expireStmt.run(now, token);
|
|
4264
|
+
}
|
|
4265
|
+
/**
|
|
4266
|
+
* Expire every live activity for a session.
|
|
4267
|
+
*
|
|
4268
|
+
* Called when the session ends. Without this, a per-activity token outlives
|
|
4269
|
+
* its session and a later renewal sweep would resurrect an activity for a
|
|
4270
|
+
* session that is already gone.
|
|
4271
|
+
*/
|
|
4272
|
+
expireSessionActivities(sessionId, now = Date.now()) {
|
|
4273
|
+
this.expireSessionActivitiesStmt.run({ session_id: sessionId, at: now });
|
|
4274
|
+
}
|
|
4275
|
+
/** Every token, including dead and revoked ones, for the health report. */
|
|
4276
|
+
listHealth(now = Date.now()) {
|
|
4277
|
+
return this.listAllStmt.all().map((r) => toHealth(r, now));
|
|
4278
|
+
}
|
|
4279
|
+
recordSuccess(token, now = Date.now()) {
|
|
4280
|
+
this.successStmt.run({ token, at: now });
|
|
4281
|
+
}
|
|
4282
|
+
recordFailure(token, code, now = Date.now()) {
|
|
4283
|
+
this.failureStmt.run({ token, at: now, code });
|
|
4284
|
+
}
|
|
4285
|
+
revoke(token, now = Date.now()) {
|
|
4286
|
+
return this.revokeStmt.run(now, token).changes > 0;
|
|
4287
|
+
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Claim an event id for delivery.
|
|
4290
|
+
*
|
|
4291
|
+
* Returns true exactly once per event id. A retry, a reconnect
|
|
4292
|
+
* reconciliation, or two triggers firing for the same underlying event all
|
|
4293
|
+
* get false and must not notify — the user should never be told twice about
|
|
4294
|
+
* one thing.
|
|
4295
|
+
*/
|
|
4296
|
+
claimEvent(eventId, sessionId, now = Date.now()) {
|
|
4297
|
+
return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
|
|
4298
|
+
}
|
|
4299
|
+
markDelivered(eventId, now = Date.now()) {
|
|
4300
|
+
this.markDeliveredStmt.run(now, eventId);
|
|
4301
|
+
}
|
|
4302
|
+
};
|
|
4303
|
+
|
|
3424
4304
|
// src/api/routes/misc.routes.ts
|
|
4305
|
+
function numberOrNull(value) {
|
|
4306
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
4307
|
+
}
|
|
3425
4308
|
function readJsonBody(req) {
|
|
3426
4309
|
return new Promise((resolve2, reject) => {
|
|
3427
4310
|
const chunks = [];
|
|
@@ -3437,7 +4320,7 @@ function readJsonBody(req) {
|
|
|
3437
4320
|
req.on("error", reject);
|
|
3438
4321
|
});
|
|
3439
4322
|
}
|
|
3440
|
-
function
|
|
4323
|
+
function readRawBody4(req) {
|
|
3441
4324
|
return new Promise((resolve2, reject) => {
|
|
3442
4325
|
const chunks = [];
|
|
3443
4326
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3456,7 +4339,7 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3456
4339
|
}
|
|
3457
4340
|
var clientLog = getLogger("client");
|
|
3458
4341
|
var createMiscRoutes = (deps) => {
|
|
3459
|
-
const app = new
|
|
4342
|
+
const app = new import_hono9.Hono();
|
|
3460
4343
|
app.get("/api/info", (c) => {
|
|
3461
4344
|
const ptyIds = deps.ptyAttachedIds();
|
|
3462
4345
|
return c.json({
|
|
@@ -3464,7 +4347,15 @@ var createMiscRoutes = (deps) => {
|
|
|
3464
4347
|
machineName: (0, import_os5.hostname)(),
|
|
3465
4348
|
platform: process.platform,
|
|
3466
4349
|
activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
|
|
3467
|
-
publicUrl: deps.publicUrl
|
|
4350
|
+
publicUrl: deps.publicUrl,
|
|
4351
|
+
// Capability flag: this server serves /api/config/claude-flags. Additive —
|
|
4352
|
+
// older clients ignore it, and clients talking to an older server see it
|
|
4353
|
+
// absent and hide the UI rather than 404ing.
|
|
4354
|
+
claudeFlags: true,
|
|
4355
|
+
// Same contract: this server serves GET /api/config/feature-flags. Lives
|
|
4356
|
+
// here rather than behind /api/config (admin-only) so a read-only client
|
|
4357
|
+
// still learns the server supports flags even if it can't read values.
|
|
4358
|
+
featureFlags: true
|
|
3468
4359
|
});
|
|
3469
4360
|
});
|
|
3470
4361
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -3481,7 +4372,54 @@ var createMiscRoutes = (deps) => {
|
|
|
3481
4372
|
}
|
|
3482
4373
|
});
|
|
3483
4374
|
});
|
|
3484
|
-
app.post("/api/push/register", (c) =>
|
|
4375
|
+
app.post("/api/push/register", async (c) => {
|
|
4376
|
+
const body = await readJsonBody(c.env.incoming).catch(() => null);
|
|
4377
|
+
const token = body?.token;
|
|
4378
|
+
const platform3 = body?.platform;
|
|
4379
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
4380
|
+
return c.json({ error: "Missing token" }, 400);
|
|
4381
|
+
}
|
|
4382
|
+
if (platform3 !== "ios" && platform3 !== "android") {
|
|
4383
|
+
return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
|
|
4384
|
+
}
|
|
4385
|
+
const kind = body?.kind === void 0 ? DEFAULT_PUSH_TOKEN_KIND : body.kind;
|
|
4386
|
+
if (!isPushTokenKind(kind)) {
|
|
4387
|
+
return c.json(
|
|
4388
|
+
{ error: `kind must be one of ${PUSH_TOKEN_KINDS.join(", ")}`, code: "INVALID_KIND" },
|
|
4389
|
+
400
|
|
4390
|
+
);
|
|
4391
|
+
}
|
|
4392
|
+
if (kind === "liveactivity_update" && typeof body?.activityId !== "string") {
|
|
4393
|
+
return c.json(
|
|
4394
|
+
{
|
|
4395
|
+
error: "activityId is required for kind 'liveactivity_update'",
|
|
4396
|
+
code: "MISSING_ACTIVITY"
|
|
4397
|
+
},
|
|
4398
|
+
400
|
|
4399
|
+
);
|
|
4400
|
+
}
|
|
4401
|
+
const repo = deps.pushRepo();
|
|
4402
|
+
if (!repo) {
|
|
4403
|
+
return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
4404
|
+
}
|
|
4405
|
+
repo.register({
|
|
4406
|
+
token,
|
|
4407
|
+
platform: platform3,
|
|
4408
|
+
deviceId: typeof body?.deviceId === "string" ? body.deviceId : null,
|
|
4409
|
+
kind,
|
|
4410
|
+
activityId: typeof body?.activityId === "string" ? body.activityId : null,
|
|
4411
|
+
sessionId: typeof body?.sessionId === "string" ? body.sessionId : null,
|
|
4412
|
+
expiresAt: numberOrNull(body?.expiresAt),
|
|
4413
|
+
staleDate: numberOrNull(body?.staleDate),
|
|
4414
|
+
startedAt: numberOrNull(body?.startedAt)
|
|
4415
|
+
});
|
|
4416
|
+
return c.json({ ok: true });
|
|
4417
|
+
});
|
|
4418
|
+
app.get("/api/push/health", (c) => {
|
|
4419
|
+
const repo = deps.pushRepo();
|
|
4420
|
+
if (!repo) return c.json({ tokens: [], available: false });
|
|
4421
|
+
return c.json({ tokens: repo.listHealth(), available: true });
|
|
4422
|
+
});
|
|
3485
4423
|
app.post("/api/__update", async (c) => {
|
|
3486
4424
|
const cfg = loadUpdateConfig();
|
|
3487
4425
|
if (!cfg?.webhook_secret) {
|
|
@@ -3489,7 +4427,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3489
4427
|
}
|
|
3490
4428
|
let body;
|
|
3491
4429
|
try {
|
|
3492
|
-
body = await
|
|
4430
|
+
body = await readRawBody4(c.env.incoming);
|
|
3493
4431
|
} catch {
|
|
3494
4432
|
return c.json({ error: "could not read body" }, 400);
|
|
3495
4433
|
}
|
|
@@ -3532,11 +4470,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3532
4470
|
};
|
|
3533
4471
|
|
|
3534
4472
|
// src/api/routes/pair.routes.ts
|
|
3535
|
-
var
|
|
4473
|
+
var import_hono10 = require("hono");
|
|
3536
4474
|
var ALREADY_HANDLED3 = 597;
|
|
3537
4475
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3538
4476
|
var createPairRoutes = (deps) => {
|
|
3539
|
-
const app = new
|
|
4477
|
+
const app = new import_hono10.Hono();
|
|
3540
4478
|
app.post("/start", (c) => {
|
|
3541
4479
|
deps.handlePairStart(c.env.outgoing);
|
|
3542
4480
|
return alreadyHandled3();
|
|
@@ -3549,11 +4487,11 @@ var createPairRoutes = (deps) => {
|
|
|
3549
4487
|
};
|
|
3550
4488
|
|
|
3551
4489
|
// src/api/routes/projects.routes.ts
|
|
3552
|
-
var
|
|
4490
|
+
var import_hono11 = require("hono");
|
|
3553
4491
|
var ALREADY_HANDLED4 = 597;
|
|
3554
4492
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3555
4493
|
var createProjectRoutes = (deps) => {
|
|
3556
|
-
const app = new
|
|
4494
|
+
const app = new import_hono11.Hono();
|
|
3557
4495
|
app.get("/", (c) => {
|
|
3558
4496
|
const url = new URL(c.req.url);
|
|
3559
4497
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3567,12 +4505,147 @@ var createProjectRoutes = (deps) => {
|
|
|
3567
4505
|
return app;
|
|
3568
4506
|
};
|
|
3569
4507
|
|
|
4508
|
+
// src/api/routes/providers.routes.ts
|
|
4509
|
+
var import_hono12 = require("hono");
|
|
4510
|
+
|
|
4511
|
+
// src/services/providers/providerHealth.ts
|
|
4512
|
+
var import_child_process3 = require("child_process");
|
|
4513
|
+
|
|
4514
|
+
// src/services/providers/capabilities.ts
|
|
4515
|
+
var CLAUDE_CODE_CAPABILITIES = {
|
|
4516
|
+
freshSessionId: "explicit",
|
|
4517
|
+
resume: "native",
|
|
4518
|
+
systemPrompt: "flag",
|
|
4519
|
+
structuredQuestions: true,
|
|
4520
|
+
permissionGates: true,
|
|
4521
|
+
liveControl: true
|
|
4522
|
+
};
|
|
4523
|
+
var CODEX_CLI_CAPABILITIES = {
|
|
4524
|
+
freshSessionId: "late-bound",
|
|
4525
|
+
resume: "native",
|
|
4526
|
+
systemPrompt: "positional",
|
|
4527
|
+
structuredQuestions: false,
|
|
4528
|
+
permissionGates: true,
|
|
4529
|
+
liveControl: true
|
|
4530
|
+
};
|
|
4531
|
+
function capabilitiesFor(provider) {
|
|
4532
|
+
switch (provider) {
|
|
4533
|
+
case CLAUDE_CODE_PROVIDER:
|
|
4534
|
+
return CLAUDE_CODE_CAPABILITIES;
|
|
4535
|
+
case CODEX_CLI_PROVIDER:
|
|
4536
|
+
return CODEX_CLI_CAPABILITIES;
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4540
|
+
// src/services/providers/providerHealth.ts
|
|
4541
|
+
var VERIFIED_AGAINST = {
|
|
4542
|
+
[CLAUDE_CODE_PROVIDER]: { captured: ["2.1.214"], min: "2.1.0" },
|
|
4543
|
+
[CODEX_CLI_PROVIDER]: { captured: ["0.140.0-alpha.19"], min: "0.140.0" }
|
|
4544
|
+
};
|
|
4545
|
+
var VERSION_TIMEOUT_MS = 3e3;
|
|
4546
|
+
function parseVersionOutput(output) {
|
|
4547
|
+
const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
4548
|
+
return match ? match[0] : null;
|
|
4549
|
+
}
|
|
4550
|
+
function runVersion(exe) {
|
|
4551
|
+
return new Promise((resolve2) => {
|
|
4552
|
+
(0, import_child_process3.execFile)(exe, ["--version"], { timeout: VERSION_TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
4553
|
+
if (err && !stdout && !stderr) return resolve2(null);
|
|
4554
|
+
resolve2(parseVersionOutput(`${stdout}${stderr}`));
|
|
4555
|
+
});
|
|
4556
|
+
});
|
|
4557
|
+
}
|
|
4558
|
+
function compareToVerified(version, verified) {
|
|
4559
|
+
if (version === null) {
|
|
4560
|
+
return {
|
|
4561
|
+
code: "version_undetectable",
|
|
4562
|
+
message: "Could not determine the installed version, so compatibility is unverified. Parsing and prompt detection may not match this build."
|
|
4563
|
+
};
|
|
4564
|
+
}
|
|
4565
|
+
if (verified.captured.includes(version)) return null;
|
|
4566
|
+
const below = verified.min != null && compareSemver(version, verified.min) < 0;
|
|
4567
|
+
const above = verified.max != null && compareSemver(version, verified.max) > 0;
|
|
4568
|
+
if (!below && !above && verified.max != null) return null;
|
|
4569
|
+
if (!below && verified.max == null && !isNewerThanAllCaptured(version, verified.captured)) {
|
|
4570
|
+
return null;
|
|
4571
|
+
}
|
|
4572
|
+
return {
|
|
4573
|
+
code: "version_unverified",
|
|
4574
|
+
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.`
|
|
4575
|
+
};
|
|
4576
|
+
}
|
|
4577
|
+
function isNewerThanAllCaptured(version, captured) {
|
|
4578
|
+
return captured.every((c) => compareSemver(version, c) > 0);
|
|
4579
|
+
}
|
|
4580
|
+
function compareSemver(a, b) {
|
|
4581
|
+
const parse = (v) => {
|
|
4582
|
+
const [core, pre] = v.split("-", 2);
|
|
4583
|
+
const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
4584
|
+
return { nums, pre: pre ?? null };
|
|
4585
|
+
};
|
|
4586
|
+
const pa = parse(a);
|
|
4587
|
+
const pb = parse(b);
|
|
4588
|
+
for (let i = 0; i < 3; i++) {
|
|
4589
|
+
const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
|
|
4590
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
4591
|
+
}
|
|
4592
|
+
if (pa.pre === pb.pre) return 0;
|
|
4593
|
+
if (pa.pre === null) return 1;
|
|
4594
|
+
if (pb.pre === null) return -1;
|
|
4595
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
4596
|
+
}
|
|
4597
|
+
async function providerHealth(name, resolveExe, detect = runVersion) {
|
|
4598
|
+
const verifiedAgainst = VERIFIED_AGAINST[name];
|
|
4599
|
+
const capabilities = capabilitiesFor(name);
|
|
4600
|
+
let exe;
|
|
4601
|
+
try {
|
|
4602
|
+
exe = resolveExe();
|
|
4603
|
+
} catch {
|
|
4604
|
+
return {
|
|
4605
|
+
name,
|
|
4606
|
+
available: false,
|
|
4607
|
+
version: null,
|
|
4608
|
+
verifiedAgainst,
|
|
4609
|
+
capabilities,
|
|
4610
|
+
warnings: [
|
|
4611
|
+
{
|
|
4612
|
+
code: "provider_not_found",
|
|
4613
|
+
message: `${name} could not be located. Sessions for this provider cannot start.`
|
|
4614
|
+
}
|
|
4615
|
+
]
|
|
4616
|
+
};
|
|
4617
|
+
}
|
|
4618
|
+
const version = await detect(exe);
|
|
4619
|
+
const warning = compareToVerified(version, verifiedAgainst);
|
|
4620
|
+
return {
|
|
4621
|
+
name,
|
|
4622
|
+
available: true,
|
|
4623
|
+
version,
|
|
4624
|
+
verifiedAgainst,
|
|
4625
|
+
capabilities,
|
|
4626
|
+
warnings: warning ? [warning] : []
|
|
4627
|
+
};
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
// src/api/routes/providers.routes.ts
|
|
4631
|
+
var createProviderRoutes = () => {
|
|
4632
|
+
const app = new import_hono12.Hono();
|
|
4633
|
+
app.get("/", async (c) => {
|
|
4634
|
+
const providers = await Promise.all([
|
|
4635
|
+
providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
|
|
4636
|
+
providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
|
|
4637
|
+
]);
|
|
4638
|
+
return c.json({ providers });
|
|
4639
|
+
});
|
|
4640
|
+
return app;
|
|
4641
|
+
};
|
|
4642
|
+
|
|
3570
4643
|
// src/api/routes/scanner.routes.ts
|
|
3571
|
-
var
|
|
4644
|
+
var import_hono13 = require("hono");
|
|
3572
4645
|
var ALREADY_HANDLED5 = 597;
|
|
3573
4646
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3574
4647
|
var createScannerRoutes = (deps) => {
|
|
3575
|
-
const app = new
|
|
4648
|
+
const app = new import_hono13.Hono();
|
|
3576
4649
|
app.get("/api/search", async (c) => {
|
|
3577
4650
|
const url = new URL(c.req.url);
|
|
3578
4651
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3582,11 +4655,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3582
4655
|
};
|
|
3583
4656
|
|
|
3584
4657
|
// src/api/routes/sessions.routes.ts
|
|
3585
|
-
var
|
|
4658
|
+
var import_hono14 = require("hono");
|
|
3586
4659
|
var ALREADY_HANDLED6 = 597;
|
|
3587
4660
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3588
4661
|
var createSessionRoutes = (deps) => {
|
|
3589
|
-
const app = new
|
|
4662
|
+
const app = new import_hono14.Hono();
|
|
3590
4663
|
app.get("/count", (c) => {
|
|
3591
4664
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3592
4665
|
return alreadyHandled6();
|
|
@@ -3653,9 +4726,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3653
4726
|
};
|
|
3654
4727
|
|
|
3655
4728
|
// src/api/routes/ws.routes.ts
|
|
3656
|
-
var
|
|
4729
|
+
var import_hono15 = require("hono");
|
|
3657
4730
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3658
|
-
const app = new
|
|
4731
|
+
const app = new import_hono15.Hono();
|
|
3659
4732
|
app.get(
|
|
3660
4733
|
"/ws",
|
|
3661
4734
|
upgradeWebSocket(() => {
|
|
@@ -3681,7 +4754,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3681
4754
|
|
|
3682
4755
|
// src/api/app.ts
|
|
3683
4756
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3684
|
-
const app = new
|
|
4757
|
+
const app = new import_hono16.Hono();
|
|
3685
4758
|
const httpLog = getLogger("http");
|
|
3686
4759
|
app.use("*", async (c, next) => {
|
|
3687
4760
|
const start = Date.now();
|
|
@@ -3706,7 +4779,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3706
4779
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3707
4780
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3708
4781
|
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
4782
|
+
app.route("/api/config", createConfigRoutes(deps));
|
|
3709
4783
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
4784
|
+
app.route("/api/providers", createProviderRoutes());
|
|
4785
|
+
app.route("/api/devices", createDeviceRoutes(deps));
|
|
3710
4786
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3711
4787
|
app.route("/api", createBrowseRoutes(deps));
|
|
3712
4788
|
app.route("/", createScannerRoutes(deps));
|
|
@@ -3914,11 +4990,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
|
3914
4990
|
}
|
|
3915
4991
|
|
|
3916
4992
|
// src/utils/fileIdentity.ts
|
|
3917
|
-
var
|
|
4993
|
+
var import_crypto6 = require("crypto");
|
|
3918
4994
|
function fileIdentity(stat3, headBytes) {
|
|
3919
4995
|
if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
|
|
3920
4996
|
const head = headBytes ?? Buffer.alloc(0);
|
|
3921
|
-
return `fp:${(0,
|
|
4997
|
+
return `fp:${(0, import_crypto6.createHash)("sha1").update(head).digest("hex")}`;
|
|
3922
4998
|
}
|
|
3923
4999
|
function splitCompleteLines(buf, baseOffset) {
|
|
3924
5000
|
const spans = [];
|
|
@@ -5200,8 +6276,122 @@ var ConversationsRepository = class {
|
|
|
5200
6276
|
}
|
|
5201
6277
|
};
|
|
5202
6278
|
|
|
6279
|
+
// src/db/repositories/managed-sessions.repository.ts
|
|
6280
|
+
var ManagedSessionsRepository = class {
|
|
6281
|
+
upsertStmt;
|
|
6282
|
+
updateStatusStmt;
|
|
6283
|
+
getStmt;
|
|
6284
|
+
listNonTerminalStmt;
|
|
6285
|
+
deleteStmt;
|
|
6286
|
+
constructor(db) {
|
|
6287
|
+
this.upsertStmt = db.prepare(`
|
|
6288
|
+
INSERT INTO managed_sessions (
|
|
6289
|
+
session_id, provider, pid, cmdline, project_path, project_name, branch,
|
|
6290
|
+
status, status_source, status_updated_at, started_at, completed_at,
|
|
6291
|
+
last_activity_at, prompt_count, session_name, project_id,
|
|
6292
|
+
bound_conversation_id, resumed_from_conversation_id, failure_reason,
|
|
6293
|
+
streamer_instance_id
|
|
6294
|
+
) VALUES (
|
|
6295
|
+
@session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
|
|
6296
|
+
@status, @status_source, @status_updated_at, @started_at, @completed_at,
|
|
6297
|
+
@last_activity_at, @prompt_count, @session_name, @project_id,
|
|
6298
|
+
@bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
|
|
6299
|
+
@streamer_instance_id
|
|
6300
|
+
)
|
|
6301
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
6302
|
+
pid = excluded.pid,
|
|
6303
|
+
cmdline = excluded.cmdline,
|
|
6304
|
+
project_path = excluded.project_path,
|
|
6305
|
+
project_name = excluded.project_name,
|
|
6306
|
+
branch = excluded.branch,
|
|
6307
|
+
status = excluded.status,
|
|
6308
|
+
status_source = excluded.status_source,
|
|
6309
|
+
status_updated_at = excluded.status_updated_at,
|
|
6310
|
+
completed_at = excluded.completed_at,
|
|
6311
|
+
last_activity_at = excluded.last_activity_at,
|
|
6312
|
+
prompt_count = excluded.prompt_count,
|
|
6313
|
+
session_name = excluded.session_name,
|
|
6314
|
+
project_id = excluded.project_id,
|
|
6315
|
+
bound_conversation_id = excluded.bound_conversation_id,
|
|
6316
|
+
resumed_from_conversation_id = excluded.resumed_from_conversation_id,
|
|
6317
|
+
failure_reason = excluded.failure_reason,
|
|
6318
|
+
streamer_instance_id = excluded.streamer_instance_id
|
|
6319
|
+
`);
|
|
6320
|
+
this.updateStatusStmt = db.prepare(`
|
|
6321
|
+
UPDATE managed_sessions
|
|
6322
|
+
SET status = @status,
|
|
6323
|
+
status_source = @status_source,
|
|
6324
|
+
status_updated_at = @status_updated_at,
|
|
6325
|
+
completed_at = @completed_at,
|
|
6326
|
+
last_activity_at = @last_activity_at,
|
|
6327
|
+
prompt_count = @prompt_count,
|
|
6328
|
+
failure_reason = COALESCE(@failure_reason, failure_reason)
|
|
6329
|
+
WHERE session_id = @session_id
|
|
6330
|
+
`);
|
|
6331
|
+
this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
|
|
6332
|
+
this.listNonTerminalStmt = db.prepare(`
|
|
6333
|
+
SELECT * FROM managed_sessions
|
|
6334
|
+
WHERE completed_at IS NULL
|
|
6335
|
+
ORDER BY started_at ASC
|
|
6336
|
+
`);
|
|
6337
|
+
this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
|
|
6338
|
+
}
|
|
6339
|
+
/** Record a session at spawn, or refresh every field of an existing row. */
|
|
6340
|
+
recordSpawn({ session, pid, cmdline, streamerInstanceId }) {
|
|
6341
|
+
this.upsertStmt.run({
|
|
6342
|
+
session_id: session.id,
|
|
6343
|
+
provider: session.provider ?? "claude-code",
|
|
6344
|
+
pid,
|
|
6345
|
+
cmdline,
|
|
6346
|
+
project_path: session.projectPath,
|
|
6347
|
+
project_name: session.projectName,
|
|
6348
|
+
branch: session.branch ?? "",
|
|
6349
|
+
status: session.status,
|
|
6350
|
+
status_source: "spawn",
|
|
6351
|
+
status_updated_at: Date.now(),
|
|
6352
|
+
started_at: session.startedAt.getTime(),
|
|
6353
|
+
completed_at: session.completedAt?.getTime() ?? null,
|
|
6354
|
+
last_activity_at: session.lastActivityAt?.getTime() ?? null,
|
|
6355
|
+
prompt_count: session.promptCount,
|
|
6356
|
+
session_name: session.sessionName ?? null,
|
|
6357
|
+
project_id: session.projectId ?? null,
|
|
6358
|
+
bound_conversation_id: session.boundConversationId ?? null,
|
|
6359
|
+
resumed_from_conversation_id: session.resumedFromConversationId ?? null,
|
|
6360
|
+
failure_reason: session.failureReason ?? null,
|
|
6361
|
+
streamer_instance_id: streamerInstanceId
|
|
6362
|
+
});
|
|
6363
|
+
}
|
|
6364
|
+
/**
|
|
6365
|
+
* Persist a status transition. `source` is required rather than defaulted:
|
|
6366
|
+
* a status whose provenance is unknown is the thing this table exists to
|
|
6367
|
+
* prevent, and the reconciler reads it to decide how much to trust the value.
|
|
6368
|
+
*/
|
|
6369
|
+
recordStatus(sessionId, status, source, fields = {}) {
|
|
6370
|
+
this.updateStatusStmt.run({
|
|
6371
|
+
session_id: sessionId,
|
|
6372
|
+
status,
|
|
6373
|
+
status_source: source,
|
|
6374
|
+
status_updated_at: Date.now(),
|
|
6375
|
+
completed_at: fields.completedAt?.getTime() ?? null,
|
|
6376
|
+
last_activity_at: fields.lastActivityAt?.getTime() ?? null,
|
|
6377
|
+
prompt_count: fields.promptCount ?? 0,
|
|
6378
|
+
failure_reason: fields.failureReason ?? null
|
|
6379
|
+
});
|
|
6380
|
+
}
|
|
6381
|
+
get(sessionId) {
|
|
6382
|
+
return this.getStmt.get(sessionId) ?? null;
|
|
6383
|
+
}
|
|
6384
|
+
/** Rows with no recorded completion — the reconciler's probe set. */
|
|
6385
|
+
listNonTerminal() {
|
|
6386
|
+
return this.listNonTerminalStmt.all();
|
|
6387
|
+
}
|
|
6388
|
+
delete(sessionId) {
|
|
6389
|
+
this.deleteStmt.run(sessionId);
|
|
6390
|
+
}
|
|
6391
|
+
};
|
|
6392
|
+
|
|
5203
6393
|
// src/db/repositories/projects.repository.ts
|
|
5204
|
-
var
|
|
6394
|
+
var import_crypto7 = require("crypto");
|
|
5205
6395
|
|
|
5206
6396
|
// src/utils/canonicalizeProjectPath.ts
|
|
5207
6397
|
function canonicalizeProjectPath(projectPath) {
|
|
@@ -5294,7 +6484,7 @@ var ProjectsRepository = class {
|
|
|
5294
6484
|
});
|
|
5295
6485
|
return rowToProject(this.getById.get(existing.id));
|
|
5296
6486
|
}
|
|
5297
|
-
const id = (0,
|
|
6487
|
+
const id = (0, import_crypto7.randomUUID)();
|
|
5298
6488
|
this.insert.run({
|
|
5299
6489
|
id,
|
|
5300
6490
|
path,
|
|
@@ -5384,8 +6574,20 @@ function handleListProjects(url, res) {
|
|
|
5384
6574
|
res.end(JSON.stringify({ projects: page, total }));
|
|
5385
6575
|
}
|
|
5386
6576
|
|
|
6577
|
+
// src/lifecycle/process-liveness.ts
|
|
6578
|
+
function isPidAlive(pid) {
|
|
6579
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
6580
|
+
try {
|
|
6581
|
+
process.kill(pid, 0);
|
|
6582
|
+
return true;
|
|
6583
|
+
} catch (err) {
|
|
6584
|
+
const code = err.code;
|
|
6585
|
+
return code === "EPERM";
|
|
6586
|
+
}
|
|
6587
|
+
}
|
|
6588
|
+
|
|
5387
6589
|
// src/pair-store.ts
|
|
5388
|
-
var
|
|
6590
|
+
var import_crypto8 = require("crypto");
|
|
5389
6591
|
var DEFAULT_TTL_SECONDS = 180;
|
|
5390
6592
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
5391
6593
|
var PairTokenStore = class {
|
|
@@ -5400,7 +6602,7 @@ var PairTokenStore = class {
|
|
|
5400
6602
|
}
|
|
5401
6603
|
}
|
|
5402
6604
|
mint() {
|
|
5403
|
-
const token = `pt_${(0,
|
|
6605
|
+
const token = `pt_${(0, import_crypto8.randomBytes)(16).toString("hex")}`;
|
|
5404
6606
|
const expiresAt = Date.now() + this.ttlMs;
|
|
5405
6607
|
this.current = { token, expiresAt, used: false };
|
|
5406
6608
|
return {
|
|
@@ -5468,7 +6670,7 @@ function setCacheMetadata(repo, key, value) {
|
|
|
5468
6670
|
}
|
|
5469
6671
|
|
|
5470
6672
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5471
|
-
var
|
|
6673
|
+
var import_crypto9 = require("crypto");
|
|
5472
6674
|
var import_fs13 = require("fs");
|
|
5473
6675
|
|
|
5474
6676
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -5533,13 +6735,13 @@ function envInt(name, fallback) {
|
|
|
5533
6735
|
}
|
|
5534
6736
|
function fingerprintOf(ids) {
|
|
5535
6737
|
const sorted = [...ids].sort();
|
|
5536
|
-
return `sha256:${(0,
|
|
6738
|
+
return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5537
6739
|
}
|
|
5538
6740
|
var CacheIntegrityMonitor = class {
|
|
5539
|
-
constructor(cache, wsHub,
|
|
6741
|
+
constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
|
|
5540
6742
|
this.cache = cache;
|
|
5541
6743
|
this.wsHub = wsHub;
|
|
5542
|
-
this.log =
|
|
6744
|
+
this.log = log7;
|
|
5543
6745
|
this.cacheDir = cacheDir;
|
|
5544
6746
|
this.rescan = rescan;
|
|
5545
6747
|
this.runDuringReset = runDuringReset;
|
|
@@ -6095,62 +7297,633 @@ function refreshConversationCache(deps) {
|
|
|
6095
7297
|
};
|
|
6096
7298
|
}
|
|
6097
7299
|
|
|
6098
|
-
// src/services/conversations/shouldRefreshProjectsFromHdd.ts
|
|
6099
|
-
var import_fs16 = require("fs");
|
|
6100
|
-
var import_os8 = require("os");
|
|
6101
|
-
var import_path16 = require("path");
|
|
6102
|
-
var DEFAULT_PROJECTS_DIR = (0, import_path16.join)((0, import_os8.homedir)(), ".claude", "projects");
|
|
6103
|
-
function maxProjectsTreeMtimeMs(projectsDir) {
|
|
6104
|
-
let maxMs;
|
|
6105
|
-
try {
|
|
6106
|
-
maxMs = (0, import_fs16.statSync)(projectsDir).mtimeMs;
|
|
6107
|
-
} catch {
|
|
6108
|
-
return null;
|
|
7300
|
+
// src/services/conversations/shouldRefreshProjectsFromHdd.ts
|
|
7301
|
+
var import_fs16 = require("fs");
|
|
7302
|
+
var import_os8 = require("os");
|
|
7303
|
+
var import_path16 = require("path");
|
|
7304
|
+
var DEFAULT_PROJECTS_DIR = (0, import_path16.join)((0, import_os8.homedir)(), ".claude", "projects");
|
|
7305
|
+
function maxProjectsTreeMtimeMs(projectsDir) {
|
|
7306
|
+
let maxMs;
|
|
7307
|
+
try {
|
|
7308
|
+
maxMs = (0, import_fs16.statSync)(projectsDir).mtimeMs;
|
|
7309
|
+
} catch {
|
|
7310
|
+
return null;
|
|
7311
|
+
}
|
|
7312
|
+
try {
|
|
7313
|
+
for (const ent of (0, import_fs16.readdirSync)(projectsDir, { withFileTypes: true })) {
|
|
7314
|
+
if (!ent.isDirectory()) continue;
|
|
7315
|
+
try {
|
|
7316
|
+
const childMs = (0, import_fs16.statSync)((0, import_path16.join)(projectsDir, ent.name)).mtimeMs;
|
|
7317
|
+
if (childMs > maxMs) maxMs = childMs;
|
|
7318
|
+
} catch {
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
} catch {
|
|
7322
|
+
}
|
|
7323
|
+
return maxMs;
|
|
7324
|
+
}
|
|
7325
|
+
function shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, opts = {}) {
|
|
7326
|
+
if (conversationsRepo.hasOrphanRows()) return true;
|
|
7327
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
7328
|
+
if (opts.projectsDirs) {
|
|
7329
|
+
for (const d of opts.projectsDirs) dirs.add(d);
|
|
7330
|
+
}
|
|
7331
|
+
dirs.add(opts.projectsDir ?? DEFAULT_PROJECTS_DIR);
|
|
7332
|
+
let newestMs = null;
|
|
7333
|
+
for (const dir of dirs) {
|
|
7334
|
+
const ms = maxProjectsTreeMtimeMs(dir);
|
|
7335
|
+
if (ms === null) continue;
|
|
7336
|
+
if (newestMs === null || ms > newestMs) newestMs = ms;
|
|
7337
|
+
}
|
|
7338
|
+
if (newestMs === null) return false;
|
|
7339
|
+
const lastIndexedIso = getCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at");
|
|
7340
|
+
if (!lastIndexedIso) return true;
|
|
7341
|
+
const lastIndexedMs = Date.parse(lastIndexedIso);
|
|
7342
|
+
if (Number.isNaN(lastIndexedMs)) return true;
|
|
7343
|
+
return newestMs > lastIndexedMs;
|
|
7344
|
+
}
|
|
7345
|
+
|
|
7346
|
+
// src/services/projectChats/deriveProjectChatTitle.ts
|
|
7347
|
+
function deriveProjectChatTitle(input) {
|
|
7348
|
+
const trimmed = input.title?.trim();
|
|
7349
|
+
if (trimmed) return trimmed;
|
|
7350
|
+
const name = input.projectName?.trim();
|
|
7351
|
+
if (name) return name;
|
|
7352
|
+
const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
|
|
7353
|
+
if (pathSuffix) return pathSuffix;
|
|
7354
|
+
return `Untitled \xB7 ${input.id.slice(0, 8)}`;
|
|
7355
|
+
}
|
|
7356
|
+
|
|
7357
|
+
// src/services/push/apnsClient.ts
|
|
7358
|
+
var import_node_crypto3 = require("crypto");
|
|
7359
|
+
var import_node_http2 = require("http2");
|
|
7360
|
+
var log3 = getLogger("apns");
|
|
7361
|
+
var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
|
|
7362
|
+
var APNS_MAX_PAYLOAD_BYTES = 4096;
|
|
7363
|
+
var JWT_TTL_SECONDS = 3e3;
|
|
7364
|
+
var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
|
|
7365
|
+
"BadDeviceToken",
|
|
7366
|
+
"DeviceTokenNotForTopic",
|
|
7367
|
+
"Unregistered",
|
|
7368
|
+
"ExpiredToken"
|
|
7369
|
+
]);
|
|
7370
|
+
function base64url(input) {
|
|
7371
|
+
return Buffer.from(input).toString("base64url");
|
|
7372
|
+
}
|
|
7373
|
+
function readApnsCredentialsFromEnv(env = process.env) {
|
|
7374
|
+
const key = env.APNS_KEY;
|
|
7375
|
+
if (!key || key.trim().length === 0) return null;
|
|
7376
|
+
const keyId = env.APNS_KEY_ID?.trim();
|
|
7377
|
+
const teamId = env.APNS_TEAM_ID?.trim();
|
|
7378
|
+
const bundleId = env.APNS_BUNDLE_ID?.trim();
|
|
7379
|
+
if (!keyId || !teamId || !bundleId) return null;
|
|
7380
|
+
const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
|
|
7381
|
+
return { key, keyId, teamId, bundleId, host };
|
|
7382
|
+
}
|
|
7383
|
+
function describeMissingApnsCredentials(env = process.env) {
|
|
7384
|
+
if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
|
|
7385
|
+
return "APNS_KEY is not set, so Live Activity push is disabled. Set it to the p8 key contents (not a path) to enable it.";
|
|
7386
|
+
}
|
|
7387
|
+
const missing = [
|
|
7388
|
+
["APNS_KEY_ID", env.APNS_KEY_ID],
|
|
7389
|
+
["APNS_TEAM_ID", env.APNS_TEAM_ID],
|
|
7390
|
+
["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
|
|
7391
|
+
].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
|
|
7392
|
+
if (missing.length === 0) return null;
|
|
7393
|
+
return `APNS_KEY is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not, so Live Activity push is disabled. Under launchd, APNS_KEY_ID is derived from the AuthKey_<keyId>.p8 filename; the team and bundle ids must be set explicitly.`;
|
|
7394
|
+
}
|
|
7395
|
+
var ApnsClient = class {
|
|
7396
|
+
constructor(creds) {
|
|
7397
|
+
this.creds = creds;
|
|
7398
|
+
}
|
|
7399
|
+
creds;
|
|
7400
|
+
session = null;
|
|
7401
|
+
cachedJwt = null;
|
|
7402
|
+
/**
|
|
7403
|
+
* The `apns-topic` for Live Activity pushes.
|
|
7404
|
+
*
|
|
7405
|
+
* The `.push-type.liveactivity` suffix is mandatory and is why the signing key
|
|
7406
|
+
* must be Team Scoped (All Topics) — a key scoped to the bundle id alone
|
|
7407
|
+
* cannot sign this topic.
|
|
7408
|
+
*/
|
|
7409
|
+
get topic() {
|
|
7410
|
+
return `${this.creds.bundleId}.push-type.liveactivity`;
|
|
7411
|
+
}
|
|
7412
|
+
/**
|
|
7413
|
+
* Mint or reuse the provider JWT.
|
|
7414
|
+
*
|
|
7415
|
+
* ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
|
|
7416
|
+
* token older than an hour, but minting one per request is wasteful and can
|
|
7417
|
+
* trip APNs' provider-token-update throttle.
|
|
7418
|
+
*/
|
|
7419
|
+
getJwt(now = Date.now()) {
|
|
7420
|
+
const nowSeconds = Math.floor(now / 1e3);
|
|
7421
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
|
|
7422
|
+
return this.cachedJwt.token;
|
|
7423
|
+
}
|
|
7424
|
+
const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
|
|
7425
|
+
const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
|
|
7426
|
+
const signingInput = `${header}.${payload}`;
|
|
7427
|
+
const signature = (0, import_node_crypto3.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
|
|
7428
|
+
const token = `${signingInput}.${base64url(signature)}`;
|
|
7429
|
+
this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
|
|
7430
|
+
return token;
|
|
7431
|
+
}
|
|
7432
|
+
/**
|
|
7433
|
+
* Reuse one HTTP/2 session across sends.
|
|
7434
|
+
*
|
|
7435
|
+
* APNs expects a long-lived connection; a fresh TLS handshake per push is slow
|
|
7436
|
+
* and Apple treats connection churn as abuse.
|
|
7437
|
+
*/
|
|
7438
|
+
getSession() {
|
|
7439
|
+
if (this.session && !this.session.closed && !this.session.destroyed) {
|
|
7440
|
+
return this.session;
|
|
7441
|
+
}
|
|
7442
|
+
const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
|
|
7443
|
+
session.on("error", (err) => {
|
|
7444
|
+
log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
|
|
7445
|
+
});
|
|
7446
|
+
this.session = session;
|
|
7447
|
+
return session;
|
|
7448
|
+
}
|
|
7449
|
+
/**
|
|
7450
|
+
* Send one push.
|
|
7451
|
+
*
|
|
7452
|
+
* Resolves with a result rather than rejecting on an APNs rejection: a
|
|
7453
|
+
* rejected push is an expected outcome the caller must act on (retire the
|
|
7454
|
+
* token), not an exception. Only a genuinely unexpected local failure throws,
|
|
7455
|
+
* and the caller logs it.
|
|
7456
|
+
*/
|
|
7457
|
+
async send(args) {
|
|
7458
|
+
const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
|
|
7459
|
+
if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
|
|
7460
|
+
throw new Error(
|
|
7461
|
+
`APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
|
|
7462
|
+
);
|
|
7463
|
+
}
|
|
7464
|
+
const session = this.getSession();
|
|
7465
|
+
const headers = {
|
|
7466
|
+
[import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
|
|
7467
|
+
[import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
|
|
7468
|
+
[import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
|
|
7469
|
+
"apns-push-type": "liveactivity",
|
|
7470
|
+
"apns-topic": this.topic,
|
|
7471
|
+
"apns-priority": String(args.priority ?? 10),
|
|
7472
|
+
...args.expirationSeconds != null && {
|
|
7473
|
+
"apns-expiration": String(args.expirationSeconds)
|
|
7474
|
+
},
|
|
7475
|
+
[import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
|
|
7476
|
+
[import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
|
|
7477
|
+
};
|
|
7478
|
+
return new Promise((resolve2, reject) => {
|
|
7479
|
+
const req = session.request(headers);
|
|
7480
|
+
req.setTimeout(args.timeoutMs ?? 1e4, () => {
|
|
7481
|
+
req.close(import_node_http2.constants.NGHTTP2_CANCEL);
|
|
7482
|
+
resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
|
|
7483
|
+
});
|
|
7484
|
+
let status = 0;
|
|
7485
|
+
req.on("response", (resHeaders) => {
|
|
7486
|
+
status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
|
|
7487
|
+
});
|
|
7488
|
+
const chunks = [];
|
|
7489
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
7490
|
+
req.on("error", reject);
|
|
7491
|
+
req.on("end", () => {
|
|
7492
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
7493
|
+
let reason;
|
|
7494
|
+
if (raw.length > 0) {
|
|
7495
|
+
try {
|
|
7496
|
+
reason = JSON.parse(raw).reason;
|
|
7497
|
+
} catch {
|
|
7498
|
+
reason = raw.slice(0, 200);
|
|
7499
|
+
}
|
|
7500
|
+
}
|
|
7501
|
+
resolve2({
|
|
7502
|
+
ok: status === 200,
|
|
7503
|
+
status,
|
|
7504
|
+
reason,
|
|
7505
|
+
tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
|
|
7506
|
+
});
|
|
7507
|
+
});
|
|
7508
|
+
req.end(body);
|
|
7509
|
+
});
|
|
7510
|
+
}
|
|
7511
|
+
/** Close the shared connection. Called on server shutdown. */
|
|
7512
|
+
close() {
|
|
7513
|
+
this.session?.close();
|
|
7514
|
+
this.session = null;
|
|
7515
|
+
}
|
|
7516
|
+
};
|
|
7517
|
+
|
|
7518
|
+
// src/services/push/liveActivityContentState.ts
|
|
7519
|
+
var LAST_OUTPUT_MAX_LENGTH = 90;
|
|
7520
|
+
function toLiveActivityStatus(status) {
|
|
7521
|
+
return status === "running" || status === "waiting_input" ? status : null;
|
|
7522
|
+
}
|
|
7523
|
+
function truncateLastOutput(raw) {
|
|
7524
|
+
const oneLine = raw.replace(/\s+/g, " ").trim();
|
|
7525
|
+
return oneLine.length <= LAST_OUTPUT_MAX_LENGTH ? oneLine : oneLine.slice(0, LAST_OUTPUT_MAX_LENGTH);
|
|
7526
|
+
}
|
|
7527
|
+
|
|
7528
|
+
// src/services/push/liveActivityNotifier.ts
|
|
7529
|
+
var log4 = getLogger("live-activity");
|
|
7530
|
+
function contentStateForSession(args) {
|
|
7531
|
+
const status = toLiveActivityStatus(args.session.status);
|
|
7532
|
+
if (!status) return null;
|
|
7533
|
+
return {
|
|
7534
|
+
sessionId: args.session.id,
|
|
7535
|
+
serverId: args.serverId,
|
|
7536
|
+
projectName: args.session.projectName,
|
|
7537
|
+
status,
|
|
7538
|
+
startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
|
|
7539
|
+
lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
|
|
7540
|
+
...args.serverLabel != null && { serverLabel: args.serverLabel }
|
|
7541
|
+
};
|
|
7542
|
+
}
|
|
7543
|
+
var LiveActivityNotifier = class {
|
|
7544
|
+
constructor(sender, serverId, serverLabel) {
|
|
7545
|
+
this.sender = sender;
|
|
7546
|
+
this.serverId = serverId;
|
|
7547
|
+
this.serverLabel = serverLabel;
|
|
7548
|
+
}
|
|
7549
|
+
sender;
|
|
7550
|
+
serverId;
|
|
7551
|
+
serverLabel;
|
|
7552
|
+
/**
|
|
7553
|
+
* Last status pushed per session.
|
|
7554
|
+
*
|
|
7555
|
+
* Live Activity pushes are rate-limited by iOS and the surface only renders
|
|
7556
|
+
* `running` vs `waiting_input`, so re-pushing an unchanged status is pure
|
|
7557
|
+
* budget spend for no visible change. This is what makes the notifier
|
|
7558
|
+
* edge-triggered rather than level-triggered.
|
|
7559
|
+
*/
|
|
7560
|
+
lastPushed = /* @__PURE__ */ new Map();
|
|
7561
|
+
/**
|
|
7562
|
+
* React to a session status change.
|
|
7563
|
+
*
|
|
7564
|
+
* Fire-and-forget by design: a push must never delay or fail a session
|
|
7565
|
+
* transition, so this returns a promise the caller may ignore and every error
|
|
7566
|
+
* is logged rather than propagated.
|
|
7567
|
+
*/
|
|
7568
|
+
async onStatusChange(session) {
|
|
7569
|
+
const status = toLiveActivityStatus(session.status);
|
|
7570
|
+
try {
|
|
7571
|
+
if (!status) {
|
|
7572
|
+
await this.endFor(session);
|
|
7573
|
+
return;
|
|
7574
|
+
}
|
|
7575
|
+
if (this.lastPushed.get(session.id) === status) return;
|
|
7576
|
+
const contentState = contentStateForSession({
|
|
7577
|
+
session,
|
|
7578
|
+
serverId: this.serverId,
|
|
7579
|
+
serverLabel: this.serverLabel
|
|
7580
|
+
});
|
|
7581
|
+
if (!contentState) return;
|
|
7582
|
+
const outcome = await this.sender.send({
|
|
7583
|
+
sessionId: session.id,
|
|
7584
|
+
event: "update",
|
|
7585
|
+
contentState
|
|
7586
|
+
});
|
|
7587
|
+
this.lastPushed.set(session.id, status);
|
|
7588
|
+
if (outcome.attempted > 0) {
|
|
7589
|
+
log4.info("live_activity.updated", {
|
|
7590
|
+
event: "live_activity.updated",
|
|
7591
|
+
sessionId: session.id,
|
|
7592
|
+
status,
|
|
7593
|
+
...outcome
|
|
7594
|
+
});
|
|
7595
|
+
}
|
|
7596
|
+
} catch (err) {
|
|
7597
|
+
log4.error("live_activity.notify_failed", {
|
|
7598
|
+
event: "live_activity.notify_failed",
|
|
7599
|
+
sessionId: session.id,
|
|
7600
|
+
status: session.status,
|
|
7601
|
+
err: String(err)
|
|
7602
|
+
});
|
|
7603
|
+
}
|
|
7604
|
+
}
|
|
7605
|
+
async endFor(session) {
|
|
7606
|
+
const lastStatus = this.lastPushed.get(session.id);
|
|
7607
|
+
this.lastPushed.delete(session.id);
|
|
7608
|
+
const contentState = contentStateForSession({
|
|
7609
|
+
session: {
|
|
7610
|
+
...session,
|
|
7611
|
+
status: lastStatus === "waiting_input" ? "waiting_input" : "running"
|
|
7612
|
+
},
|
|
7613
|
+
serverId: this.serverId,
|
|
7614
|
+
serverLabel: this.serverLabel
|
|
7615
|
+
});
|
|
7616
|
+
if (!contentState) return;
|
|
7617
|
+
const outcome = await this.sender.end({ sessionId: session.id, contentState });
|
|
7618
|
+
if (outcome.attempted > 0) {
|
|
7619
|
+
log4.info("live_activity.ended", {
|
|
7620
|
+
event: "live_activity.ended",
|
|
7621
|
+
sessionId: session.id,
|
|
7622
|
+
...outcome
|
|
7623
|
+
});
|
|
7624
|
+
}
|
|
7625
|
+
}
|
|
7626
|
+
/** Drop cached state for a session, so a resume re-pushes its first status. */
|
|
7627
|
+
forget(sessionId) {
|
|
7628
|
+
this.lastPushed.delete(sessionId);
|
|
7629
|
+
}
|
|
7630
|
+
};
|
|
7631
|
+
|
|
7632
|
+
// src/services/push/liveActivitySender.ts
|
|
7633
|
+
var log5 = getLogger("live-activity");
|
|
7634
|
+
var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
|
|
7635
|
+
function buildActivityKitPayload(args) {
|
|
7636
|
+
return {
|
|
7637
|
+
aps: {
|
|
7638
|
+
timestamp: Math.floor(args.now / 1e3),
|
|
7639
|
+
event: args.event,
|
|
7640
|
+
"content-state": args.contentState,
|
|
7641
|
+
...args.staleDate != null && { "stale-date": Math.floor(args.staleDate / 1e3) },
|
|
7642
|
+
...args.dismissalDate != null && {
|
|
7643
|
+
"dismissal-date": Math.floor(args.dismissalDate / 1e3)
|
|
7644
|
+
}
|
|
7645
|
+
}
|
|
7646
|
+
};
|
|
7647
|
+
}
|
|
7648
|
+
var LiveActivitySender = class {
|
|
7649
|
+
constructor(apns, repo) {
|
|
7650
|
+
this.apns = apns;
|
|
7651
|
+
this.repo = repo;
|
|
7652
|
+
}
|
|
7653
|
+
apns;
|
|
7654
|
+
repo;
|
|
7655
|
+
/**
|
|
7656
|
+
* Push to every live activity of a session.
|
|
7657
|
+
*
|
|
7658
|
+
* Sends are independent: one rejected token must not stop the others, because
|
|
7659
|
+
* a single dead device would otherwise silence every other device watching the
|
|
7660
|
+
* same session.
|
|
7661
|
+
*/
|
|
7662
|
+
async send(args) {
|
|
7663
|
+
const now = args.now ?? Date.now();
|
|
7664
|
+
return this.sendToTokens({
|
|
7665
|
+
tokens: this.repo.listForSession("liveactivity_update", args.sessionId, now),
|
|
7666
|
+
sessionId: args.sessionId,
|
|
7667
|
+
event: args.event,
|
|
7668
|
+
contentState: args.contentState,
|
|
7669
|
+
now,
|
|
7670
|
+
priority: args.priority
|
|
7671
|
+
});
|
|
7672
|
+
}
|
|
7673
|
+
/**
|
|
7674
|
+
* Push to an explicit token list.
|
|
7675
|
+
*
|
|
7676
|
+
* Renewal needs this: a replacement activity does not exist yet, so it is
|
|
7677
|
+
* started via the app-wide push-to-start token rather than any per-session
|
|
7678
|
+
* lookup. Shares one fan-out body with `send()` so failure handling cannot
|
|
7679
|
+
* drift between the two paths.
|
|
7680
|
+
*/
|
|
7681
|
+
async sendToTokens(args) {
|
|
7682
|
+
const now = args.now ?? Date.now();
|
|
7683
|
+
const tokens = args.tokens;
|
|
7684
|
+
const outcome = {
|
|
7685
|
+
attempted: tokens.length,
|
|
7686
|
+
succeeded: 0,
|
|
7687
|
+
retired: 0
|
|
7688
|
+
};
|
|
7689
|
+
if (tokens.length === 0) return outcome;
|
|
7690
|
+
const results = await Promise.all(
|
|
7691
|
+
tokens.map(
|
|
7692
|
+
(row) => this.sendToToken(row, args.event, args.contentState, now, args.priority, args.staleDate)
|
|
7693
|
+
)
|
|
7694
|
+
);
|
|
7695
|
+
for (const { row, result, error } of results) {
|
|
7696
|
+
if (error) {
|
|
7697
|
+
log5.error("live_activity.send_failed", {
|
|
7698
|
+
event: "live_activity.send_failed",
|
|
7699
|
+
sessionId: args.sessionId,
|
|
7700
|
+
activityId: row.activity_id,
|
|
7701
|
+
apnsEvent: args.event,
|
|
7702
|
+
err: String(error)
|
|
7703
|
+
});
|
|
7704
|
+
this.repo.recordFailure(row.token, "SendError", now);
|
|
7705
|
+
continue;
|
|
7706
|
+
}
|
|
7707
|
+
if (!result) continue;
|
|
7708
|
+
if (result.ok) {
|
|
7709
|
+
this.repo.recordSuccess(row.token, now);
|
|
7710
|
+
outcome.succeeded += 1;
|
|
7711
|
+
continue;
|
|
7712
|
+
}
|
|
7713
|
+
this.repo.recordFailure(row.token, result.reason ?? `HTTP_${result.status}`, now);
|
|
7714
|
+
if (result.tokenDead) {
|
|
7715
|
+
this.repo.expire(row.token, now);
|
|
7716
|
+
outcome.retired += 1;
|
|
7717
|
+
}
|
|
7718
|
+
log5.warn("live_activity.send_rejected", {
|
|
7719
|
+
event: "live_activity.send_rejected",
|
|
7720
|
+
sessionId: args.sessionId,
|
|
7721
|
+
activityId: row.activity_id,
|
|
7722
|
+
apnsEvent: args.event,
|
|
7723
|
+
status: result.status,
|
|
7724
|
+
reason: result.reason,
|
|
7725
|
+
tokenDead: result.tokenDead
|
|
7726
|
+
});
|
|
7727
|
+
}
|
|
7728
|
+
return outcome;
|
|
7729
|
+
}
|
|
7730
|
+
/**
|
|
7731
|
+
* End every live activity for a session and stop tracking them.
|
|
7732
|
+
*
|
|
7733
|
+
* Expiring locally is what stops the renewal sweep from later resurrecting an
|
|
7734
|
+
* activity for a session that has already finished.
|
|
7735
|
+
*/
|
|
7736
|
+
async end(args) {
|
|
7737
|
+
const now = args.now ?? Date.now();
|
|
7738
|
+
const outcome = await this.send({
|
|
7739
|
+
sessionId: args.sessionId,
|
|
7740
|
+
event: "end",
|
|
7741
|
+
contentState: args.contentState,
|
|
7742
|
+
now
|
|
7743
|
+
});
|
|
7744
|
+
this.repo.expireSessionActivities(args.sessionId, now);
|
|
7745
|
+
return outcome;
|
|
7746
|
+
}
|
|
7747
|
+
async sendToToken(row, event, contentState, now, priority, staleDateOverride) {
|
|
7748
|
+
const staleDate = event === "update" ? staleDateOverride ?? row.stale_date ?? contentState.startedAt + ACTIVITY_MAX_LIFETIME_MS : null;
|
|
7749
|
+
try {
|
|
7750
|
+
const result = await this.apns.send({
|
|
7751
|
+
deviceToken: row.token,
|
|
7752
|
+
payload: buildActivityKitPayload({ event, contentState, now, staleDate }),
|
|
7753
|
+
priority
|
|
7754
|
+
});
|
|
7755
|
+
return { row, result };
|
|
7756
|
+
} catch (error) {
|
|
7757
|
+
return { row, error };
|
|
7758
|
+
}
|
|
7759
|
+
}
|
|
7760
|
+
};
|
|
7761
|
+
|
|
7762
|
+
// src/services/push/liveActivityRenewal.ts
|
|
7763
|
+
var log6 = getLogger("live-activity");
|
|
7764
|
+
var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
|
|
7765
|
+
var MAX_TIMER_MS = 60 * 60 * 1e3;
|
|
7766
|
+
function renewalDueAt(row) {
|
|
7767
|
+
return row.stale_date == null ? null : row.stale_date - RENEWAL_LEAD_MS;
|
|
7768
|
+
}
|
|
7769
|
+
var LiveActivityRenewalScheduler = class {
|
|
7770
|
+
constructor(deps) {
|
|
7771
|
+
this.deps = deps;
|
|
7772
|
+
this.now = deps.now ?? (() => Date.now());
|
|
7773
|
+
}
|
|
7774
|
+
deps;
|
|
7775
|
+
timer = null;
|
|
7776
|
+
stopped = false;
|
|
7777
|
+
now;
|
|
7778
|
+
/**
|
|
7779
|
+
* Arm the scheduler from persisted state.
|
|
7780
|
+
*
|
|
7781
|
+
* Called on boot, which is what makes a renewal survive a restart: the
|
|
7782
|
+
* deadlines were never in memory to begin with.
|
|
7783
|
+
*/
|
|
7784
|
+
start() {
|
|
7785
|
+
this.stopped = false;
|
|
7786
|
+
void this.tick();
|
|
6109
7787
|
}
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
7788
|
+
stop() {
|
|
7789
|
+
this.stopped = true;
|
|
7790
|
+
if (this.timer) {
|
|
7791
|
+
clearTimeout(this.timer);
|
|
7792
|
+
this.timer = null;
|
|
7793
|
+
}
|
|
7794
|
+
}
|
|
7795
|
+
/**
|
|
7796
|
+
* Renew everything due, then sleep until the next deadline.
|
|
7797
|
+
*
|
|
7798
|
+
* Re-reads from the DB every tick rather than caching a schedule in memory, so
|
|
7799
|
+
* an activity registered after boot is picked up without re-arming anything.
|
|
7800
|
+
*/
|
|
7801
|
+
async tick() {
|
|
7802
|
+
if (this.stopped) return;
|
|
7803
|
+
const now = this.now();
|
|
7804
|
+
try {
|
|
7805
|
+
for (const row of this.deps.repo.listRenewable()) {
|
|
7806
|
+
const dueAt = renewalDueAt(row);
|
|
7807
|
+
if (dueAt == null || dueAt > now) continue;
|
|
7808
|
+
await this.renew(row, now);
|
|
6117
7809
|
}
|
|
7810
|
+
} catch (err) {
|
|
7811
|
+
log6.error("live_activity.renewal_sweep_failed", {
|
|
7812
|
+
event: "live_activity.renewal_sweep_failed",
|
|
7813
|
+
err: String(err)
|
|
7814
|
+
});
|
|
6118
7815
|
}
|
|
6119
|
-
|
|
7816
|
+
this.scheduleNext();
|
|
6120
7817
|
}
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
7818
|
+
scheduleNext() {
|
|
7819
|
+
if (this.stopped) return;
|
|
7820
|
+
const now = this.now();
|
|
7821
|
+
const pending = this.deps.repo.listRenewable().map(renewalDueAt).filter((d) => d != null);
|
|
7822
|
+
const nextDue = pending.length > 0 ? Math.min(...pending) : now + MAX_TIMER_MS;
|
|
7823
|
+
const delay = Math.min(Math.max(nextDue - now, 0), MAX_TIMER_MS);
|
|
7824
|
+
this.timer = setTimeout(() => void this.tick(), delay);
|
|
7825
|
+
this.timer.unref?.();
|
|
6128
7826
|
}
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
7827
|
+
/**
|
|
7828
|
+
* Renew one activity.
|
|
7829
|
+
*
|
|
7830
|
+
* Claims first: `claimRenewal()` succeeds exactly once per row, so a timer
|
|
7831
|
+
* re-armed after a restart mid-window cannot send a second time.
|
|
7832
|
+
*/
|
|
7833
|
+
async renew(row, now) {
|
|
7834
|
+
if (!row.session_id) return;
|
|
7835
|
+
const session = this.deps.sessionStore.getManaged(row.session_id);
|
|
7836
|
+
const status = session ? toLiveActivityStatus(session.status) : null;
|
|
7837
|
+
if (!session || !status) {
|
|
7838
|
+
this.deps.repo.claimRenewal(row.token, now);
|
|
7839
|
+
this.deps.repo.expire(row.token, now);
|
|
7840
|
+
log6.info("live_activity.renewal_skipped", {
|
|
7841
|
+
event: "live_activity.renewal_skipped",
|
|
7842
|
+
sessionId: row.session_id,
|
|
7843
|
+
activityId: row.activity_id,
|
|
7844
|
+
reason: session ? `status_${session.status}` : "session_gone"
|
|
7845
|
+
});
|
|
7846
|
+
return;
|
|
7847
|
+
}
|
|
7848
|
+
if (!this.deps.repo.claimRenewal(row.token, now)) {
|
|
7849
|
+
return;
|
|
7850
|
+
}
|
|
7851
|
+
const startedAt = row.started_at ?? session.startedAt.getTime();
|
|
7852
|
+
const contentState = {
|
|
7853
|
+
sessionId: session.id,
|
|
7854
|
+
serverId: this.deps.serverId,
|
|
7855
|
+
projectName: session.projectName,
|
|
7856
|
+
status,
|
|
7857
|
+
startedAt,
|
|
7858
|
+
lastOutput: session.lastOutput ?? "",
|
|
7859
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
7860
|
+
};
|
|
7861
|
+
try {
|
|
7862
|
+
await this.deps.sender.send({
|
|
7863
|
+
sessionId: session.id,
|
|
7864
|
+
event: "end",
|
|
7865
|
+
contentState: { ...contentState, lastOutput: truncateLastOutput(contentState.lastOutput) },
|
|
7866
|
+
now
|
|
7867
|
+
});
|
|
7868
|
+
this.deps.repo.expire(row.token, now);
|
|
7869
|
+
const started = await this.startReplacement({
|
|
7870
|
+
sessionId: session.id,
|
|
7871
|
+
startedAt,
|
|
7872
|
+
now
|
|
7873
|
+
});
|
|
7874
|
+
log6.info("live_activity.renewed", {
|
|
7875
|
+
event: "live_activity.renewed",
|
|
7876
|
+
sessionId: session.id,
|
|
7877
|
+
activityId: row.activity_id,
|
|
7878
|
+
// Logged because a regression here is invisible on the server and only
|
|
7879
|
+
// shows up as a reset timer on someone's Lock Screen.
|
|
7880
|
+
startedAt,
|
|
7881
|
+
replacementRequested: started
|
|
7882
|
+
});
|
|
7883
|
+
} catch (err) {
|
|
7884
|
+
log6.error("live_activity.renewal_failed", {
|
|
7885
|
+
event: "live_activity.renewal_failed",
|
|
7886
|
+
sessionId: session.id,
|
|
7887
|
+
activityId: row.activity_id,
|
|
7888
|
+
err: String(err)
|
|
7889
|
+
});
|
|
7890
|
+
}
|
|
6135
7891
|
}
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
7892
|
+
/**
|
|
7893
|
+
* Ask the device to start a replacement activity.
|
|
7894
|
+
*
|
|
7895
|
+
* Uses the app-wide push-to-start token, because the replacement does not
|
|
7896
|
+
* exist yet and therefore has no per-activity token. Returns false when the
|
|
7897
|
+
* device never registered one, which is not an error: the app simply cannot be
|
|
7898
|
+
* asked to start an activity remotely, and the next foreground WS update
|
|
7899
|
+
* recreates it.
|
|
7900
|
+
*/
|
|
7901
|
+
async startReplacement(args) {
|
|
7902
|
+
const starters = this.deps.repo.listByKind("liveactivity_start", args.now);
|
|
7903
|
+
if (starters.length === 0) return false;
|
|
7904
|
+
const session = this.deps.sessionStore.getManaged(args.sessionId);
|
|
7905
|
+
const status = session ? toLiveActivityStatus(session.status) : null;
|
|
7906
|
+
if (!session || !status) return false;
|
|
7907
|
+
await this.deps.sender.sendToTokens({
|
|
7908
|
+
tokens: starters,
|
|
7909
|
+
event: "update",
|
|
7910
|
+
sessionId: args.sessionId,
|
|
7911
|
+
contentState: {
|
|
7912
|
+
sessionId: session.id,
|
|
7913
|
+
serverId: this.deps.serverId,
|
|
7914
|
+
projectName: session.projectName,
|
|
7915
|
+
status,
|
|
7916
|
+
// Carried through unchanged — the whole point of the renewal.
|
|
7917
|
+
startedAt: args.startedAt,
|
|
7918
|
+
lastOutput: truncateLastOutput(session.lastOutput ?? ""),
|
|
7919
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
7920
|
+
},
|
|
7921
|
+
now: args.now,
|
|
7922
|
+
staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
|
|
7923
|
+
});
|
|
7924
|
+
return true;
|
|
7925
|
+
}
|
|
7926
|
+
};
|
|
6154
7927
|
|
|
6155
7928
|
// src/services/questions/parseStatusLine.ts
|
|
6156
7929
|
var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
|
|
@@ -6347,6 +8120,112 @@ function conversationBusy(input) {
|
|
|
6347
8120
|
};
|
|
6348
8121
|
}
|
|
6349
8122
|
|
|
8123
|
+
// src/services/sessions/idempotency.ts
|
|
8124
|
+
var IDEMPOTENCY_TTL_MS = 10 * 60 * 1e3;
|
|
8125
|
+
var IDEMPOTENCY_MAX_KEYS = 200;
|
|
8126
|
+
var IdempotencyStore = class {
|
|
8127
|
+
constructor(ttlMs = IDEMPOTENCY_TTL_MS, maxKeys = IDEMPOTENCY_MAX_KEYS) {
|
|
8128
|
+
this.ttlMs = ttlMs;
|
|
8129
|
+
this.maxKeys = maxKeys;
|
|
8130
|
+
}
|
|
8131
|
+
ttlMs;
|
|
8132
|
+
maxKeys;
|
|
8133
|
+
bySession = /* @__PURE__ */ new Map();
|
|
8134
|
+
/**
|
|
8135
|
+
* Previously recorded result for this key, or null if the key is new,
|
|
8136
|
+
* expired, or evicted. A miss always means "treat as a fresh request" —
|
|
8137
|
+
* failing open, because dropping a real prompt is far worse than allowing a
|
|
8138
|
+
* rare duplicate.
|
|
8139
|
+
*/
|
|
8140
|
+
get(sessionId, key, now = Date.now()) {
|
|
8141
|
+
const entries = this.bySession.get(sessionId);
|
|
8142
|
+
if (!entries) return null;
|
|
8143
|
+
const hit = entries.find((e) => e.key === key);
|
|
8144
|
+
if (!hit) return null;
|
|
8145
|
+
if (now - hit.at > this.ttlMs) {
|
|
8146
|
+
this.bySession.set(
|
|
8147
|
+
sessionId,
|
|
8148
|
+
entries.filter((e) => e !== hit)
|
|
8149
|
+
);
|
|
8150
|
+
return null;
|
|
8151
|
+
}
|
|
8152
|
+
return hit.result;
|
|
8153
|
+
}
|
|
8154
|
+
/** Record the outcome of an accepted write so a retry can replay it. */
|
|
8155
|
+
set(sessionId, key, result, now = Date.now()) {
|
|
8156
|
+
const entries = this.bySession.get(sessionId) ?? [];
|
|
8157
|
+
const pruned = entries.filter((e) => e.key !== key && now - e.at <= this.ttlMs);
|
|
8158
|
+
pruned.push({ key, at: now, result });
|
|
8159
|
+
this.bySession.set(sessionId, pruned.slice(-this.maxKeys));
|
|
8160
|
+
}
|
|
8161
|
+
/** Drop everything for a session whose PTY is gone. */
|
|
8162
|
+
clear(sessionId) {
|
|
8163
|
+
this.bySession.delete(sessionId);
|
|
8164
|
+
}
|
|
8165
|
+
/** Test/diagnostic helper: how many keys are currently held for a session. */
|
|
8166
|
+
size(sessionId) {
|
|
8167
|
+
return this.bySession.get(sessionId)?.length ?? 0;
|
|
8168
|
+
}
|
|
8169
|
+
};
|
|
8170
|
+
function readIdempotencyKey(body) {
|
|
8171
|
+
const raw = body.idempotencyKey;
|
|
8172
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
8173
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > 200) {
|
|
8174
|
+
throw new Error("idempotencyKey must be a non-empty string of at most 200 characters");
|
|
8175
|
+
}
|
|
8176
|
+
return raw;
|
|
8177
|
+
}
|
|
8178
|
+
|
|
8179
|
+
// src/services/sessions/reconcileSessions.ts
|
|
8180
|
+
async function classifySession(row, probe, currentInstanceId) {
|
|
8181
|
+
const { session_id: sessionId } = row;
|
|
8182
|
+
if (row.completed_at != null) {
|
|
8183
|
+
const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
|
|
8184
|
+
return {
|
|
8185
|
+
sessionId,
|
|
8186
|
+
lifecycle: clean ? "completed" : "failed",
|
|
8187
|
+
reason: `terminal (${row.status_source})`
|
|
8188
|
+
};
|
|
8189
|
+
}
|
|
8190
|
+
if (row.pid == null) {
|
|
8191
|
+
return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
|
|
8192
|
+
}
|
|
8193
|
+
if (!probe.isPidAlive(row.pid)) {
|
|
8194
|
+
const clean = probe.endedCleanly?.(row) ?? false;
|
|
8195
|
+
if (clean) {
|
|
8196
|
+
return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
|
|
8197
|
+
}
|
|
8198
|
+
return {
|
|
8199
|
+
sessionId,
|
|
8200
|
+
lifecycle: "resumable",
|
|
8201
|
+
reason: "process gone, resumable from provider history"
|
|
8202
|
+
};
|
|
8203
|
+
}
|
|
8204
|
+
const args = await probe.getProcessArgs(row.pid);
|
|
8205
|
+
const token = row.cmdline;
|
|
8206
|
+
if (!token || !args?.includes(token)) {
|
|
8207
|
+
return {
|
|
8208
|
+
sessionId,
|
|
8209
|
+
lifecycle: "orphaned",
|
|
8210
|
+
reason: args ? "pid alive but command line does not match" : "pid alive but argv unreadable"
|
|
8211
|
+
};
|
|
8212
|
+
}
|
|
8213
|
+
const sameRun = row.streamer_instance_id === currentInstanceId;
|
|
8214
|
+
return {
|
|
8215
|
+
sessionId,
|
|
8216
|
+
lifecycle: sameRun ? "attached" : "detached",
|
|
8217
|
+
reason: sameRun ? "owned by this run" : "survived a previous streamer run"
|
|
8218
|
+
};
|
|
8219
|
+
}
|
|
8220
|
+
async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
8221
|
+
return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
|
|
8222
|
+
}
|
|
8223
|
+
|
|
8224
|
+
// src/types.ts
|
|
8225
|
+
function confidenceForSource(source) {
|
|
8226
|
+
return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
|
|
8227
|
+
}
|
|
8228
|
+
|
|
6350
8229
|
// src/session-store.ts
|
|
6351
8230
|
var SessionStore = class {
|
|
6352
8231
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -6486,6 +8365,14 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6486
8365
|
conversationId: s.id,
|
|
6487
8366
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
6488
8367
|
status: s.status,
|
|
8368
|
+
// Lifecycle for a session this run knows about. `attached` while we hold
|
|
8369
|
+
// its PTY; once the PTY is gone the session is terminal from this run's
|
|
8370
|
+
// perspective — `failed` when it recorded a reason, else `completed`.
|
|
8371
|
+
// Sessions left by *previous* runs never reach here: they aren't in the
|
|
8372
|
+
// in-memory store, and the boot reconciler classifies them instead
|
|
8373
|
+
// (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
8374
|
+
lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
|
|
8375
|
+
lifecycleSource: ptyAttached ? "spawn" : "exit",
|
|
6489
8376
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6490
8377
|
// `activity` is attached for managed sessions.
|
|
6491
8378
|
ownership: "managed",
|
|
@@ -6509,6 +8396,13 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6509
8396
|
...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
|
|
6510
8397
|
...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
|
|
6511
8398
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt.toISOString() },
|
|
8399
|
+
// C3: how the status was derived, and how far to trust it. Confidence is
|
|
8400
|
+
// derived from the source rather than stored, so the two cannot disagree.
|
|
8401
|
+
...s.statusSource != null && {
|
|
8402
|
+
statusSource: s.statusSource,
|
|
8403
|
+
statusConfidence: confidenceForSource(s.statusSource)
|
|
8404
|
+
},
|
|
8405
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt.toISOString() },
|
|
6512
8406
|
...s.filePath != null && { filePath: s.filePath },
|
|
6513
8407
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
6514
8408
|
...s.resumedFromConversationId != null && {
|
|
@@ -6530,6 +8424,12 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6530
8424
|
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6531
8425
|
// report "gone" here — a vanished process simply stops being listed.
|
|
6532
8426
|
processLiveness: "alive",
|
|
8427
|
+
// Alive, but spawned outside this streamer, so we hold no PTY for it. That
|
|
8428
|
+
// is precisely `detached` — and it is strictly more informative than the
|
|
8429
|
+
// `status: "idle"` above, which discovery is forced to report because it
|
|
8430
|
+
// cannot see the process's prompt state.
|
|
8431
|
+
lifecycle: "detached",
|
|
8432
|
+
lifecycleSource: "probe",
|
|
6533
8433
|
projectPath: d.projectPath,
|
|
6534
8434
|
projectName: d.projectName,
|
|
6535
8435
|
branch: d.branch,
|
|
@@ -6544,7 +8444,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6544
8444
|
}
|
|
6545
8445
|
|
|
6546
8446
|
// src/uploads.ts
|
|
6547
|
-
var
|
|
8447
|
+
var import_crypto10 = require("crypto");
|
|
6548
8448
|
var import_promises6 = require("fs/promises");
|
|
6549
8449
|
var import_heic_convert = __toESM(require("heic-convert"), 1);
|
|
6550
8450
|
var import_path17 = require("path");
|
|
@@ -6578,7 +8478,7 @@ async function saveUploadFile(input) {
|
|
|
6578
8478
|
mimeType = "image/jpeg";
|
|
6579
8479
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
6580
8480
|
}
|
|
6581
|
-
const id = `up_${(0,
|
|
8481
|
+
const id = `up_${(0, import_crypto10.randomBytes)(8).toString("hex")}`;
|
|
6582
8482
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
6583
8483
|
const dir = (0, import_path17.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
6584
8484
|
await (0, import_promises6.mkdir)(dir, { recursive: true });
|
|
@@ -6612,21 +8512,46 @@ function extractCodexText(content) {
|
|
|
6612
8512
|
return "";
|
|
6613
8513
|
}).filter(Boolean).join("").trim();
|
|
6614
8514
|
}
|
|
6615
|
-
|
|
8515
|
+
var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
|
|
8516
|
+
function classifyCodexLine(line) {
|
|
6616
8517
|
let entry;
|
|
6617
8518
|
try {
|
|
6618
8519
|
entry = JSON.parse(line);
|
|
6619
8520
|
} catch {
|
|
6620
|
-
return
|
|
8521
|
+
return { kind: "unknown", raw: line, reason: "line is not valid JSON" };
|
|
8522
|
+
}
|
|
8523
|
+
if (typeof entry.type !== "string" || !KNOWN_CODEX_TYPES.has(entry.type)) {
|
|
8524
|
+
return {
|
|
8525
|
+
kind: "unknown",
|
|
8526
|
+
raw: line,
|
|
8527
|
+
reason: `unrecognized rollout envelope type: ${String(entry.type)}`
|
|
8528
|
+
};
|
|
8529
|
+
}
|
|
8530
|
+
if (entry.type !== "response_item") {
|
|
8531
|
+
return { kind: "ignored", reason: `${entry.type} carries no chat content` };
|
|
6621
8532
|
}
|
|
6622
|
-
if (entry.type !== "response_item") return null;
|
|
6623
8533
|
const payload = entry.payload;
|
|
6624
|
-
if (payload?.type !== "message")
|
|
8534
|
+
if (payload?.type !== "message") {
|
|
8535
|
+
return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
|
|
8536
|
+
}
|
|
6625
8537
|
const role = payload.role;
|
|
6626
|
-
if (role !== "user" && role !== "assistant")
|
|
8538
|
+
if (role !== "user" && role !== "assistant") {
|
|
8539
|
+
return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
|
|
8540
|
+
}
|
|
6627
8541
|
const text = extractCodexText(payload.content);
|
|
6628
|
-
if (!text)
|
|
6629
|
-
|
|
8542
|
+
if (!text) {
|
|
8543
|
+
return { kind: "ignored", reason: "message has no extractable text" };
|
|
8544
|
+
}
|
|
8545
|
+
if (role === "user" && isCodexInjectedContext(text)) {
|
|
8546
|
+
return { kind: "ignored", reason: "synthetic injected context" };
|
|
8547
|
+
}
|
|
8548
|
+
return { kind: "message", line: buildClaudeShapedLine(entry, payload, role, text) };
|
|
8549
|
+
}
|
|
8550
|
+
function normalizeCodexLineToClaudeShape(line) {
|
|
8551
|
+
const result = classifyCodexLine(line);
|
|
8552
|
+
return result.kind === "message" ? result.line : null;
|
|
8553
|
+
}
|
|
8554
|
+
function buildClaudeShapedLine(entry, payload, role, text) {
|
|
6630
8555
|
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6631
8556
|
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
6632
8557
|
return JSON.stringify({
|
|
@@ -6682,13 +8607,13 @@ function hashPrefix(text) {
|
|
|
6682
8607
|
}
|
|
6683
8608
|
|
|
6684
8609
|
// src/utils/conversationEtag.ts
|
|
6685
|
-
var
|
|
8610
|
+
var import_node_crypto4 = require("crypto");
|
|
6686
8611
|
function computeConversationEtag({
|
|
6687
8612
|
filePath,
|
|
6688
8613
|
messageCount,
|
|
6689
8614
|
timestamp: timestamp2
|
|
6690
8615
|
}) {
|
|
6691
|
-
const digest = (0,
|
|
8616
|
+
const digest = (0, import_node_crypto4.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
6692
8617
|
return `"${digest}"`;
|
|
6693
8618
|
}
|
|
6694
8619
|
|
|
@@ -6831,6 +8756,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
|
|
|
6831
8756
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
6832
8757
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6833
8758
|
var GRACE_MAX_DEFERS = 4;
|
|
8759
|
+
var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
|
|
8760
|
+
var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
|
|
6834
8761
|
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6835
8762
|
var DISCOVERY_TTL_MS = 15e3;
|
|
6836
8763
|
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
@@ -6949,9 +8876,25 @@ var StreamerServer = class {
|
|
|
6949
8876
|
sessionInputAttempts = /* @__PURE__ */ new Map();
|
|
6950
8877
|
ptyGracePeriodMs;
|
|
6951
8878
|
defaultSystemPrompt;
|
|
8879
|
+
// Resolved once at boot; see src/feature-flags.ts. Total map — every registry
|
|
8880
|
+
// id is present, so indexing it never yields undefined.
|
|
8881
|
+
featureFlags;
|
|
8882
|
+
// Derived from featureFlags.codexSystemPrompt. Kept as its own field so the
|
|
8883
|
+
// read site in startFresh() is unchanged.
|
|
8884
|
+
codexSystemPromptEnabled;
|
|
6952
8885
|
defaultPermissionMode;
|
|
6953
8886
|
defaultModel;
|
|
6954
8887
|
defaultEffort;
|
|
8888
|
+
// Allowlisted Claude CLI flags + free-text escape hatch, applied to every
|
|
8889
|
+
// spawn. Resolved once at startup (flag → server.yaml), then mutated in place
|
|
8890
|
+
// by PUT /api/config/claude-flags so a change applies to the next session
|
|
8891
|
+
// without a restart.
|
|
8892
|
+
claudeFlags;
|
|
8893
|
+
claudeExtraArgs;
|
|
8894
|
+
// True when the values came from server.yaml (and so a write persists).
|
|
8895
|
+
// False when they were pinned by a CLI flag, mirroring the api-key rotate
|
|
8896
|
+
// contract: the write still takes effect in memory but won't survive restart.
|
|
8897
|
+
claudeFlagsPersistable;
|
|
6955
8898
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6956
8899
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6957
8900
|
// Consecutive grace-timer defers for a still-`running` session (see
|
|
@@ -6959,6 +8902,18 @@ var StreamerServer = class {
|
|
|
6959
8902
|
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6960
8903
|
// Map of sessionId → set of subscribed WS clients
|
|
6961
8904
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
8905
|
+
// sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
|
|
8906
|
+
// every provider; read only by the idle reaper. Entries are dropped when the
|
|
8907
|
+
// session leaves the runner (reap/exit/hold).
|
|
8908
|
+
lastAgentChunkAt = /* @__PURE__ */ new Map();
|
|
8909
|
+
// Recently accepted input idempotency keys (C4). A retried POST replays its
|
|
8910
|
+
// original outcome instead of submitting the prompt to the agent twice.
|
|
8911
|
+
idempotency = new IdempotencyStore();
|
|
8912
|
+
// sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
|
|
8913
|
+
// this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
|
|
8914
|
+
sessionLifecycles = /* @__PURE__ */ new Map();
|
|
8915
|
+
// Periodic sweep that releases PTYs no agent is using. Null until listen().
|
|
8916
|
+
idleReaperTimer = null;
|
|
6962
8917
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
6963
8918
|
clientIdToWs = /* @__PURE__ */ new Map();
|
|
6964
8919
|
// Reverse map for cleanup on close
|
|
@@ -6968,7 +8923,26 @@ var StreamerServer = class {
|
|
|
6968
8923
|
projectsRepo = null;
|
|
6969
8924
|
conversationsRepo = null;
|
|
6970
8925
|
sessionsRepo = null;
|
|
8926
|
+
// Durable session registry (C1 Phase 2). Null when the cache DB failed to
|
|
8927
|
+
// open — persistence degrades to today's in-memory-only behaviour rather than
|
|
8928
|
+
// taking the server down with it, so every write goes through `?.`.
|
|
8929
|
+
managedSessionsRepo = null;
|
|
8930
|
+
// Identifies this streamer run. A registry row carrying a different id is a
|
|
8931
|
+
// session that outlived the process that started it.
|
|
8932
|
+
streamerInstanceId = (0, import_crypto11.randomUUID)();
|
|
6971
8933
|
cacheMetadataRepo = null;
|
|
8934
|
+
// Push registration + delivery state (C7). Null when the cache DB failed to
|
|
8935
|
+
// open — registration then degrades to a no-op rather than 500ing.
|
|
8936
|
+
pushRepo = null;
|
|
8937
|
+
// Paired-device registry (C5). Null when the cache DB failed to open — auth
|
|
8938
|
+
// then falls back to the shared API key alone, which is the pre-C5 behaviour.
|
|
8939
|
+
devicesRepo = null;
|
|
8940
|
+
// Live Activity push (Feature 12). Null when APNS_KEY is unset — the ordinary
|
|
8941
|
+
// case on a dev machine and in CI, where the feature is simply off. Missing an
|
|
8942
|
+
// optional push credential must never stop the server from booting.
|
|
8943
|
+
apnsClient = null;
|
|
8944
|
+
liveActivityNotifier = null;
|
|
8945
|
+
liveActivityRenewal = null;
|
|
6972
8946
|
discoveryCache = null;
|
|
6973
8947
|
cacheDir;
|
|
6974
8948
|
tailSize;
|
|
@@ -7004,9 +8978,17 @@ var StreamerServer = class {
|
|
|
7004
8978
|
this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os9.homedir)(), ".codex", "sessions")];
|
|
7005
8979
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
7006
8980
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
8981
|
+
this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
|
|
8982
|
+
if (config.codexSystemPromptEnabled !== void 0) {
|
|
8983
|
+
this.featureFlags.codexSystemPrompt = config.codexSystemPromptEnabled;
|
|
8984
|
+
}
|
|
8985
|
+
this.codexSystemPromptEnabled = this.featureFlags.codexSystemPrompt;
|
|
7007
8986
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
7008
8987
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
7009
8988
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
8989
|
+
this.claudeFlagsPersistable = config.claudeFlags === void 0;
|
|
8990
|
+
this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
|
|
8991
|
+
this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
7010
8992
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os9.homedir)(), ".threadbase", "cache");
|
|
7011
8993
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
7012
8994
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
@@ -7016,6 +8998,13 @@ var StreamerServer = class {
|
|
|
7016
8998
|
}, this.directoryDebounceMs);
|
|
7017
8999
|
this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
|
|
7018
9000
|
this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
|
|
9001
|
+
const enabledFlags = nonDefaultFeatureFlags(this.featureFlags);
|
|
9002
|
+
if (enabledFlags.length > 0) {
|
|
9003
|
+
this.log.info(`Feature flags active: ${enabledFlags.join(", ")}`, {
|
|
9004
|
+
event: "config.feature_flags_active",
|
|
9005
|
+
flags: enabledFlags
|
|
9006
|
+
});
|
|
9007
|
+
}
|
|
7019
9008
|
const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
|
|
7020
9009
|
if (rawRoot) {
|
|
7021
9010
|
(0, import_promises7.realpath)(rawRoot).then((resolved) => {
|
|
@@ -7131,6 +9120,7 @@ var StreamerServer = class {
|
|
|
7131
9120
|
this.ptyManager = new LiveSessionManager({
|
|
7132
9121
|
logger: getLogger("pty"),
|
|
7133
9122
|
onOutput: (sessionId, data) => {
|
|
9123
|
+
this.lastAgentChunkAt.set(sessionId, Date.now());
|
|
7134
9124
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
7135
9125
|
},
|
|
7136
9126
|
onUserMessage: (sessionId, text, ts) => {
|
|
@@ -7159,6 +9149,17 @@ var StreamerServer = class {
|
|
|
7159
9149
|
completedAt: session.completedAt,
|
|
7160
9150
|
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
|
|
7161
9151
|
});
|
|
9152
|
+
this.managedSessionsRepo?.recordStatus(
|
|
9153
|
+
session.id,
|
|
9154
|
+
session.status,
|
|
9155
|
+
session.completedAt != null ? "exit" : "transition",
|
|
9156
|
+
{
|
|
9157
|
+
completedAt: session.completedAt,
|
|
9158
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
9159
|
+
promptCount: session.promptCount,
|
|
9160
|
+
failureReason: session.failureReason ?? null
|
|
9161
|
+
}
|
|
9162
|
+
);
|
|
7162
9163
|
if (session.status === "waiting_input" || session.status === "idle") {
|
|
7163
9164
|
const filePath = this.sessionFileMap.get(session.id);
|
|
7164
9165
|
if (filePath) {
|
|
@@ -7197,6 +9198,7 @@ var StreamerServer = class {
|
|
|
7197
9198
|
if (resp) {
|
|
7198
9199
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
7199
9200
|
}
|
|
9201
|
+
void this.liveActivityNotifier?.onStatusChange(session);
|
|
7200
9202
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status);
|
|
7201
9203
|
}
|
|
7202
9204
|
});
|
|
@@ -7230,6 +9232,9 @@ var StreamerServer = class {
|
|
|
7230
9232
|
localNoAuth: this.localNoAuth,
|
|
7231
9233
|
logMenubarRequests: this.logMenubarRequests,
|
|
7232
9234
|
rotateApiKey: () => this.rotateApiKey(),
|
|
9235
|
+
claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
|
|
9236
|
+
featureFlagsConfig: () => this.getFeatureFlagsConfig(),
|
|
9237
|
+
setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
|
|
7233
9238
|
publicUrl: this.publicUrl,
|
|
7234
9239
|
browseRoot: this.browseRoot,
|
|
7235
9240
|
browserCors: this.browserCors,
|
|
@@ -7238,6 +9243,8 @@ var StreamerServer = class {
|
|
|
7238
9243
|
wsHub: this.wsHub,
|
|
7239
9244
|
cache: () => this.cache,
|
|
7240
9245
|
cacheMonitor: () => this.cacheMonitor,
|
|
9246
|
+
pushRepo: () => this.pushRepo,
|
|
9247
|
+
devicesRepo: () => this.devicesRepo,
|
|
7241
9248
|
projectsRepo: () => this.projectsRepo,
|
|
7242
9249
|
conversationsRepo: () => this.conversationsRepo,
|
|
7243
9250
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -7271,7 +9278,9 @@ var StreamerServer = class {
|
|
|
7271
9278
|
handleMkdir: (req, res) => this.handleMkdir(req, res),
|
|
7272
9279
|
handleWsOpen: (ws) => {
|
|
7273
9280
|
this.wsHub.addClient(ws);
|
|
7274
|
-
const sessions = this.
|
|
9281
|
+
const sessions = this.withReconciledLifecycle(
|
|
9282
|
+
this.sessionStore.list(this.ptyAttachedIds())
|
|
9283
|
+
);
|
|
7275
9284
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
7276
9285
|
if (!this.currentWarmupState()) {
|
|
7277
9286
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
@@ -7347,11 +9356,8 @@ var StreamerServer = class {
|
|
|
7347
9356
|
this.clientIdToWs.delete(clientId);
|
|
7348
9357
|
this.wsToClientId.delete(ws);
|
|
7349
9358
|
}
|
|
7350
|
-
for (const
|
|
9359
|
+
for (const subscribers of this.sessionSubscribers.values()) {
|
|
7351
9360
|
subscribers.delete(ws);
|
|
7352
|
-
if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
|
|
7353
|
-
this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
|
|
7354
|
-
}
|
|
7355
9361
|
}
|
|
7356
9362
|
},
|
|
7357
9363
|
agentClient,
|
|
@@ -7414,7 +9420,7 @@ var StreamerServer = class {
|
|
|
7414
9420
|
const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
|
|
7415
9421
|
const payload = {
|
|
7416
9422
|
type: "session_list",
|
|
7417
|
-
sessions: this.sessionStore.list(this.ptyAttachedIds())
|
|
9423
|
+
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
7418
9424
|
};
|
|
7419
9425
|
if (ws) {
|
|
7420
9426
|
this.wsHub.unicast(ws, payload);
|
|
@@ -7422,6 +9428,29 @@ var StreamerServer = class {
|
|
|
7422
9428
|
this.wsHub.broadcast(payload);
|
|
7423
9429
|
}
|
|
7424
9430
|
}
|
|
9431
|
+
/**
|
|
9432
|
+
* Overlay boot-reconciliation verdicts onto session responses.
|
|
9433
|
+
*
|
|
9434
|
+
* A session left by a previous run is not in the in-memory store, so
|
|
9435
|
+
* SessionStore cannot classify it — it only ever sees what this run spawned.
|
|
9436
|
+
* Discovery may still surface the process, in which case the reconciler knows
|
|
9437
|
+
* strictly more about it than discovery does: it can tell `detached` (alive
|
|
9438
|
+
* and confirmed ours) from `orphaned` (alive but identity unconfirmed), which
|
|
9439
|
+
* a pid enumeration alone cannot.
|
|
9440
|
+
*
|
|
9441
|
+
* Only applied when the session is NOT live here: a session this run owns has
|
|
9442
|
+
* an authoritative lifecycle already, and a stale verdict must never override
|
|
9443
|
+
* it.
|
|
9444
|
+
*/
|
|
9445
|
+
withReconciledLifecycle(sessions) {
|
|
9446
|
+
if (this.sessionLifecycles.size === 0) return sessions;
|
|
9447
|
+
return sessions.map((s) => {
|
|
9448
|
+
if (s.ptyAttached) return s;
|
|
9449
|
+
const verdict = this.sessionLifecycles.get(s.id);
|
|
9450
|
+
if (!verdict) return s;
|
|
9451
|
+
return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
|
|
9452
|
+
});
|
|
9453
|
+
}
|
|
7425
9454
|
addSessionSubscriber(sessionId, ws) {
|
|
7426
9455
|
let subs = this.sessionSubscribers.get(sessionId);
|
|
7427
9456
|
if (!subs) {
|
|
@@ -7436,6 +9465,216 @@ var StreamerServer = class {
|
|
|
7436
9465
|
}
|
|
7437
9466
|
this.ptyGraceDeferCounts.delete(sessionId);
|
|
7438
9467
|
}
|
|
9468
|
+
/**
|
|
9469
|
+
* Bring up Live Activity push, if credentials are present (Feature 12).
|
|
9470
|
+
*
|
|
9471
|
+
* APNS_KEY absent is the ordinary case on a dev machine and in CI, so this
|
|
9472
|
+
* logs once at info and leaves the feature off rather than failing: the server
|
|
9473
|
+
* must not refuse to boot over a missing optional push credential.
|
|
9474
|
+
*
|
|
9475
|
+
* The key is read from the environment as PEM contents and never from a path
|
|
9476
|
+
* on disk; neither it nor any device token is ever logged.
|
|
9477
|
+
*/
|
|
9478
|
+
initLiveActivityPush(pushRepo) {
|
|
9479
|
+
const creds = readApnsCredentialsFromEnv();
|
|
9480
|
+
if (!creds) {
|
|
9481
|
+
const why = describeMissingApnsCredentials();
|
|
9482
|
+
if (why) this.log.info(why, { event: "live_activity.disabled" });
|
|
9483
|
+
return;
|
|
9484
|
+
}
|
|
9485
|
+
this.apnsClient = new ApnsClient(creds);
|
|
9486
|
+
const sender = new LiveActivitySender(this.apnsClient, pushRepo);
|
|
9487
|
+
const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os9.hostname)();
|
|
9488
|
+
this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os9.hostname)());
|
|
9489
|
+
this.liveActivityRenewal = new LiveActivityRenewalScheduler({
|
|
9490
|
+
repo: pushRepo,
|
|
9491
|
+
sender,
|
|
9492
|
+
sessionStore: this.sessionStore,
|
|
9493
|
+
serverId,
|
|
9494
|
+
serverLabel: (0, import_os9.hostname)()
|
|
9495
|
+
});
|
|
9496
|
+
this.liveActivityRenewal.start();
|
|
9497
|
+
this.log.info("Live Activity push enabled", {
|
|
9498
|
+
event: "live_activity.enabled",
|
|
9499
|
+
host: creds.host,
|
|
9500
|
+
topic: `${creds.bundleId}.push-type.liveactivity`
|
|
9501
|
+
});
|
|
9502
|
+
}
|
|
9503
|
+
/**
|
|
9504
|
+
* Classify sessions left behind by previous streamer runs (C1 Phase 3a).
|
|
9505
|
+
*
|
|
9506
|
+
* Agents already outlive the streamer today on the crash and dev-takeover
|
|
9507
|
+
* paths, which exit without reaching ptyManager.dispose() — they are just
|
|
9508
|
+
* invisible when they do, because nothing recorded that they existed. This
|
|
9509
|
+
* turns those rows into an explicit verdict per session.
|
|
9510
|
+
*
|
|
9511
|
+
* Read-only with respect to processes: it probes and classifies, and never
|
|
9512
|
+
* signals anything. `orphaned` is a report, not a cleanup trigger.
|
|
9513
|
+
*/
|
|
9514
|
+
async reconcilePreviousSessions() {
|
|
9515
|
+
if (!this.managedSessionsRepo) return [];
|
|
9516
|
+
let verdicts = [];
|
|
9517
|
+
try {
|
|
9518
|
+
const rows = this.managedSessionsRepo.listNonTerminal();
|
|
9519
|
+
if (rows.length === 0) return [];
|
|
9520
|
+
verdicts = await reconcileSessions(
|
|
9521
|
+
rows,
|
|
9522
|
+
{ isPidAlive, getProcessArgs },
|
|
9523
|
+
this.streamerInstanceId
|
|
9524
|
+
);
|
|
9525
|
+
for (const v of verdicts) {
|
|
9526
|
+
this.sessionLifecycles.set(v.sessionId, v.lifecycle);
|
|
9527
|
+
if (v.lifecycle === "completed" || v.lifecycle === "failed") {
|
|
9528
|
+
this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
|
|
9529
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
9530
|
+
});
|
|
9531
|
+
}
|
|
9532
|
+
}
|
|
9533
|
+
this.log.info(`[reconcile] classified ${verdicts.length} session(s) from previous runs`, {
|
|
9534
|
+
event: "sessions.reconciled",
|
|
9535
|
+
counts: verdicts.reduce((acc, v) => {
|
|
9536
|
+
acc[v.lifecycle] = (acc[v.lifecycle] ?? 0) + 1;
|
|
9537
|
+
return acc;
|
|
9538
|
+
}, {})
|
|
9539
|
+
});
|
|
9540
|
+
} catch (err) {
|
|
9541
|
+
this.log.warn("[reconcile] failed to reconcile previous sessions", {
|
|
9542
|
+
event: "sessions.reconcile_failed",
|
|
9543
|
+
err
|
|
9544
|
+
});
|
|
9545
|
+
}
|
|
9546
|
+
return verdicts;
|
|
9547
|
+
}
|
|
9548
|
+
/**
|
|
9549
|
+
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
9550
|
+
* reconciler's pid-reuse guard.
|
|
9551
|
+
*
|
|
9552
|
+
* Claude always passes the session id (`--resume <id>` or `--session-id
|
|
9553
|
+
* <id>`), so it is both present and unique. Codex only does on *resume*
|
|
9554
|
+
* (`codex resume <id>`); a fresh Codex spawn is `codex --cd <path>
|
|
9555
|
+
* --no-alt-screen` with no id at all, because the rollout id does not exist
|
|
9556
|
+
* until the CLI writes it. boundConversationId is what distinguishes the two:
|
|
9557
|
+
* it is set once that rollout has been discovered.
|
|
9558
|
+
*/
|
|
9559
|
+
spawnArgvToken(session) {
|
|
9560
|
+
if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
|
|
9561
|
+
return session.boundConversationId ?? session.projectPath;
|
|
9562
|
+
}
|
|
9563
|
+
/**
|
|
9564
|
+
* Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
|
|
9565
|
+
*
|
|
9566
|
+
* Called at each addManaged() site rather than inside SessionStore, because
|
|
9567
|
+
* the store is a pure in-memory structure with no DB dependency and adding
|
|
9568
|
+
* one would drag persistence into every unit test that touches it.
|
|
9569
|
+
*
|
|
9570
|
+
* Best-effort by design: a failed registry write must never break session
|
|
9571
|
+
* start. Losing a row costs post-restart *visibility* for that session, which
|
|
9572
|
+
* is strictly better than refusing to run the agent at all.
|
|
9573
|
+
*/
|
|
9574
|
+
recordSessionSpawn(session) {
|
|
9575
|
+
if (!this.managedSessionsRepo) return;
|
|
9576
|
+
try {
|
|
9577
|
+
const pid = this.ptyManager.getPid(session.id);
|
|
9578
|
+
this.managedSessionsRepo.recordSpawn({
|
|
9579
|
+
session,
|
|
9580
|
+
pid,
|
|
9581
|
+
// Identity guard against pid reuse: the reconciler requires this token
|
|
9582
|
+
// to appear in the live process's argv before it will claim the pid is
|
|
9583
|
+
// still ours (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
9584
|
+
//
|
|
9585
|
+
// Reading the real argv here would cost an async `ps` per session start
|
|
9586
|
+
// on a path the user is waiting on, so we record a token we already
|
|
9587
|
+
// know is in it. Claude always carries the session id (`--resume <id>`
|
|
9588
|
+
// on resume, `--session-id <id>` on fresh). A *fresh* Codex spawn does
|
|
9589
|
+
// not — its argv is only `--cd <path> --no-alt-screen`, because the
|
|
9590
|
+
// rollout id doesn't exist yet — so fall back to the project path,
|
|
9591
|
+
// which is present in every spawn path for both providers.
|
|
9592
|
+
//
|
|
9593
|
+
// The fallback is weaker: two sessions in one project share a token, so
|
|
9594
|
+
// it proves "a process of ours in this project" rather than "this exact
|
|
9595
|
+
// session". It still rejects an unrelated recycled pid, which is the
|
|
9596
|
+
// failure being guarded against.
|
|
9597
|
+
//
|
|
9598
|
+
// Note the Codex id is always *set* (a local placeholder) — it is just
|
|
9599
|
+
// not in the process's argv — so the choice keys off the provider, not
|
|
9600
|
+
// off the id being null.
|
|
9601
|
+
cmdline: pid != null ? this.spawnArgvToken(session) : null,
|
|
9602
|
+
streamerInstanceId: this.streamerInstanceId
|
|
9603
|
+
});
|
|
9604
|
+
} catch (err) {
|
|
9605
|
+
this.log.warn("[registry] failed to record session spawn", {
|
|
9606
|
+
event: "registry.spawn_write_failed",
|
|
9607
|
+
sessionId: session.id,
|
|
9608
|
+
err
|
|
9609
|
+
});
|
|
9610
|
+
}
|
|
9611
|
+
}
|
|
9612
|
+
/**
|
|
9613
|
+
* Stamp every live session as ended-by-shutdown before dispose() kills it.
|
|
9614
|
+
*
|
|
9615
|
+
* PTYManager.dispose() signals each child directly and fires no
|
|
9616
|
+
* onStatusChange, so the registry would otherwise keep rows sitting at
|
|
9617
|
+
* `running` forever and the next boot could not tell a deliberate restart
|
|
9618
|
+
* from a crash. Recording `shutdown` as the status source makes that
|
|
9619
|
+
* distinction explicit rather than inferred.
|
|
9620
|
+
*
|
|
9621
|
+
* Not a `completed_at` write for the agent's own work — the agent did not
|
|
9622
|
+
* finish, we stopped it — but the session is genuinely terminal, so it must
|
|
9623
|
+
* leave the reconciler's probe set.
|
|
9624
|
+
*/
|
|
9625
|
+
recordShutdownState() {
|
|
9626
|
+
if (!this.managedSessionsRepo) return;
|
|
9627
|
+
const now = /* @__PURE__ */ new Date();
|
|
9628
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
9629
|
+
try {
|
|
9630
|
+
this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
|
|
9631
|
+
completedAt: now,
|
|
9632
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
9633
|
+
promptCount: session.promptCount
|
|
9634
|
+
});
|
|
9635
|
+
} catch (err) {
|
|
9636
|
+
this.log.warn("[registry] failed to record shutdown state", {
|
|
9637
|
+
event: "registry.shutdown_write_failed",
|
|
9638
|
+
sessionId: session.id,
|
|
9639
|
+
err
|
|
9640
|
+
});
|
|
9641
|
+
}
|
|
9642
|
+
}
|
|
9643
|
+
}
|
|
9644
|
+
/**
|
|
9645
|
+
* Release PTYs whose agent has been silent past IDLE_REAP_AFTER_MS.
|
|
9646
|
+
*
|
|
9647
|
+
* This is the bound that lets handleWsClose stop arming kill timers. The
|
|
9648
|
+
* distinction that matters: the old timer measured how long nobody was
|
|
9649
|
+
* *watching*, which is uncorrelated with whether work is in flight. This
|
|
9650
|
+
* measures how long the *agent* has produced nothing, and only ever considers
|
|
9651
|
+
* sessions that are already settled — a `running` PTY is skipped regardless of
|
|
9652
|
+
* age, so a long silent turn is never interrupted.
|
|
9653
|
+
*
|
|
9654
|
+
* Exposed (not private) so tests can drive one sweep deterministically instead
|
|
9655
|
+
* of waiting on the interval.
|
|
9656
|
+
*/
|
|
9657
|
+
reapIdleSessions(now = Date.now()) {
|
|
9658
|
+
const reaped = [];
|
|
9659
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
9660
|
+
if (session.status === "running") continue;
|
|
9661
|
+
const lastActive = this.lastAgentChunkAt.get(session.id) ?? session.lastActivityAt?.getTime() ?? session.startedAt.getTime();
|
|
9662
|
+
if (now - lastActive < IDLE_REAP_AFTER_MS) continue;
|
|
9663
|
+
this.log.info(
|
|
9664
|
+
`[reap] releasing idle PTY for ${session.id} (idle ${Math.round((now - lastActive) / 6e4)}m)`,
|
|
9665
|
+
{ sessionId: session.id, event: "pty.idle_reap", idleMs: now - lastActive },
|
|
9666
|
+
"pino"
|
|
9667
|
+
);
|
|
9668
|
+
this.ptyManager.putOnHold(session.id);
|
|
9669
|
+
this.lastAgentChunkAt.delete(session.id);
|
|
9670
|
+
this.idempotency.clear(session.id);
|
|
9671
|
+
this.sessionSubscribers.delete(session.id);
|
|
9672
|
+
reaped.push(session.id);
|
|
9673
|
+
const held = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
9674
|
+
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
9675
|
+
}
|
|
9676
|
+
return reaped;
|
|
9677
|
+
}
|
|
7439
9678
|
startGraceTimer(sessionId, delayMs) {
|
|
7440
9679
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
7441
9680
|
if (existing) clearTimeout(existing);
|
|
@@ -7530,6 +9769,8 @@ var StreamerServer = class {
|
|
|
7530
9769
|
this.log.info("Database migrations applied", { event: "db.migrations_applied" });
|
|
7531
9770
|
}
|
|
7532
9771
|
await this.bindWithRetry(port);
|
|
9772
|
+
this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
|
|
9773
|
+
this.idleReaperTimer.unref?.();
|
|
7533
9774
|
const warmUp = new Promise((resolveWarm) => {
|
|
7534
9775
|
{
|
|
7535
9776
|
this.log.info(`Streamer server listening on port ${port}`, {
|
|
@@ -7563,7 +9804,12 @@ var StreamerServer = class {
|
|
|
7563
9804
|
this.projectsRepo = new ProjectsRepository(db);
|
|
7564
9805
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
7565
9806
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
9807
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(db);
|
|
9808
|
+
void this.reconcilePreviousSessions();
|
|
7566
9809
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
9810
|
+
this.pushRepo = new PushRepository(db);
|
|
9811
|
+
this.devicesRepo = new DevicesRepository(db);
|
|
9812
|
+
this.initLiveActivityPush(this.pushRepo);
|
|
7567
9813
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7568
9814
|
this.cache,
|
|
7569
9815
|
this.wsHub,
|
|
@@ -7781,6 +10027,12 @@ var StreamerServer = class {
|
|
|
7781
10027
|
async close() {
|
|
7782
10028
|
for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
|
|
7783
10029
|
this.ptyGraceTimers.clear();
|
|
10030
|
+
if (this.idleReaperTimer) {
|
|
10031
|
+
clearInterval(this.idleReaperTimer);
|
|
10032
|
+
this.idleReaperTimer = null;
|
|
10033
|
+
}
|
|
10034
|
+
this.lastAgentChunkAt.clear();
|
|
10035
|
+
this.recordShutdownState();
|
|
7784
10036
|
this.markScannerStaleDebounced.cancel();
|
|
7785
10037
|
await Promise.all([...this.inFlightCacheWrites]);
|
|
7786
10038
|
await Promise.all([...this.allScanners].map((s) => s.close()));
|
|
@@ -7792,6 +10044,8 @@ var StreamerServer = class {
|
|
|
7792
10044
|
this.externalTails.clear();
|
|
7793
10045
|
this.wsHub.dispose();
|
|
7794
10046
|
this.pairTokens.dispose();
|
|
10047
|
+
this.liveActivityRenewal?.stop();
|
|
10048
|
+
this.apnsClient?.close();
|
|
7795
10049
|
if (this.dbPool) {
|
|
7796
10050
|
await this.dbPool.end();
|
|
7797
10051
|
}
|
|
@@ -7863,19 +10117,34 @@ var StreamerServer = class {
|
|
|
7863
10117
|
json(res, 400, { error: message });
|
|
7864
10118
|
return;
|
|
7865
10119
|
}
|
|
7866
|
-
const { hostname: hostname2 } = require("os");
|
|
7867
10120
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
7868
10121
|
this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
|
|
7869
10122
|
event: "pair.token_exchanged",
|
|
7870
10123
|
ip,
|
|
7871
10124
|
ts
|
|
7872
10125
|
});
|
|
10126
|
+
let device = null;
|
|
10127
|
+
try {
|
|
10128
|
+
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
10129
|
+
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
10130
|
+
device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
|
|
10131
|
+
} catch (err) {
|
|
10132
|
+
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
10133
|
+
event: "pair.device_register_failed",
|
|
10134
|
+
err
|
|
10135
|
+
});
|
|
10136
|
+
}
|
|
7873
10137
|
json(res, 200, {
|
|
7874
10138
|
ciphertext: sealed.ciphertext,
|
|
7875
10139
|
nonce: sealed.nonce,
|
|
7876
10140
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
7877
10141
|
publicUrl: this.publicUrl,
|
|
7878
|
-
machineName:
|
|
10142
|
+
machineName: (0, import_os9.hostname)(),
|
|
10143
|
+
...device && {
|
|
10144
|
+
deviceId: device.deviceId,
|
|
10145
|
+
deviceToken: device.deviceToken,
|
|
10146
|
+
capabilities: device.capabilities
|
|
10147
|
+
}
|
|
7879
10148
|
});
|
|
7880
10149
|
}
|
|
7881
10150
|
rotateApiKey() {
|
|
@@ -7892,6 +10161,58 @@ var StreamerServer = class {
|
|
|
7892
10161
|
});
|
|
7893
10162
|
return { newKey, persisted };
|
|
7894
10163
|
}
|
|
10164
|
+
/**
|
|
10165
|
+
* The registry ships with the values so a client renders the list from one
|
|
10166
|
+
* round-trip, same as getClaudeFlagsConfig().
|
|
10167
|
+
*
|
|
10168
|
+
* Deliberately no `persisted` field: unlike claude-flags there is no PUT, and
|
|
10169
|
+
* the absence of that field is the signal that this endpoint is read-only.
|
|
10170
|
+
*/
|
|
10171
|
+
getFeatureFlagsConfig() {
|
|
10172
|
+
return { registry: FEATURE_FLAGS, values: this.featureFlags };
|
|
10173
|
+
}
|
|
10174
|
+
getClaudeFlagsConfig() {
|
|
10175
|
+
return {
|
|
10176
|
+
registry: CLAUDE_FLAGS,
|
|
10177
|
+
values: this.claudeFlags,
|
|
10178
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
10179
|
+
persisted: this.claudeFlagsPersistable
|
|
10180
|
+
};
|
|
10181
|
+
}
|
|
10182
|
+
/**
|
|
10183
|
+
* Replace the per-server flag set. Applies to the NEXT spawn — a live PTY
|
|
10184
|
+
* keeps the argv it was started with.
|
|
10185
|
+
*
|
|
10186
|
+
* Mirrors rotateApiKey(): when the values were pinned by a CLI flag we still
|
|
10187
|
+
* apply them in memory but skip the server.yaml write, because the flag would
|
|
10188
|
+
* win again on restart and silently revert them.
|
|
10189
|
+
*
|
|
10190
|
+
* Logged with old→new at info level on purpose: this can disable the
|
|
10191
|
+
* permission prompts entirely, so it needs a forensic trail.
|
|
10192
|
+
*/
|
|
10193
|
+
setClaudeFlagsConfig(values, extraArgs) {
|
|
10194
|
+
const safe = validateFlagValues(values);
|
|
10195
|
+
const previous = { values: this.claudeFlags, extraArgs: this.claudeExtraArgs };
|
|
10196
|
+
if (this.claudeFlagsPersistable) {
|
|
10197
|
+
setClaudeExtraArgs(extraArgs);
|
|
10198
|
+
setClaudeFlags(safe);
|
|
10199
|
+
}
|
|
10200
|
+
this.claudeFlags = safe;
|
|
10201
|
+
this.claudeExtraArgs = extraArgs?.trim() ? extraArgs.trim() : void 0;
|
|
10202
|
+
this.log.info("Claude CLI flags updated", {
|
|
10203
|
+
event: "config.claude_flags_updated",
|
|
10204
|
+
persisted: this.claudeFlagsPersistable,
|
|
10205
|
+
previousValues: previous.values,
|
|
10206
|
+
previousExtraArgs: previous.extraArgs ?? null,
|
|
10207
|
+
values: this.claudeFlags,
|
|
10208
|
+
extraArgs: this.claudeExtraArgs ?? null
|
|
10209
|
+
});
|
|
10210
|
+
return {
|
|
10211
|
+
values: this.claudeFlags,
|
|
10212
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
10213
|
+
persisted: this.claudeFlagsPersistable
|
|
10214
|
+
};
|
|
10215
|
+
}
|
|
7895
10216
|
checkRateLimit(map, key, limit, windowMs) {
|
|
7896
10217
|
const now = Date.now();
|
|
7897
10218
|
const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
@@ -8909,7 +11230,13 @@ var StreamerServer = class {
|
|
|
8909
11230
|
}
|
|
8910
11231
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
8911
11232
|
if (!hasPaginationParams) {
|
|
8912
|
-
json(
|
|
11233
|
+
json(
|
|
11234
|
+
res,
|
|
11235
|
+
200,
|
|
11236
|
+
this.withExternalActivity(
|
|
11237
|
+
this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
11238
|
+
)
|
|
11239
|
+
);
|
|
8913
11240
|
return;
|
|
8914
11241
|
}
|
|
8915
11242
|
const parsed = parseSessionListQuery(url);
|
|
@@ -8919,7 +11246,7 @@ var StreamerServer = class {
|
|
|
8919
11246
|
}
|
|
8920
11247
|
try {
|
|
8921
11248
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8922
|
-
page.sessions = this.withExternalActivity(page.sessions);
|
|
11249
|
+
page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
|
|
8923
11250
|
json(res, 200, page);
|
|
8924
11251
|
} catch (err) {
|
|
8925
11252
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -8936,6 +11263,9 @@ var StreamerServer = class {
|
|
|
8936
11263
|
if (!(0, import_fs18.existsSync)(session.projectPath)) {
|
|
8937
11264
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
8938
11265
|
}
|
|
11266
|
+
const reconciled = this.withReconciledLifecycle([session])[0];
|
|
11267
|
+
session.lifecycle = reconciled.lifecycle;
|
|
11268
|
+
session.lifecycleSource = reconciled.lifecycleSource;
|
|
8939
11269
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
8940
11270
|
try {
|
|
8941
11271
|
const lines = await this.ptyManager.getOutputLines(sessionId, 10);
|
|
@@ -9033,10 +11363,13 @@ var StreamerServer = class {
|
|
|
9033
11363
|
projectName: body.projectName,
|
|
9034
11364
|
branch: body.branch,
|
|
9035
11365
|
permissionMode: this.defaultPermissionMode,
|
|
11366
|
+
claudeFlags: this.claudeFlags,
|
|
11367
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9036
11368
|
model: this.defaultModel,
|
|
9037
11369
|
effort: this.defaultEffort
|
|
9038
11370
|
});
|
|
9039
11371
|
this.sessionStore.addManaged(session);
|
|
11372
|
+
this.recordSessionSpawn(session);
|
|
9040
11373
|
void this.watchConversationFile(sessionId);
|
|
9041
11374
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
9042
11375
|
this.broadcastOrUnicastSessionList(req);
|
|
@@ -9109,6 +11442,24 @@ var StreamerServer = class {
|
|
|
9109
11442
|
}
|
|
9110
11443
|
const body = await readBody(req);
|
|
9111
11444
|
const { input, keys } = body;
|
|
11445
|
+
let idempotencyKey;
|
|
11446
|
+
try {
|
|
11447
|
+
idempotencyKey = readIdempotencyKey(body);
|
|
11448
|
+
} catch (err) {
|
|
11449
|
+
json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
|
|
11450
|
+
return;
|
|
11451
|
+
}
|
|
11452
|
+
if (idempotencyKey) {
|
|
11453
|
+
const replayed = this.idempotency.get(sessionId, idempotencyKey);
|
|
11454
|
+
if (replayed) {
|
|
11455
|
+
this.log.info(`[input.replay] ${sessionId.slice(0, 8)} duplicate idempotencyKey`, {
|
|
11456
|
+
event: "input.idempotent_replay",
|
|
11457
|
+
sessionId
|
|
11458
|
+
});
|
|
11459
|
+
json(res, replayed.status, replayed.body);
|
|
11460
|
+
return;
|
|
11461
|
+
}
|
|
11462
|
+
}
|
|
9112
11463
|
if (typeof keys === "string") {
|
|
9113
11464
|
try {
|
|
9114
11465
|
this.ptyManager.sendKeys(sessionId, keys);
|
|
@@ -9116,7 +11467,9 @@ var StreamerServer = class {
|
|
|
9116
11467
|
if (updated) {
|
|
9117
11468
|
this.wsHub.broadcast({ type: "session_update", session: updated });
|
|
9118
11469
|
}
|
|
9119
|
-
|
|
11470
|
+
const result = { status: 200, body: { ok: true } };
|
|
11471
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
11472
|
+
json(res, result.status, result.body);
|
|
9120
11473
|
} catch (err) {
|
|
9121
11474
|
const message = err instanceof Error ? err.message : "Failed to send keys";
|
|
9122
11475
|
json(res, 400, { error: message });
|
|
@@ -9154,7 +11507,9 @@ var StreamerServer = class {
|
|
|
9154
11507
|
});
|
|
9155
11508
|
});
|
|
9156
11509
|
}
|
|
9157
|
-
|
|
11510
|
+
const result = { status: 200, body: { ok: true } };
|
|
11511
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
11512
|
+
json(res, result.status, result.body);
|
|
9158
11513
|
} catch (err) {
|
|
9159
11514
|
const message = err instanceof Error ? err.message : "Failed to send input";
|
|
9160
11515
|
json(res, 400, { error: message });
|
|
@@ -9459,10 +11814,13 @@ var StreamerServer = class {
|
|
|
9459
11814
|
projectName,
|
|
9460
11815
|
branch,
|
|
9461
11816
|
permissionMode: this.defaultPermissionMode,
|
|
11817
|
+
claudeFlags: this.claudeFlags,
|
|
11818
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9462
11819
|
model: this.defaultModel,
|
|
9463
11820
|
effort: this.defaultEffort
|
|
9464
11821
|
});
|
|
9465
11822
|
this.sessionStore.addManaged(session);
|
|
11823
|
+
this.recordSessionSpawn(session);
|
|
9466
11824
|
void this.watchConversationFile(session.id);
|
|
9467
11825
|
this.wsHub.broadcast({
|
|
9468
11826
|
type: "session_list",
|
|
@@ -9525,17 +11883,21 @@ var StreamerServer = class {
|
|
|
9525
11883
|
BROWSE_SYSTEM_PROMPT(this.browseRoot),
|
|
9526
11884
|
typeof clientPrompt === "string" ? clientPrompt : null
|
|
9527
11885
|
].filter(Boolean);
|
|
11886
|
+
const includeSystemPrompt = provider !== CODEX_CLI_PROVIDER || this.codexSystemPromptEnabled;
|
|
9528
11887
|
try {
|
|
9529
11888
|
const session = await this.ptyManager.startFresh({
|
|
9530
11889
|
provider,
|
|
9531
11890
|
projectPath: resolvedPath,
|
|
9532
11891
|
projectName: body.projectName,
|
|
9533
|
-
systemPrompt: systemPromptParts.join("\n"),
|
|
11892
|
+
...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
|
|
9534
11893
|
permissionMode: this.defaultPermissionMode,
|
|
11894
|
+
claudeFlags: this.claudeFlags,
|
|
11895
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9535
11896
|
model: this.defaultModel,
|
|
9536
11897
|
effort: this.defaultEffort
|
|
9537
11898
|
});
|
|
9538
11899
|
this.sessionStore.addManaged(session);
|
|
11900
|
+
this.recordSessionSpawn(session);
|
|
9539
11901
|
const readyOrFailed = new Promise((resolve2) => {
|
|
9540
11902
|
const handler = (status) => {
|
|
9541
11903
|
if (status === "waiting_input" || status === "idle") {
|
|
@@ -10057,6 +12419,7 @@ function readBody(req) {
|
|
|
10057
12419
|
SessionStore,
|
|
10058
12420
|
StreamerServer,
|
|
10059
12421
|
WSHub,
|
|
12422
|
+
confidenceForSource,
|
|
10060
12423
|
createAgentClient,
|
|
10061
12424
|
createConversationWriter,
|
|
10062
12425
|
createPool,
|