@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.js
CHANGED
|
@@ -278,6 +278,217 @@ import { randomBytes, timingSafeEqual } from "crypto";
|
|
|
278
278
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
279
279
|
import { homedir } from "os";
|
|
280
280
|
import { join as join2 } from "path";
|
|
281
|
+
|
|
282
|
+
// src/claude-flags.ts
|
|
283
|
+
var PERMISSION_MODES = [
|
|
284
|
+
"acceptEdits",
|
|
285
|
+
"auto",
|
|
286
|
+
"bypassPermissions",
|
|
287
|
+
"manual",
|
|
288
|
+
"dontAsk",
|
|
289
|
+
"plan"
|
|
290
|
+
];
|
|
291
|
+
function isPermissionMode(value) {
|
|
292
|
+
return typeof value === "string" && PERMISSION_MODES.includes(value);
|
|
293
|
+
}
|
|
294
|
+
var DANGEROUS_PERMISSION_MODES = [
|
|
295
|
+
"bypassPermissions",
|
|
296
|
+
"dontAsk"
|
|
297
|
+
];
|
|
298
|
+
function isDangerousPermissionMode(mode) {
|
|
299
|
+
return DANGEROUS_PERMISSION_MODES.includes(mode);
|
|
300
|
+
}
|
|
301
|
+
var CLAUDE_FLAGS = [
|
|
302
|
+
{
|
|
303
|
+
id: "permissionMode",
|
|
304
|
+
flag: "--permission-mode",
|
|
305
|
+
valueType: "enum",
|
|
306
|
+
enumValues: PERMISSION_MODES,
|
|
307
|
+
risk: "low"
|
|
308
|
+
},
|
|
309
|
+
{ id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
|
|
310
|
+
{ id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
|
|
311
|
+
{ id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
|
|
312
|
+
{ id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
|
|
313
|
+
{ id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
|
|
314
|
+
];
|
|
315
|
+
function findFlag(id) {
|
|
316
|
+
return CLAUDE_FLAGS.find((f) => f.id === id);
|
|
317
|
+
}
|
|
318
|
+
function validateFlagValues(raw) {
|
|
319
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
320
|
+
const out = {};
|
|
321
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
322
|
+
const def = findFlag(id);
|
|
323
|
+
if (!def) continue;
|
|
324
|
+
switch (def.valueType) {
|
|
325
|
+
case "boolean":
|
|
326
|
+
if (typeof value === "boolean") out[id] = value;
|
|
327
|
+
break;
|
|
328
|
+
case "enum":
|
|
329
|
+
if (typeof value === "string" && def.enumValues?.includes(value)) out[id] = value;
|
|
330
|
+
break;
|
|
331
|
+
case "string":
|
|
332
|
+
if (typeof value === "string" && value.trim().length > 0) out[id] = value.trim();
|
|
333
|
+
break;
|
|
334
|
+
case "list": {
|
|
335
|
+
if (!Array.isArray(value)) break;
|
|
336
|
+
const items = value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
|
|
337
|
+
if (items.length > 0) out[id] = items;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
function tokenizeExtraArgs(input) {
|
|
345
|
+
if (!input) return [];
|
|
346
|
+
const tokens = [];
|
|
347
|
+
let current = "";
|
|
348
|
+
let quote = null;
|
|
349
|
+
let started = false;
|
|
350
|
+
for (const ch of input) {
|
|
351
|
+
if (quote) {
|
|
352
|
+
if (ch === quote) quote = null;
|
|
353
|
+
else current += ch;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (ch === '"' || ch === "'") {
|
|
357
|
+
quote = ch;
|
|
358
|
+
started = true;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (/\s/.test(ch)) {
|
|
362
|
+
if (started) {
|
|
363
|
+
tokens.push(current);
|
|
364
|
+
current = "";
|
|
365
|
+
started = false;
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
current += ch;
|
|
370
|
+
started = true;
|
|
371
|
+
}
|
|
372
|
+
if (started) tokens.push(current);
|
|
373
|
+
return tokens;
|
|
374
|
+
}
|
|
375
|
+
function buildFlagArgs(values, extraArgs) {
|
|
376
|
+
const args = [];
|
|
377
|
+
const safe = validateFlagValues(values ?? {});
|
|
378
|
+
for (const def of CLAUDE_FLAGS) {
|
|
379
|
+
if (def.id === "permissionMode") continue;
|
|
380
|
+
const value = safe[def.id];
|
|
381
|
+
if (value === void 0) continue;
|
|
382
|
+
if (def.valueType === "boolean") {
|
|
383
|
+
if (value === true) args.push(def.flag);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (Array.isArray(value)) {
|
|
387
|
+
args.push(def.flag, ...value);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
args.push(def.flag, String(value));
|
|
391
|
+
}
|
|
392
|
+
args.push(...tokenizeExtraArgs(extraArgs));
|
|
393
|
+
return args;
|
|
394
|
+
}
|
|
395
|
+
function buildSettingsJson(permissionMode) {
|
|
396
|
+
const settings = { spinnerTipsEnabled: false };
|
|
397
|
+
if (isDangerousPermissionMode(permissionMode)) {
|
|
398
|
+
settings.skipDangerousModePermissionPrompt = true;
|
|
399
|
+
}
|
|
400
|
+
return JSON.stringify(settings);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/logger.ts
|
|
404
|
+
import pino from "pino";
|
|
405
|
+
var baseLogger = pino({
|
|
406
|
+
level: process.env.LOG_LEVEL ?? "info",
|
|
407
|
+
base: { service: "tb-streamer" },
|
|
408
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
409
|
+
redact: {
|
|
410
|
+
paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
|
|
411
|
+
censor: "[redacted]"
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
function emit(pinoChild, level, msg, fields, dest) {
|
|
415
|
+
if (dest === "pino" || dest === "both") {
|
|
416
|
+
if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
|
|
417
|
+
else pinoChild[level](msg);
|
|
418
|
+
}
|
|
419
|
+
if (dest === "console" || dest === "both") {
|
|
420
|
+
const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
|
|
421
|
+
console[consoleMethod](msg);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function build(pinoChild) {
|
|
425
|
+
return {
|
|
426
|
+
debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
|
|
427
|
+
info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
|
|
428
|
+
warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
|
|
429
|
+
error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
|
|
430
|
+
log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
|
|
431
|
+
pino: pinoChild
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function getLogger(component) {
|
|
435
|
+
return build(component ? baseLogger.child({ component }) : baseLogger);
|
|
436
|
+
}
|
|
437
|
+
var logger = build(baseLogger);
|
|
438
|
+
|
|
439
|
+
// src/feature-flags.ts
|
|
440
|
+
var FEATURE_FLAGS = [
|
|
441
|
+
{
|
|
442
|
+
id: "codexSystemPrompt",
|
|
443
|
+
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.",
|
|
444
|
+
default: false,
|
|
445
|
+
env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
|
|
446
|
+
}
|
|
447
|
+
];
|
|
448
|
+
function findFeatureFlag(id) {
|
|
449
|
+
return FEATURE_FLAGS.find((f) => f.id === id);
|
|
450
|
+
}
|
|
451
|
+
function parseBooleanEnv(raw) {
|
|
452
|
+
if (raw === void 0) return void 0;
|
|
453
|
+
const v = raw.trim().toLowerCase();
|
|
454
|
+
if (v === "") return false;
|
|
455
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
456
|
+
}
|
|
457
|
+
function validateFeatureFlagValues(raw) {
|
|
458
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
459
|
+
const out = {};
|
|
460
|
+
const dropped = [];
|
|
461
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
462
|
+
if (!findFeatureFlag(id) || typeof value !== "boolean") {
|
|
463
|
+
dropped.push(id);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
out[id] = value;
|
|
467
|
+
}
|
|
468
|
+
if (dropped.length > 0) {
|
|
469
|
+
getLogger("feature-flags").warn(
|
|
470
|
+
`Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
|
|
471
|
+
{
|
|
472
|
+
event: "config.feature_flags_dropped",
|
|
473
|
+
dropped
|
|
474
|
+
}
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
function resolveFeatureFlags(opts) {
|
|
480
|
+
const env = opts?.env ?? process.env;
|
|
481
|
+
const out = {};
|
|
482
|
+
for (const def of FEATURE_FLAGS) {
|
|
483
|
+
out[def.id] = parseBooleanEnv(env[def.env]) ?? opts?.cli?.[def.id] ?? opts?.yaml?.[def.id] ?? def.default;
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
function nonDefaultFeatureFlags(values) {
|
|
488
|
+
return FEATURE_FLAGS.filter((f) => values[f.id] !== f.default).map((f) => f.id);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/auth.ts
|
|
281
492
|
function configDir() {
|
|
282
493
|
return process.env.THREADBASE_CONFIG_DIR ?? join2(homedir(), ".threadbase");
|
|
283
494
|
}
|
|
@@ -356,11 +567,94 @@ function loadDefaultPermissionMode() {
|
|
|
356
567
|
const content = readFileSync(configFile(), "utf-8");
|
|
357
568
|
const match = content.match(/default_permission_mode:\s*(\S+)/);
|
|
358
569
|
const value = match?.[1]?.trim();
|
|
359
|
-
if (value
|
|
570
|
+
if (isPermissionMode(value)) return value;
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
return void 0;
|
|
574
|
+
}
|
|
575
|
+
function setConfigValue(key, value) {
|
|
576
|
+
const file = configFile();
|
|
577
|
+
mkdirSync(configDir(), { recursive: true });
|
|
578
|
+
let content = "";
|
|
579
|
+
try {
|
|
580
|
+
content = readFileSync(file, "utf-8");
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (err.code !== "ENOENT") throw err;
|
|
583
|
+
}
|
|
584
|
+
const lineRe = new RegExp(`^${key}:\\s*.*$\\n?`, "m");
|
|
585
|
+
let updated;
|
|
586
|
+
if (value === void 0) {
|
|
587
|
+
updated = content.replace(lineRe, "");
|
|
588
|
+
} else {
|
|
589
|
+
const line = `${key}: ${value}`;
|
|
590
|
+
if (lineRe.test(content)) {
|
|
591
|
+
updated = content.replace(lineRe, `${line}
|
|
592
|
+
`);
|
|
593
|
+
} else if (content.length === 0 || content.endsWith("\n")) {
|
|
594
|
+
updated = `${content}${line}
|
|
595
|
+
`;
|
|
596
|
+
} else {
|
|
597
|
+
updated = `${content}
|
|
598
|
+
${line}
|
|
599
|
+
`;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const tmpFile = `${file}.tmp`;
|
|
603
|
+
writeFileSync(tmpFile, updated, { encoding: "utf-8", mode: 384 });
|
|
604
|
+
chmodSync(tmpFile, 384);
|
|
605
|
+
renameSync(tmpFile, file);
|
|
606
|
+
}
|
|
607
|
+
function loadClaudeFlags() {
|
|
608
|
+
try {
|
|
609
|
+
const content = readFileSync(configFile(), "utf-8");
|
|
610
|
+
const match = content.match(/^claude_flags:\s*(.+)$/m);
|
|
611
|
+
if (!match?.[1]) return {};
|
|
612
|
+
return validateFlagValues(JSON.parse(match[1].trim()));
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (err.code !== "ENOENT") {
|
|
615
|
+
getLogger("auth").warn(`Ignoring unreadable claude_flags in server.yaml: ${String(err)}`, {
|
|
616
|
+
event: "config.claude_flags_parse_failed"
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return {};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function setClaudeFlags(values) {
|
|
623
|
+
const safe = validateFlagValues(values);
|
|
624
|
+
setConfigValue("claude_flags", Object.keys(safe).length === 0 ? void 0 : JSON.stringify(safe));
|
|
625
|
+
}
|
|
626
|
+
function loadClaudeExtraArgs() {
|
|
627
|
+
try {
|
|
628
|
+
const content = readFileSync(configFile(), "utf-8");
|
|
629
|
+
const match = content.match(/^claude_extra_args:\s*(.+)$/m);
|
|
630
|
+
const value = match?.[1]?.trim();
|
|
631
|
+
return value && value.length > 0 ? value : void 0;
|
|
360
632
|
} catch {
|
|
361
633
|
}
|
|
362
634
|
return void 0;
|
|
363
635
|
}
|
|
636
|
+
function setClaudeExtraArgs(text) {
|
|
637
|
+
const trimmed = text?.trim();
|
|
638
|
+
if (trimmed && /[\r\n]/.test(trimmed)) {
|
|
639
|
+
throw new Error("claude_extra_args must not contain newlines");
|
|
640
|
+
}
|
|
641
|
+
setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
|
|
642
|
+
}
|
|
643
|
+
function loadFeatureFlags() {
|
|
644
|
+
try {
|
|
645
|
+
const content = readFileSync(configFile(), "utf-8");
|
|
646
|
+
const match = content.match(/^feature_flags:\s*(.+)$/m);
|
|
647
|
+
if (!match?.[1]) return {};
|
|
648
|
+
return validateFeatureFlagValues(JSON.parse(match[1].trim()));
|
|
649
|
+
} catch (err) {
|
|
650
|
+
if (err.code !== "ENOENT") {
|
|
651
|
+
getLogger("auth").warn(`Ignoring unreadable feature_flags in server.yaml: ${String(err)}`, {
|
|
652
|
+
event: "config.feature_flags_parse_failed"
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return {};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
364
658
|
function validatePublicUrl(raw) {
|
|
365
659
|
let parsed;
|
|
366
660
|
try {
|
|
@@ -508,42 +802,6 @@ import { randomUUID } from "crypto";
|
|
|
508
802
|
import { existsSync as existsSync2 } from "fs";
|
|
509
803
|
import { basename } from "path";
|
|
510
804
|
|
|
511
|
-
// src/logger.ts
|
|
512
|
-
import pino from "pino";
|
|
513
|
-
var baseLogger = pino({
|
|
514
|
-
level: process.env.LOG_LEVEL ?? "info",
|
|
515
|
-
base: { service: "tb-streamer" },
|
|
516
|
-
timestamp: pino.stdTimeFunctions.isoTime,
|
|
517
|
-
redact: {
|
|
518
|
-
paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
|
|
519
|
-
censor: "[redacted]"
|
|
520
|
-
}
|
|
521
|
-
});
|
|
522
|
-
function emit(pinoChild, level, msg, fields, dest) {
|
|
523
|
-
if (dest === "pino" || dest === "both") {
|
|
524
|
-
if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
|
|
525
|
-
else pinoChild[level](msg);
|
|
526
|
-
}
|
|
527
|
-
if (dest === "console" || dest === "both") {
|
|
528
|
-
const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
|
|
529
|
-
console[consoleMethod](msg);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
function build(pinoChild) {
|
|
533
|
-
return {
|
|
534
|
-
debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
|
|
535
|
-
info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
|
|
536
|
-
warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
|
|
537
|
-
error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
|
|
538
|
-
log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
|
|
539
|
-
pino: pinoChild
|
|
540
|
-
};
|
|
541
|
-
}
|
|
542
|
-
function getLogger(component) {
|
|
543
|
-
return build(component ? baseLogger.child({ component }) : baseLogger);
|
|
544
|
-
}
|
|
545
|
-
var logger = build(baseLogger);
|
|
546
|
-
|
|
547
805
|
// src/platform.ts
|
|
548
806
|
import { execFileSync } from "child_process";
|
|
549
807
|
import { existsSync } from "fs";
|
|
@@ -895,6 +1153,8 @@ var CodexPtyRunner = class {
|
|
|
895
1153
|
projectName,
|
|
896
1154
|
branch: options.branch ?? "",
|
|
897
1155
|
status: "running",
|
|
1156
|
+
statusSource: "spawn",
|
|
1157
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
898
1158
|
startedAt: /* @__PURE__ */ new Date(),
|
|
899
1159
|
completedAt: null,
|
|
900
1160
|
promptCount: 0,
|
|
@@ -942,6 +1202,8 @@ var CodexPtyRunner = class {
|
|
|
942
1202
|
projectName,
|
|
943
1203
|
branch: "",
|
|
944
1204
|
status: "running",
|
|
1205
|
+
statusSource: "spawn",
|
|
1206
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
945
1207
|
startedAt: /* @__PURE__ */ new Date(),
|
|
946
1208
|
completedAt: null,
|
|
947
1209
|
promptCount: 0,
|
|
@@ -972,7 +1234,7 @@ var CodexPtyRunner = class {
|
|
|
972
1234
|
this.readyFallbackTimers.delete(sessionId);
|
|
973
1235
|
const session = this.sessions.get(sessionId);
|
|
974
1236
|
if (session?.status === "running" && this.pendingReady.has(sessionId)) {
|
|
975
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
1237
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
976
1238
|
}
|
|
977
1239
|
}, CODEX_READY_FALLBACK_MS);
|
|
978
1240
|
timer.unref?.();
|
|
@@ -987,6 +1249,8 @@ var CodexPtyRunner = class {
|
|
|
987
1249
|
}
|
|
988
1250
|
if (session.status === "waiting_input") {
|
|
989
1251
|
session.status = "running";
|
|
1252
|
+
session.statusSource = "user-input";
|
|
1253
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
990
1254
|
this.onStatusChange?.(toPublicSession(session));
|
|
991
1255
|
}
|
|
992
1256
|
const gate = this.openGate.get(sessionId);
|
|
@@ -1056,6 +1320,8 @@ var CodexPtyRunner = class {
|
|
|
1056
1320
|
}
|
|
1057
1321
|
if (session.status === "waiting_input") {
|
|
1058
1322
|
session.status = "running";
|
|
1323
|
+
session.statusSource = "user-input";
|
|
1324
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1059
1325
|
this.onStatusChange?.(toPublicSession(session));
|
|
1060
1326
|
}
|
|
1061
1327
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -1156,6 +1422,8 @@ var CodexPtyRunner = class {
|
|
|
1156
1422
|
} catch {
|
|
1157
1423
|
}
|
|
1158
1424
|
session.status = "idle";
|
|
1425
|
+
session.statusSource = "shutdown";
|
|
1426
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1159
1427
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1160
1428
|
session.screen.dispose();
|
|
1161
1429
|
this.sessions.delete(sessionId);
|
|
@@ -1200,6 +1468,11 @@ var CodexPtyRunner = class {
|
|
|
1200
1468
|
getInputHistory(sessionId) {
|
|
1201
1469
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
1202
1470
|
}
|
|
1471
|
+
// OS pid of the spawned agent, or null if the session isn't live here.
|
|
1472
|
+
// Mirrors PTYManager.getPid — see there for why the registry records it.
|
|
1473
|
+
getPid(sessionId) {
|
|
1474
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
1475
|
+
}
|
|
1203
1476
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
1204
1477
|
// Called from writeSubmit (direct and flush paths) — never from sendKeys.
|
|
1205
1478
|
recordUserMessage(session, text) {
|
|
@@ -1301,9 +1574,9 @@ var CodexPtyRunner = class {
|
|
|
1301
1574
|
if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
|
|
1302
1575
|
const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
|
|
1303
1576
|
if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
|
|
1304
|
-
this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1577
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1305
1578
|
} else if (trigger === "quiet") {
|
|
1306
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
1579
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
1307
1580
|
}
|
|
1308
1581
|
}
|
|
1309
1582
|
// Answer a gate from the persisted remember-store, or surface it as a
|
|
@@ -1334,9 +1607,11 @@ var CodexPtyRunner = class {
|
|
|
1334
1607
|
});
|
|
1335
1608
|
this.onPermissionChange?.(sessionId, card);
|
|
1336
1609
|
}
|
|
1337
|
-
markReady(sessionId, session, reason) {
|
|
1610
|
+
markReady(sessionId, session, source, reason) {
|
|
1338
1611
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
1339
1612
|
session.status = "waiting_input";
|
|
1613
|
+
session.statusSource = source;
|
|
1614
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1340
1615
|
this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
|
|
1341
1616
|
event: "codex.ready",
|
|
1342
1617
|
sessionId,
|
|
@@ -1354,6 +1629,8 @@ var CodexPtyRunner = class {
|
|
|
1354
1629
|
if (!session) return;
|
|
1355
1630
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1356
1631
|
session.status = "idle";
|
|
1632
|
+
session.statusSource = "process-exit";
|
|
1633
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1357
1634
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
1358
1635
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
1359
1636
|
if (!existsSync2(session.projectPath)) {
|
|
@@ -1383,6 +1660,8 @@ function toPublicSession(s) {
|
|
|
1383
1660
|
lastOutput: s.lastOutput,
|
|
1384
1661
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
1385
1662
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
1663
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
1664
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
1386
1665
|
...s.filePath != null && { filePath: s.filePath }
|
|
1387
1666
|
};
|
|
1388
1667
|
}
|
|
@@ -1711,16 +1990,17 @@ var PTYManager = class {
|
|
|
1711
1990
|
}
|
|
1712
1991
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
1713
1992
|
//
|
|
1714
|
-
// options.permissionMode defaults to `acceptEdits`
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
1722
|
-
// `
|
|
1723
|
-
//
|
|
1993
|
+
// options.permissionMode defaults to `acceptEdits` — the safe default that
|
|
1994
|
+
// auto-approves file edits while still prompting for shell commands. All six
|
|
1995
|
+
// Claude CLI modes are accepted (see PERMISSION_MODES in claude-flags.ts).
|
|
1996
|
+
//
|
|
1997
|
+
// On the bypass modes: `bypassPermissions`/`dontAsk` DO trigger a blocking
|
|
1998
|
+
// "Bypass Permissions mode" warning menu at boot ("1. No, exit" /
|
|
1999
|
+
// "2. Yes, I accept") which would strand the PTY and leave mobile on an empty
|
|
2000
|
+
// screen. buildSettingsJson() suppresses it by adding
|
|
2001
|
+
// `skipDangerousModePermissionPrompt` to the `--settings` blob for exactly
|
|
2002
|
+
// those modes — probe-verified on Claude Code v2.1.218. We never pass
|
|
2003
|
+
// `--dangerously-skip-permissions`; bypass is requested via --permission-mode.
|
|
1724
2004
|
// (The other first-run gates — onboarding/theme, workspace trust,
|
|
1725
2005
|
// custom-API-key — are cleared by the seeded ~/.claude.json in
|
|
1726
2006
|
// docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
|
|
@@ -1738,28 +2018,27 @@ var PTYManager = class {
|
|
|
1738
2018
|
async doStart(sessionId, options) {
|
|
1739
2019
|
const nodePty = await loadPty2();
|
|
1740
2020
|
const projectName = options.projectName ?? basename2(options.projectPath);
|
|
1741
|
-
const
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
);
|
|
2021
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
2022
|
+
const args = [
|
|
2023
|
+
"--permission-mode",
|
|
2024
|
+
permissionMode,
|
|
2025
|
+
"--settings",
|
|
2026
|
+
buildSettingsJson(permissionMode),
|
|
2027
|
+
"--model",
|
|
2028
|
+
options.model ?? "sonnet",
|
|
2029
|
+
"--effort",
|
|
2030
|
+
options.effort ?? "low",
|
|
2031
|
+
"--resume",
|
|
2032
|
+
sessionId
|
|
2033
|
+
];
|
|
2034
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
2035
|
+
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
2036
|
+
name: "xterm-256color",
|
|
2037
|
+
cols: 120,
|
|
2038
|
+
rows: 40,
|
|
2039
|
+
cwd: options.projectPath,
|
|
2040
|
+
env: buildSpawnEnv()
|
|
2041
|
+
});
|
|
1763
2042
|
const session = {
|
|
1764
2043
|
id: sessionId,
|
|
1765
2044
|
provider: CLAUDE_CODE_PROVIDER,
|
|
@@ -1767,6 +2046,8 @@ var PTYManager = class {
|
|
|
1767
2046
|
projectName,
|
|
1768
2047
|
branch: options.branch ?? "",
|
|
1769
2048
|
status: "running",
|
|
2049
|
+
statusSource: "spawn",
|
|
2050
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1770
2051
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1771
2052
|
completedAt: null,
|
|
1772
2053
|
promptCount: 0,
|
|
@@ -1794,11 +2075,12 @@ var PTYManager = class {
|
|
|
1794
2075
|
const nodePty = await loadPty2();
|
|
1795
2076
|
const sessionId = randomUUID2();
|
|
1796
2077
|
const projectName = options.projectName ?? basename2(options.projectPath);
|
|
2078
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
1797
2079
|
const args = [
|
|
1798
2080
|
"--permission-mode",
|
|
1799
|
-
|
|
2081
|
+
permissionMode,
|
|
1800
2082
|
"--settings",
|
|
1801
|
-
|
|
2083
|
+
buildSettingsJson(permissionMode),
|
|
1802
2084
|
"--model",
|
|
1803
2085
|
options.model ?? "sonnet",
|
|
1804
2086
|
"--effort",
|
|
@@ -1809,6 +2091,7 @@ var PTYManager = class {
|
|
|
1809
2091
|
if (options.systemPrompt) {
|
|
1810
2092
|
args.push("--system-prompt", options.systemPrompt);
|
|
1811
2093
|
}
|
|
2094
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
1812
2095
|
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
1813
2096
|
name: "xterm-256color",
|
|
1814
2097
|
cols: 120,
|
|
@@ -1823,6 +2106,8 @@ var PTYManager = class {
|
|
|
1823
2106
|
projectName,
|
|
1824
2107
|
branch: "",
|
|
1825
2108
|
status: "running",
|
|
2109
|
+
statusSource: "spawn",
|
|
2110
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1826
2111
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1827
2112
|
completedAt: null,
|
|
1828
2113
|
promptCount: 0,
|
|
@@ -1853,6 +2138,8 @@ var PTYManager = class {
|
|
|
1853
2138
|
}
|
|
1854
2139
|
if (session.status === "waiting_input") {
|
|
1855
2140
|
session.status = "running";
|
|
2141
|
+
session.statusSource = "user-input";
|
|
2142
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1856
2143
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1857
2144
|
}
|
|
1858
2145
|
this.log.info(
|
|
@@ -1888,6 +2175,8 @@ var PTYManager = class {
|
|
|
1888
2175
|
}
|
|
1889
2176
|
if (session.status === "waiting_input") {
|
|
1890
2177
|
session.status = "running";
|
|
2178
|
+
session.statusSource = "user-input";
|
|
2179
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1891
2180
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1892
2181
|
}
|
|
1893
2182
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -2009,6 +2298,8 @@ var PTYManager = class {
|
|
|
2009
2298
|
} catch {
|
|
2010
2299
|
}
|
|
2011
2300
|
session.status = "idle";
|
|
2301
|
+
session.statusSource = "shutdown";
|
|
2302
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2012
2303
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2013
2304
|
session.screen.dispose();
|
|
2014
2305
|
this.sessions.delete(sessionId);
|
|
@@ -2044,6 +2335,13 @@ var PTYManager = class {
|
|
|
2044
2335
|
getInputHistory(sessionId) {
|
|
2045
2336
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
2046
2337
|
}
|
|
2338
|
+
// OS pid of the spawned agent, or null if the session isn't live here. The
|
|
2339
|
+
// durable registry records this so a later streamer run can probe whether the
|
|
2340
|
+
// process outlived it. Liveness alone is never identity — a recycled pid is
|
|
2341
|
+
// why the registry stores a cmdline alongside it.
|
|
2342
|
+
getPid(sessionId) {
|
|
2343
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
2344
|
+
}
|
|
2047
2345
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
2048
2346
|
// Called from writeSubmit (both direct and flush paths) — never from
|
|
2049
2347
|
// sendKeys, so raw keystrokes aren't logged as messages.
|
|
@@ -2120,9 +2418,9 @@ var PTYManager = class {
|
|
|
2120
2418
|
session.lastOutput = stripped;
|
|
2121
2419
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
|
|
2122
2420
|
if (session.status === "running" && matchedMarker) {
|
|
2123
|
-
this.markReady(sessionId, session, `marker:${matchedMarker}`);
|
|
2421
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
|
|
2124
2422
|
} else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
|
|
2125
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
2423
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
2126
2424
|
}
|
|
2127
2425
|
this.onOutput?.(sessionId, data);
|
|
2128
2426
|
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
@@ -2222,7 +2520,7 @@ var PTYManager = class {
|
|
|
2222
2520
|
const session = this.sessions.get(sessionId);
|
|
2223
2521
|
if (session?.status !== "running") return;
|
|
2224
2522
|
if (this.pendingReady.has(sessionId)) {
|
|
2225
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
2523
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
2226
2524
|
} else {
|
|
2227
2525
|
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2228
2526
|
this.log.warn("[pty.ready] screen recheck failed", {
|
|
@@ -2251,14 +2549,16 @@ var PTYManager = class {
|
|
|
2251
2549
|
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2252
2550
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2253
2551
|
if (matchedMarker && session.status === "running") {
|
|
2254
|
-
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2552
|
+
this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
|
|
2255
2553
|
}
|
|
2256
2554
|
}
|
|
2257
2555
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2258
2556
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2259
|
-
markReady(sessionId, session, reason) {
|
|
2557
|
+
markReady(sessionId, session, source, reason) {
|
|
2260
2558
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2261
2559
|
session.status = "waiting_input";
|
|
2560
|
+
session.statusSource = source;
|
|
2561
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2262
2562
|
const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
|
|
2263
2563
|
this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
|
|
2264
2564
|
event: "pty.ready",
|
|
@@ -2278,6 +2578,8 @@ var PTYManager = class {
|
|
|
2278
2578
|
if (!session) return;
|
|
2279
2579
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2280
2580
|
session.status = "idle";
|
|
2581
|
+
session.statusSource = "process-exit";
|
|
2582
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2281
2583
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
2282
2584
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
2283
2585
|
if (!existsSync3(session.projectPath)) {
|
|
@@ -2312,6 +2614,8 @@ function toPublicSession2(s) {
|
|
|
2312
2614
|
lastOutput: s.lastOutput,
|
|
2313
2615
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
2314
2616
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2617
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2618
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2315
2619
|
...s.filePath != null && { filePath: s.filePath }
|
|
2316
2620
|
};
|
|
2317
2621
|
}
|
|
@@ -2384,6 +2688,16 @@ var LiveSessionManager = class {
|
|
|
2384
2688
|
}
|
|
2385
2689
|
return null;
|
|
2386
2690
|
}
|
|
2691
|
+
// Scans rather than using runnerFor(): the registry records a pid on a
|
|
2692
|
+
// best-effort basis, so an unknown session must return null rather than
|
|
2693
|
+
// throw the way the input-routing methods do.
|
|
2694
|
+
getPid(sessionId) {
|
|
2695
|
+
for (const runner of this.runners.values()) {
|
|
2696
|
+
const pid = runner.getPid(sessionId);
|
|
2697
|
+
if (pid != null) return pid;
|
|
2698
|
+
}
|
|
2699
|
+
return null;
|
|
2700
|
+
}
|
|
2387
2701
|
hasSession(sessionId) {
|
|
2388
2702
|
for (const runner of this.runners.values()) {
|
|
2389
2703
|
if (runner.hasSession(sessionId)) return true;
|
|
@@ -2560,6 +2874,18 @@ async function getProcessCwdUnix(pid) {
|
|
|
2560
2874
|
async function getProcessArgsUnix(pid) {
|
|
2561
2875
|
return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
|
|
2562
2876
|
}
|
|
2877
|
+
async function getProcessArgs(pid) {
|
|
2878
|
+
if (!Number.isInteger(pid) || pid < 1) return "";
|
|
2879
|
+
try {
|
|
2880
|
+
if (platform2() === "win32") {
|
|
2881
|
+
const info = await getProcessInfoWindows(pid);
|
|
2882
|
+
return info?.args ?? "";
|
|
2883
|
+
}
|
|
2884
|
+
return await getProcessArgsUnix(pid);
|
|
2885
|
+
} catch {
|
|
2886
|
+
return "";
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2563
2889
|
async function getProcessStartTimeUnix(pid) {
|
|
2564
2890
|
const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
|
|
2565
2891
|
const d = new Date(raw);
|
|
@@ -2682,6 +3008,7 @@ import {
|
|
|
2682
3008
|
ConversationScanner,
|
|
2683
3009
|
search
|
|
2684
3010
|
} from "@threadbase-sh/scanner";
|
|
3011
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2685
3012
|
import { EventEmitter } from "events";
|
|
2686
3013
|
import {
|
|
2687
3014
|
createReadStream,
|
|
@@ -2693,7 +3020,7 @@ import {
|
|
|
2693
3020
|
} from "fs";
|
|
2694
3021
|
import { realpath as realpath2 } from "fs/promises";
|
|
2695
3022
|
import { createServer } from "http";
|
|
2696
|
-
import { homedir as homedir9 } from "os";
|
|
3023
|
+
import { homedir as homedir9, hostname as hostname2 } from "os";
|
|
2697
3024
|
import { basename as basename5, dirname as dirname9, join as join18 } from "path";
|
|
2698
3025
|
import { createInterface } from "readline";
|
|
2699
3026
|
|
|
@@ -2953,7 +3280,202 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2953
3280
|
}
|
|
2954
3281
|
|
|
2955
3282
|
// src/api/app.ts
|
|
2956
|
-
import { Hono as
|
|
3283
|
+
import { Hono as Hono16 } from "hono";
|
|
3284
|
+
|
|
3285
|
+
// src/db/repositories/devices.repository.ts
|
|
3286
|
+
import { createHash, randomBytes as randomBytes2, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3287
|
+
|
|
3288
|
+
// src/services/security/capabilities.ts
|
|
3289
|
+
var CAPABILITIES = [
|
|
3290
|
+
"history:read",
|
|
3291
|
+
// read conversations, search
|
|
3292
|
+
"session:control",
|
|
3293
|
+
// start, resume, send input, interrupt
|
|
3294
|
+
"fs:browse",
|
|
3295
|
+
// browse the project tree
|
|
3296
|
+
"fs:upload",
|
|
3297
|
+
// upload files into a project
|
|
3298
|
+
"notifications",
|
|
3299
|
+
// register for push
|
|
3300
|
+
"admin"
|
|
3301
|
+
// rotate keys, manage devices
|
|
3302
|
+
];
|
|
3303
|
+
function isCapability(value) {
|
|
3304
|
+
return typeof value === "string" && CAPABILITIES.includes(value);
|
|
3305
|
+
}
|
|
3306
|
+
var FULL_CAPABILITIES = [
|
|
3307
|
+
"history:read",
|
|
3308
|
+
"session:control",
|
|
3309
|
+
"fs:browse",
|
|
3310
|
+
"fs:upload",
|
|
3311
|
+
"notifications"
|
|
3312
|
+
];
|
|
3313
|
+
var READ_ONLY_CAPABILITIES = ["history:read"];
|
|
3314
|
+
function capabilitiesForPreset(preset) {
|
|
3315
|
+
return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
|
|
3316
|
+
}
|
|
3317
|
+
function legacyPrincipal() {
|
|
3318
|
+
return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
|
|
3319
|
+
}
|
|
3320
|
+
function hasCapability(principal, required) {
|
|
3321
|
+
return principal.capabilities.includes(required);
|
|
3322
|
+
}
|
|
3323
|
+
var ROUTE_CAPABILITIES = [
|
|
3324
|
+
// Most specific first for readability; matching sorts by length anyway.
|
|
3325
|
+
["/api/sessions/", "session:control"],
|
|
3326
|
+
["/api/sessions", "history:read"],
|
|
3327
|
+
// listing sessions is a read
|
|
3328
|
+
["/api/conversations", "history:read"],
|
|
3329
|
+
["/api/projects", "history:read"],
|
|
3330
|
+
["/api/search", "history:read"],
|
|
3331
|
+
["/api/providers", "history:read"],
|
|
3332
|
+
["/api/browse", "fs:browse"],
|
|
3333
|
+
["/api/upload", "fs:upload"],
|
|
3334
|
+
["/api/push", "notifications"],
|
|
3335
|
+
["/api/devices", "admin"],
|
|
3336
|
+
["/api/config", "admin"],
|
|
3337
|
+
["/api/auth/rotate", "admin"],
|
|
3338
|
+
["/api/backup", "admin"],
|
|
3339
|
+
// Server identity and capability discovery. A read-only device must be able
|
|
3340
|
+
// to see WHICH server it is talking to and what it supports, or it cannot
|
|
3341
|
+
// render anything at all.
|
|
3342
|
+
["/api/info", "history:read"],
|
|
3343
|
+
["/api/profiles", "history:read"],
|
|
3344
|
+
["/api/diagnostics", "history:read"],
|
|
3345
|
+
["/api/cache/alert", "history:read"],
|
|
3346
|
+
// Client log shipping: any authenticated client may report its own errors.
|
|
3347
|
+
// Gating this behind a capability would silence diagnostics from exactly the
|
|
3348
|
+
// devices most likely to be misbehaving.
|
|
3349
|
+
["/api/__client-log", "history:read"],
|
|
3350
|
+
// Logs viewer is localhost-only and already bypasses this middleware; the
|
|
3351
|
+
// mapping exists so a remote request is classified rather than denied as
|
|
3352
|
+
// unclassified.
|
|
3353
|
+
["/api/logs", "admin"],
|
|
3354
|
+
// Pairing routes other than the public exchange (e.g. minting a token).
|
|
3355
|
+
["/api/pair", "admin"],
|
|
3356
|
+
// The live WebSocket. Subscribing is a read — terminal output, session
|
|
3357
|
+
// updates, conversation events. Control still flows through the HTTP input
|
|
3358
|
+
// routes, which carry their own capability check, so a read-only device can
|
|
3359
|
+
// watch a session stream without being able to drive it.
|
|
3360
|
+
["/ws", "history:read"],
|
|
3361
|
+
// Progress webhook (multi-agent). Authenticated by HMAC in the handler and
|
|
3362
|
+
// already skipped by the middleware; classified so a stray request is denied
|
|
3363
|
+
// by rule rather than as "unclassified".
|
|
3364
|
+
["/internal/sessions", "admin"]
|
|
3365
|
+
];
|
|
3366
|
+
function requiredCapability(path, method) {
|
|
3367
|
+
if (path.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
|
|
3368
|
+
return "history:read";
|
|
3369
|
+
}
|
|
3370
|
+
let best = null;
|
|
3371
|
+
for (const [prefix, cap] of ROUTE_CAPABILITIES) {
|
|
3372
|
+
if (path.startsWith(prefix) && (best === null || prefix.length > best.len)) {
|
|
3373
|
+
best = { len: prefix.length, cap };
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
return best?.cap ?? null;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
// src/db/repositories/devices.repository.ts
|
|
3380
|
+
function generateDeviceToken() {
|
|
3381
|
+
return randomBytes2(32).toString("base64url");
|
|
3382
|
+
}
|
|
3383
|
+
function hashDeviceToken(token) {
|
|
3384
|
+
return createHash("sha256").update(token).digest("hex");
|
|
3385
|
+
}
|
|
3386
|
+
function safeHashEquals(a, b) {
|
|
3387
|
+
if (a.length !== b.length) return false;
|
|
3388
|
+
return timingSafeEqual2(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
3389
|
+
}
|
|
3390
|
+
function parseCapabilities(raw) {
|
|
3391
|
+
try {
|
|
3392
|
+
const parsed = JSON.parse(raw);
|
|
3393
|
+
if (!Array.isArray(parsed)) return [];
|
|
3394
|
+
return parsed.filter(isCapability);
|
|
3395
|
+
} catch {
|
|
3396
|
+
return [];
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
function toDeviceView(row) {
|
|
3400
|
+
return {
|
|
3401
|
+
deviceId: row.device_id,
|
|
3402
|
+
name: row.name,
|
|
3403
|
+
capabilities: parseCapabilities(row.capabilities),
|
|
3404
|
+
createdAt: row.created_at,
|
|
3405
|
+
lastSeenAt: row.last_seen_at,
|
|
3406
|
+
revokedAt: row.revoked_at
|
|
3407
|
+
};
|
|
3408
|
+
}
|
|
3409
|
+
var DevicesRepository = class {
|
|
3410
|
+
insertStmt;
|
|
3411
|
+
byTokenHashStmt;
|
|
3412
|
+
byIdStmt;
|
|
3413
|
+
listStmt;
|
|
3414
|
+
revokeStmt;
|
|
3415
|
+
touchStmt;
|
|
3416
|
+
constructor(db) {
|
|
3417
|
+
this.insertStmt = db.prepare(`
|
|
3418
|
+
INSERT INTO devices (
|
|
3419
|
+
device_id, public_key, token_hash, name, capabilities, created_at
|
|
3420
|
+
) VALUES (
|
|
3421
|
+
@device_id, @public_key, @token_hash, @name, @capabilities, @created_at
|
|
3422
|
+
)
|
|
3423
|
+
`);
|
|
3424
|
+
this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
|
|
3425
|
+
this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
|
|
3426
|
+
this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
|
|
3427
|
+
this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
|
|
3428
|
+
this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
|
|
3429
|
+
}
|
|
3430
|
+
/**
|
|
3431
|
+
* Record a newly paired device and mint its token.
|
|
3432
|
+
*
|
|
3433
|
+
* The raw token is returned to the caller and never stored — this is the only
|
|
3434
|
+
* moment it exists outside the client.
|
|
3435
|
+
*/
|
|
3436
|
+
register(args) {
|
|
3437
|
+
const deviceId = randomUUID3();
|
|
3438
|
+
const deviceToken = generateDeviceToken();
|
|
3439
|
+
const capabilities = capabilitiesForPreset(args.preset ?? "full");
|
|
3440
|
+
this.insertStmt.run({
|
|
3441
|
+
device_id: deviceId,
|
|
3442
|
+
public_key: args.publicKey,
|
|
3443
|
+
token_hash: hashDeviceToken(deviceToken),
|
|
3444
|
+
name: args.name ?? null,
|
|
3445
|
+
capabilities: JSON.stringify(capabilities),
|
|
3446
|
+
created_at: args.now ?? Date.now()
|
|
3447
|
+
});
|
|
3448
|
+
return { deviceId, deviceToken, capabilities };
|
|
3449
|
+
}
|
|
3450
|
+
/**
|
|
3451
|
+
* Resolve a presented token to a device, or null.
|
|
3452
|
+
*
|
|
3453
|
+
* Returns null for a revoked device, so revocation takes effect on the very
|
|
3454
|
+
* next request with no cache to go stale.
|
|
3455
|
+
*/
|
|
3456
|
+
authenticate(token) {
|
|
3457
|
+
const hash = hashDeviceToken(token);
|
|
3458
|
+
const row = this.byTokenHashStmt.get(hash);
|
|
3459
|
+
if (!row) return null;
|
|
3460
|
+
if (!safeHashEquals(row.token_hash, hash)) return null;
|
|
3461
|
+
if (row.revoked_at != null) return null;
|
|
3462
|
+
return row;
|
|
3463
|
+
}
|
|
3464
|
+
get(deviceId) {
|
|
3465
|
+
return this.byIdStmt.get(deviceId) ?? null;
|
|
3466
|
+
}
|
|
3467
|
+
/** All devices, including revoked ones — an audit surface needs the history. */
|
|
3468
|
+
list() {
|
|
3469
|
+
return this.listStmt.all().map(toDeviceView);
|
|
3470
|
+
}
|
|
3471
|
+
/** Revoke one device. Others are untouched — no key rotation, no collateral. */
|
|
3472
|
+
revoke(deviceId, now = Date.now()) {
|
|
3473
|
+
return this.revokeStmt.run(now, deviceId).changes > 0;
|
|
3474
|
+
}
|
|
3475
|
+
touch(deviceId, now = Date.now()) {
|
|
3476
|
+
this.touchStmt.run(now, deviceId);
|
|
3477
|
+
}
|
|
3478
|
+
};
|
|
2957
3479
|
|
|
2958
3480
|
// src/api/middleware/auth.middleware.ts
|
|
2959
3481
|
function isLocalRequest(remoteAddr) {
|
|
@@ -2984,19 +3506,40 @@ var authMiddleware = (deps) => async (c, next) => {
|
|
|
2984
3506
|
}
|
|
2985
3507
|
}
|
|
2986
3508
|
const authorization = c.req.header("authorization");
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
3509
|
+
const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : void 0;
|
|
3510
|
+
const queryKey = c.req.query("key") ?? void 0;
|
|
3511
|
+
const presented = bearer ?? queryKey;
|
|
3512
|
+
if (!presented) {
|
|
3513
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3514
|
+
}
|
|
3515
|
+
let principal = null;
|
|
3516
|
+
const device = deps.devicesRepo()?.authenticate(presented) ?? null;
|
|
3517
|
+
if (device) {
|
|
3518
|
+
principal = {
|
|
3519
|
+
kind: "device",
|
|
3520
|
+
deviceId: device.device_id,
|
|
3521
|
+
capabilities: parseCapabilities(device.capabilities)
|
|
3522
|
+
};
|
|
3523
|
+
try {
|
|
3524
|
+
deps.devicesRepo()?.touch(device.device_id);
|
|
3525
|
+
} catch {
|
|
2992
3526
|
}
|
|
3527
|
+
} else if (validateApiKey(presented, deps.apiKey)) {
|
|
3528
|
+
principal = legacyPrincipal();
|
|
2993
3529
|
}
|
|
2994
|
-
|
|
2995
|
-
|
|
3530
|
+
if (!principal) {
|
|
3531
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3532
|
+
}
|
|
3533
|
+
const required = requiredCapability(path, method);
|
|
3534
|
+
if (required === null) {
|
|
2996
3535
|
await next();
|
|
2997
3536
|
return;
|
|
2998
3537
|
}
|
|
2999
|
-
|
|
3538
|
+
if (!hasCapability(principal, required)) {
|
|
3539
|
+
return c.json({ error: "Forbidden", code: "MISSING_CAPABILITY", required }, 403);
|
|
3540
|
+
}
|
|
3541
|
+
c.set("principal", principal);
|
|
3542
|
+
await next();
|
|
3000
3543
|
};
|
|
3001
3544
|
|
|
3002
3545
|
// src/api/middleware/cors.middleware.ts
|
|
@@ -3135,12 +3678,68 @@ var createCacheAlertRoutes = (deps) => {
|
|
|
3135
3678
|
return app;
|
|
3136
3679
|
};
|
|
3137
3680
|
|
|
3138
|
-
// src/api/routes/
|
|
3681
|
+
// src/api/routes/config.routes.ts
|
|
3139
3682
|
import { Hono as Hono4 } from "hono";
|
|
3683
|
+
|
|
3684
|
+
// src/schemas/claudeFlags.schema.ts
|
|
3685
|
+
import { z as z2 } from "zod";
|
|
3686
|
+
var ClaudeFlagsBodySchema = z2.object({
|
|
3687
|
+
values: z2.record(z2.string(), z2.union([z2.string(), z2.boolean(), z2.array(z2.string())])).default({}),
|
|
3688
|
+
// A newline would corrupt the flat one-line-per-key server.yaml, so reject
|
|
3689
|
+
// it here with a field error instead of silently stripping it.
|
|
3690
|
+
extraArgs: z2.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
|
|
3691
|
+
}).strict();
|
|
3692
|
+
|
|
3693
|
+
// src/api/routes/config.routes.ts
|
|
3694
|
+
function readRawBody3(req) {
|
|
3695
|
+
return new Promise((resolve2, reject) => {
|
|
3696
|
+
const chunks = [];
|
|
3697
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3698
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
3699
|
+
req.on("error", reject);
|
|
3700
|
+
});
|
|
3701
|
+
}
|
|
3702
|
+
var createConfigRoutes = (deps) => {
|
|
3703
|
+
const app = new Hono4();
|
|
3704
|
+
app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
|
|
3705
|
+
app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
|
|
3706
|
+
app.put("/claude-flags", async (c) => {
|
|
3707
|
+
if (deps.localNoAuth) {
|
|
3708
|
+
return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
|
|
3709
|
+
}
|
|
3710
|
+
let body;
|
|
3711
|
+
try {
|
|
3712
|
+
const incoming = c.env?.incoming;
|
|
3713
|
+
const raw = incoming ? await readRawBody3(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
|
|
3714
|
+
body = raw ? JSON.parse(raw) : {};
|
|
3715
|
+
} catch {
|
|
3716
|
+
return c.json({ error: "invalid json" }, 400);
|
|
3717
|
+
}
|
|
3718
|
+
const parsed = ClaudeFlagsBodySchema.safeParse(body);
|
|
3719
|
+
if (!parsed.success) {
|
|
3720
|
+
return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
|
|
3721
|
+
}
|
|
3722
|
+
try {
|
|
3723
|
+
const result = deps.setClaudeFlagsConfig(parsed.data.values, parsed.data.extraArgs);
|
|
3724
|
+
return c.json({
|
|
3725
|
+
...result,
|
|
3726
|
+
...result.persisted ? {} : {
|
|
3727
|
+
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."
|
|
3728
|
+
}
|
|
3729
|
+
});
|
|
3730
|
+
} catch (err) {
|
|
3731
|
+
return c.json({ error: err instanceof Error ? err.message : "could not apply flags" }, 400);
|
|
3732
|
+
}
|
|
3733
|
+
});
|
|
3734
|
+
return app;
|
|
3735
|
+
};
|
|
3736
|
+
|
|
3737
|
+
// src/api/routes/conversations.routes.ts
|
|
3738
|
+
import { Hono as Hono5 } from "hono";
|
|
3140
3739
|
var ALREADY_HANDLED2 = 597;
|
|
3141
3740
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
3142
3741
|
var createConversationRoutes = (deps) => {
|
|
3143
|
-
const app = new
|
|
3742
|
+
const app = new Hono5();
|
|
3144
3743
|
app.get("/count", async (c) => {
|
|
3145
3744
|
const url = new URL(c.req.url);
|
|
3146
3745
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -3166,8 +3765,34 @@ var createConversationRoutes = (deps) => {
|
|
|
3166
3765
|
return app;
|
|
3167
3766
|
};
|
|
3168
3767
|
|
|
3768
|
+
// src/api/routes/devices.routes.ts
|
|
3769
|
+
import { Hono as Hono6 } from "hono";
|
|
3770
|
+
var createDeviceRoutes = (deps) => {
|
|
3771
|
+
const app = new Hono6();
|
|
3772
|
+
app.get("/", (c) => {
|
|
3773
|
+
const repo = deps.devicesRepo();
|
|
3774
|
+
if (!repo) return c.json({ devices: [], available: false });
|
|
3775
|
+
return c.json({ devices: repo.list(), available: true });
|
|
3776
|
+
});
|
|
3777
|
+
app.post("/:id/revoke", (c) => {
|
|
3778
|
+
const repo = deps.devicesRepo();
|
|
3779
|
+
if (!repo) {
|
|
3780
|
+
return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
3781
|
+
}
|
|
3782
|
+
const id = c.req.param("id");
|
|
3783
|
+
const existing = repo.get(id);
|
|
3784
|
+
if (!existing) return c.json({ error: "Device not found" }, 404);
|
|
3785
|
+
if (existing.revoked_at != null) {
|
|
3786
|
+
return c.json({ ok: true, alreadyRevoked: true });
|
|
3787
|
+
}
|
|
3788
|
+
repo.revoke(id);
|
|
3789
|
+
return c.json({ ok: true, alreadyRevoked: false });
|
|
3790
|
+
});
|
|
3791
|
+
return app;
|
|
3792
|
+
};
|
|
3793
|
+
|
|
3169
3794
|
// src/api/routes/health.routes.ts
|
|
3170
|
-
import { Hono as
|
|
3795
|
+
import { Hono as Hono7 } from "hono";
|
|
3171
3796
|
|
|
3172
3797
|
// src/version.ts
|
|
3173
3798
|
import { readFileSync as readFileSync4, realpathSync } from "fs";
|
|
@@ -3204,7 +3829,7 @@ function resolveVersion() {
|
|
|
3204
3829
|
|
|
3205
3830
|
// src/api/routes/health.routes.ts
|
|
3206
3831
|
var createHealthRoutes = (deps) => {
|
|
3207
|
-
const app = new
|
|
3832
|
+
const app = new Hono7();
|
|
3208
3833
|
app.get("/", (c) => {
|
|
3209
3834
|
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3210
3835
|
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
@@ -3215,7 +3840,7 @@ var createHealthRoutes = (deps) => {
|
|
|
3215
3840
|
// src/api/routes/logs.routes.ts
|
|
3216
3841
|
import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
|
|
3217
3842
|
import { join as join9 } from "path";
|
|
3218
|
-
import { Hono as
|
|
3843
|
+
import { Hono as Hono8 } from "hono";
|
|
3219
3844
|
|
|
3220
3845
|
// src/lifecycle/constants.ts
|
|
3221
3846
|
import { homedir as homedir4 } from "os";
|
|
@@ -3273,7 +3898,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3273
3898
|
}
|
|
3274
3899
|
}
|
|
3275
3900
|
function createLogsRoutes() {
|
|
3276
|
-
const app = new
|
|
3901
|
+
const app = new Hono8();
|
|
3277
3902
|
app.get("/", (c) => {
|
|
3278
3903
|
try {
|
|
3279
3904
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3343,8 +3968,8 @@ function createLogsRoutes() {
|
|
|
3343
3968
|
|
|
3344
3969
|
// src/api/routes/misc.routes.ts
|
|
3345
3970
|
import { spawn } from "child_process";
|
|
3346
|
-
import { createHmac, timingSafeEqual as
|
|
3347
|
-
import { Hono as
|
|
3971
|
+
import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
3972
|
+
import { Hono as Hono9 } from "hono";
|
|
3348
3973
|
import { hostname } from "os";
|
|
3349
3974
|
|
|
3350
3975
|
// src/config/update-config.ts
|
|
@@ -3354,15 +3979,15 @@ import { join as join10 } from "path";
|
|
|
3354
3979
|
import { parse as parseYaml } from "yaml";
|
|
3355
3980
|
|
|
3356
3981
|
// src/schemas/updateConfig.schema.ts
|
|
3357
|
-
import { z as
|
|
3358
|
-
var UpdateConfigSchema =
|
|
3359
|
-
auto_update:
|
|
3360
|
-
channel:
|
|
3361
|
-
allow:
|
|
3362
|
-
poll_interval_minutes:
|
|
3363
|
-
defer_if_active_sessions:
|
|
3364
|
-
github_repo:
|
|
3365
|
-
webhook_secret:
|
|
3982
|
+
import { z as z3 } from "zod";
|
|
3983
|
+
var UpdateConfigSchema = z3.object({
|
|
3984
|
+
auto_update: z3.boolean().default(false),
|
|
3985
|
+
channel: z3.enum(["stable", "next"]).default("stable"),
|
|
3986
|
+
allow: z3.array(z3.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
|
|
3987
|
+
poll_interval_minutes: z3.number().int().min(0).default(1440),
|
|
3988
|
+
defer_if_active_sessions: z3.boolean().default(true),
|
|
3989
|
+
github_repo: z3.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
|
|
3990
|
+
webhook_secret: z3.string().min(1).nullable().default(null)
|
|
3366
3991
|
}).strict();
|
|
3367
3992
|
|
|
3368
3993
|
// src/config/update-config.ts
|
|
@@ -3383,7 +4008,264 @@ function loadUpdateConfig(opts = {}) {
|
|
|
3383
4008
|
return UpdateConfigSchema.parse(parsed);
|
|
3384
4009
|
}
|
|
3385
4010
|
|
|
4011
|
+
// src/db/repositories/push.repository.ts
|
|
4012
|
+
var FAILURE_STREAK_LIMIT = 5;
|
|
4013
|
+
var PUSH_TOKEN_KINDS = ["expo", "liveactivity_start", "liveactivity_update"];
|
|
4014
|
+
var DEFAULT_PUSH_TOKEN_KIND = "expo";
|
|
4015
|
+
function isPushTokenKind(value) {
|
|
4016
|
+
return typeof value === "string" && PUSH_TOKEN_KINDS.includes(value);
|
|
4017
|
+
}
|
|
4018
|
+
function tokenState(row, now = Date.now()) {
|
|
4019
|
+
if (row.revoked_at != null) return "revoked";
|
|
4020
|
+
if (row.expires_at != null && row.expires_at <= now) return "expired";
|
|
4021
|
+
if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
|
|
4022
|
+
if (row.failure_streak > 0) return "failing";
|
|
4023
|
+
if (row.last_success_at == null) return "never-delivered";
|
|
4024
|
+
return "healthy";
|
|
4025
|
+
}
|
|
4026
|
+
function toHealth(row, now = Date.now()) {
|
|
4027
|
+
return {
|
|
4028
|
+
platform: row.platform,
|
|
4029
|
+
deviceId: row.device_id,
|
|
4030
|
+
registeredAt: row.registered_at,
|
|
4031
|
+
lastSuccessAt: row.last_success_at,
|
|
4032
|
+
lastFailureAt: row.last_failure_at,
|
|
4033
|
+
lastFailureCode: row.last_failure_code,
|
|
4034
|
+
failureStreak: row.failure_streak,
|
|
4035
|
+
revokedAt: row.revoked_at,
|
|
4036
|
+
state: tokenState(row, now),
|
|
4037
|
+
kind: row.kind,
|
|
4038
|
+
activityId: row.activity_id,
|
|
4039
|
+
sessionId: row.session_id,
|
|
4040
|
+
expiresAt: row.expires_at
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4043
|
+
var PushRepository = class {
|
|
4044
|
+
upsertStmt;
|
|
4045
|
+
getStmt;
|
|
4046
|
+
listActiveStmt;
|
|
4047
|
+
listAllStmt;
|
|
4048
|
+
successStmt;
|
|
4049
|
+
failureStmt;
|
|
4050
|
+
revokeStmt;
|
|
4051
|
+
claimEventStmt;
|
|
4052
|
+
markDeliveredStmt;
|
|
4053
|
+
listByKindSessionStmt;
|
|
4054
|
+
listByKindStmt;
|
|
4055
|
+
listRenewableStmt;
|
|
4056
|
+
claimRenewalStmt;
|
|
4057
|
+
expireStmt;
|
|
4058
|
+
expireSessionActivitiesStmt;
|
|
4059
|
+
constructor(db) {
|
|
4060
|
+
this.upsertStmt = db.prepare(`
|
|
4061
|
+
INSERT INTO push_tokens (
|
|
4062
|
+
token, platform, device_id, registered_at,
|
|
4063
|
+
kind, activity_id, session_id, expires_at, stale_date, started_at
|
|
4064
|
+
)
|
|
4065
|
+
VALUES (
|
|
4066
|
+
@token, @platform, @device_id, @registered_at,
|
|
4067
|
+
@kind, @activity_id, @session_id, @expires_at, @stale_date, @started_at
|
|
4068
|
+
)
|
|
4069
|
+
ON CONFLICT(token) DO UPDATE SET
|
|
4070
|
+
platform = excluded.platform,
|
|
4071
|
+
device_id = COALESCE(excluded.device_id, push_tokens.device_id),
|
|
4072
|
+
registered_at = excluded.registered_at,
|
|
4073
|
+
kind = excluded.kind,
|
|
4074
|
+
activity_id = COALESCE(excluded.activity_id, push_tokens.activity_id),
|
|
4075
|
+
session_id = COALESCE(excluded.session_id, push_tokens.session_id),
|
|
4076
|
+
expires_at = excluded.expires_at,
|
|
4077
|
+
stale_date = excluded.stale_date,
|
|
4078
|
+
-- Preserve the ORIGINAL start across a re-registration. iOS renders its
|
|
4079
|
+
-- own ticking timer from started_at, so overwriting it with a fresh
|
|
4080
|
+
-- value visibly resets the user's elapsed time to zero.
|
|
4081
|
+
started_at = COALESCE(push_tokens.started_at, excluded.started_at),
|
|
4082
|
+
-- A fresh registration clears prior failure state and any revocation:
|
|
4083
|
+
-- the client is telling us this token is live again. renewed_at clears
|
|
4084
|
+
-- too \u2014 this is a new activity generation, so it is renewable again.
|
|
4085
|
+
failure_streak = 0,
|
|
4086
|
+
last_failure_at = NULL,
|
|
4087
|
+
last_failure_code = NULL,
|
|
4088
|
+
revoked_at = NULL,
|
|
4089
|
+
renewed_at = NULL
|
|
4090
|
+
`);
|
|
4091
|
+
this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
|
|
4092
|
+
this.listActiveStmt = db.prepare(`
|
|
4093
|
+
SELECT * FROM push_tokens
|
|
4094
|
+
WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4095
|
+
AND kind = 'expo'
|
|
4096
|
+
ORDER BY registered_at ASC
|
|
4097
|
+
`);
|
|
4098
|
+
this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
|
|
4099
|
+
this.successStmt = db.prepare(`
|
|
4100
|
+
UPDATE push_tokens
|
|
4101
|
+
SET last_success_at = @at, failure_streak = 0,
|
|
4102
|
+
last_failure_code = NULL
|
|
4103
|
+
WHERE token = @token
|
|
4104
|
+
`);
|
|
4105
|
+
this.failureStmt = db.prepare(`
|
|
4106
|
+
UPDATE push_tokens
|
|
4107
|
+
SET last_failure_at = @at, last_failure_code = @code,
|
|
4108
|
+
failure_streak = failure_streak + 1
|
|
4109
|
+
WHERE token = @token
|
|
4110
|
+
`);
|
|
4111
|
+
this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
|
|
4112
|
+
this.listByKindSessionStmt = db.prepare(`
|
|
4113
|
+
SELECT * FROM push_tokens
|
|
4114
|
+
WHERE kind = @kind AND session_id = @session_id
|
|
4115
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4116
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
4117
|
+
ORDER BY registered_at ASC
|
|
4118
|
+
`);
|
|
4119
|
+
this.listByKindStmt = db.prepare(`
|
|
4120
|
+
SELECT * FROM push_tokens
|
|
4121
|
+
WHERE kind = @kind
|
|
4122
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4123
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
4124
|
+
ORDER BY registered_at ASC
|
|
4125
|
+
`);
|
|
4126
|
+
this.listRenewableStmt = db.prepare(`
|
|
4127
|
+
SELECT * FROM push_tokens
|
|
4128
|
+
WHERE kind = 'liveactivity_update'
|
|
4129
|
+
AND stale_date IS NOT NULL AND renewed_at IS NULL
|
|
4130
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4131
|
+
ORDER BY stale_date ASC
|
|
4132
|
+
`);
|
|
4133
|
+
this.claimRenewalStmt = db.prepare(`
|
|
4134
|
+
UPDATE push_tokens SET renewed_at = @at
|
|
4135
|
+
WHERE token = @token AND renewed_at IS NULL
|
|
4136
|
+
`);
|
|
4137
|
+
this.expireStmt = db.prepare("UPDATE push_tokens SET expires_at = ? WHERE token = ?");
|
|
4138
|
+
this.expireSessionActivitiesStmt = db.prepare(`
|
|
4139
|
+
UPDATE push_tokens SET expires_at = @at
|
|
4140
|
+
WHERE session_id = @session_id AND kind = 'liveactivity_update'
|
|
4141
|
+
AND (expires_at IS NULL OR expires_at > @at)
|
|
4142
|
+
`);
|
|
4143
|
+
this.claimEventStmt = db.prepare(`
|
|
4144
|
+
INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
|
|
4145
|
+
VALUES (@event_id, @session_id, @created_at)
|
|
4146
|
+
`);
|
|
4147
|
+
this.markDeliveredStmt = db.prepare(
|
|
4148
|
+
"UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
|
|
4149
|
+
);
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4152
|
+
* Register or refresh a token.
|
|
4153
|
+
*
|
|
4154
|
+
* `kind` defaults to Expo so a released client posting `{ token, platform }`
|
|
4155
|
+
* keeps working — tb-mobile cannot be force-updated, and every client
|
|
4156
|
+
* predating Live Activities is registering an Expo relay token.
|
|
4157
|
+
*
|
|
4158
|
+
* Several rows per device is normal and intended: a device runs one activity
|
|
4159
|
+
* per live session, each with its own update token. The token itself is the
|
|
4160
|
+
* primary key, so distinct activities never collide.
|
|
4161
|
+
*/
|
|
4162
|
+
register(args) {
|
|
4163
|
+
this.upsertStmt.run({
|
|
4164
|
+
token: args.token,
|
|
4165
|
+
platform: args.platform,
|
|
4166
|
+
device_id: args.deviceId ?? null,
|
|
4167
|
+
registered_at: args.now ?? Date.now(),
|
|
4168
|
+
kind: args.kind ?? DEFAULT_PUSH_TOKEN_KIND,
|
|
4169
|
+
activity_id: args.activityId ?? null,
|
|
4170
|
+
session_id: args.sessionId ?? null,
|
|
4171
|
+
expires_at: args.expiresAt ?? null,
|
|
4172
|
+
stale_date: args.staleDate ?? null,
|
|
4173
|
+
started_at: args.startedAt ?? null
|
|
4174
|
+
});
|
|
4175
|
+
}
|
|
4176
|
+
get(token) {
|
|
4177
|
+
return this.getStmt.get(token) ?? null;
|
|
4178
|
+
}
|
|
4179
|
+
/**
|
|
4180
|
+
* Expo tokens eligible for delivery — not revoked, not past the failure limit.
|
|
4181
|
+
*
|
|
4182
|
+
* Deliberately Expo-only. ActivityKit tokens go over direct APNs with a
|
|
4183
|
+
* different topic and are rejected by Expo's relay, so the ordinary
|
|
4184
|
+
* notification fan-out must not see them.
|
|
4185
|
+
*/
|
|
4186
|
+
listDeliverable() {
|
|
4187
|
+
return this.listActiveStmt.all();
|
|
4188
|
+
}
|
|
4189
|
+
/** Live-activity tokens for one session, eligible for delivery. */
|
|
4190
|
+
listForSession(kind, sessionId, now = Date.now()) {
|
|
4191
|
+
return this.listByKindSessionStmt.all({
|
|
4192
|
+
kind,
|
|
4193
|
+
session_id: sessionId,
|
|
4194
|
+
now
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
/**
|
|
4198
|
+
* Every deliverable token of one kind.
|
|
4199
|
+
*
|
|
4200
|
+
* Used for push-to-start, which is app-wide rather than session-scoped: the
|
|
4201
|
+
* activity does not exist yet, so there is no per-activity token to look up.
|
|
4202
|
+
*/
|
|
4203
|
+
listByKind(kind, now = Date.now()) {
|
|
4204
|
+
return this.listByKindStmt.all({ kind, now });
|
|
4205
|
+
}
|
|
4206
|
+
/** Unrenewed activities with a renewal deadline, soonest first. */
|
|
4207
|
+
listRenewable() {
|
|
4208
|
+
return this.listRenewableStmt.all();
|
|
4209
|
+
}
|
|
4210
|
+
/**
|
|
4211
|
+
* Claim a row for renewal.
|
|
4212
|
+
*
|
|
4213
|
+
* Returns true exactly once per row. A restart re-arms timers from the
|
|
4214
|
+
* persisted deadline, so the same renewal can be attempted twice; the loser
|
|
4215
|
+
* gets false and must not send. Doing this as a conditional UPDATE rather
|
|
4216
|
+
* than read-then-write avoids the race where both attempts observe
|
|
4217
|
+
* "not yet renewed".
|
|
4218
|
+
*/
|
|
4219
|
+
claimRenewal(token, now = Date.now()) {
|
|
4220
|
+
return this.claimRenewalStmt.run({ token, at: now }).changes > 0;
|
|
4221
|
+
}
|
|
4222
|
+
/** Mark one token expired, so it stops being a delivery target. */
|
|
4223
|
+
expire(token, now = Date.now()) {
|
|
4224
|
+
this.expireStmt.run(now, token);
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* Expire every live activity for a session.
|
|
4228
|
+
*
|
|
4229
|
+
* Called when the session ends. Without this, a per-activity token outlives
|
|
4230
|
+
* its session and a later renewal sweep would resurrect an activity for a
|
|
4231
|
+
* session that is already gone.
|
|
4232
|
+
*/
|
|
4233
|
+
expireSessionActivities(sessionId, now = Date.now()) {
|
|
4234
|
+
this.expireSessionActivitiesStmt.run({ session_id: sessionId, at: now });
|
|
4235
|
+
}
|
|
4236
|
+
/** Every token, including dead and revoked ones, for the health report. */
|
|
4237
|
+
listHealth(now = Date.now()) {
|
|
4238
|
+
return this.listAllStmt.all().map((r) => toHealth(r, now));
|
|
4239
|
+
}
|
|
4240
|
+
recordSuccess(token, now = Date.now()) {
|
|
4241
|
+
this.successStmt.run({ token, at: now });
|
|
4242
|
+
}
|
|
4243
|
+
recordFailure(token, code, now = Date.now()) {
|
|
4244
|
+
this.failureStmt.run({ token, at: now, code });
|
|
4245
|
+
}
|
|
4246
|
+
revoke(token, now = Date.now()) {
|
|
4247
|
+
return this.revokeStmt.run(now, token).changes > 0;
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Claim an event id for delivery.
|
|
4251
|
+
*
|
|
4252
|
+
* Returns true exactly once per event id. A retry, a reconnect
|
|
4253
|
+
* reconciliation, or two triggers firing for the same underlying event all
|
|
4254
|
+
* get false and must not notify — the user should never be told twice about
|
|
4255
|
+
* one thing.
|
|
4256
|
+
*/
|
|
4257
|
+
claimEvent(eventId, sessionId, now = Date.now()) {
|
|
4258
|
+
return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
|
|
4259
|
+
}
|
|
4260
|
+
markDelivered(eventId, now = Date.now()) {
|
|
4261
|
+
this.markDeliveredStmt.run(now, eventId);
|
|
4262
|
+
}
|
|
4263
|
+
};
|
|
4264
|
+
|
|
3386
4265
|
// src/api/routes/misc.routes.ts
|
|
4266
|
+
function numberOrNull(value) {
|
|
4267
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
4268
|
+
}
|
|
3387
4269
|
function readJsonBody(req) {
|
|
3388
4270
|
return new Promise((resolve2, reject) => {
|
|
3389
4271
|
const chunks = [];
|
|
@@ -3399,7 +4281,7 @@ function readJsonBody(req) {
|
|
|
3399
4281
|
req.on("error", reject);
|
|
3400
4282
|
});
|
|
3401
4283
|
}
|
|
3402
|
-
function
|
|
4284
|
+
function readRawBody4(req) {
|
|
3403
4285
|
return new Promise((resolve2, reject) => {
|
|
3404
4286
|
const chunks = [];
|
|
3405
4287
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3414,11 +4296,11 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3414
4296
|
const a = Buffer.from(provided, "utf-8");
|
|
3415
4297
|
const b = Buffer.from(expected, "utf-8");
|
|
3416
4298
|
if (a.length !== b.length) return false;
|
|
3417
|
-
return
|
|
4299
|
+
return timingSafeEqual3(a, b);
|
|
3418
4300
|
}
|
|
3419
4301
|
var clientLog = getLogger("client");
|
|
3420
4302
|
var createMiscRoutes = (deps) => {
|
|
3421
|
-
const app = new
|
|
4303
|
+
const app = new Hono9();
|
|
3422
4304
|
app.get("/api/info", (c) => {
|
|
3423
4305
|
const ptyIds = deps.ptyAttachedIds();
|
|
3424
4306
|
return c.json({
|
|
@@ -3426,7 +4308,15 @@ var createMiscRoutes = (deps) => {
|
|
|
3426
4308
|
machineName: hostname(),
|
|
3427
4309
|
platform: process.platform,
|
|
3428
4310
|
activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
|
|
3429
|
-
publicUrl: deps.publicUrl
|
|
4311
|
+
publicUrl: deps.publicUrl,
|
|
4312
|
+
// Capability flag: this server serves /api/config/claude-flags. Additive —
|
|
4313
|
+
// older clients ignore it, and clients talking to an older server see it
|
|
4314
|
+
// absent and hide the UI rather than 404ing.
|
|
4315
|
+
claudeFlags: true,
|
|
4316
|
+
// Same contract: this server serves GET /api/config/feature-flags. Lives
|
|
4317
|
+
// here rather than behind /api/config (admin-only) so a read-only client
|
|
4318
|
+
// still learns the server supports flags even if it can't read values.
|
|
4319
|
+
featureFlags: true
|
|
3430
4320
|
});
|
|
3431
4321
|
});
|
|
3432
4322
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -3443,7 +4333,54 @@ var createMiscRoutes = (deps) => {
|
|
|
3443
4333
|
}
|
|
3444
4334
|
});
|
|
3445
4335
|
});
|
|
3446
|
-
app.post("/api/push/register", (c) =>
|
|
4336
|
+
app.post("/api/push/register", async (c) => {
|
|
4337
|
+
const body = await readJsonBody(c.env.incoming).catch(() => null);
|
|
4338
|
+
const token = body?.token;
|
|
4339
|
+
const platform3 = body?.platform;
|
|
4340
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
4341
|
+
return c.json({ error: "Missing token" }, 400);
|
|
4342
|
+
}
|
|
4343
|
+
if (platform3 !== "ios" && platform3 !== "android") {
|
|
4344
|
+
return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
|
|
4345
|
+
}
|
|
4346
|
+
const kind = body?.kind === void 0 ? DEFAULT_PUSH_TOKEN_KIND : body.kind;
|
|
4347
|
+
if (!isPushTokenKind(kind)) {
|
|
4348
|
+
return c.json(
|
|
4349
|
+
{ error: `kind must be one of ${PUSH_TOKEN_KINDS.join(", ")}`, code: "INVALID_KIND" },
|
|
4350
|
+
400
|
|
4351
|
+
);
|
|
4352
|
+
}
|
|
4353
|
+
if (kind === "liveactivity_update" && typeof body?.activityId !== "string") {
|
|
4354
|
+
return c.json(
|
|
4355
|
+
{
|
|
4356
|
+
error: "activityId is required for kind 'liveactivity_update'",
|
|
4357
|
+
code: "MISSING_ACTIVITY"
|
|
4358
|
+
},
|
|
4359
|
+
400
|
|
4360
|
+
);
|
|
4361
|
+
}
|
|
4362
|
+
const repo = deps.pushRepo();
|
|
4363
|
+
if (!repo) {
|
|
4364
|
+
return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
4365
|
+
}
|
|
4366
|
+
repo.register({
|
|
4367
|
+
token,
|
|
4368
|
+
platform: platform3,
|
|
4369
|
+
deviceId: typeof body?.deviceId === "string" ? body.deviceId : null,
|
|
4370
|
+
kind,
|
|
4371
|
+
activityId: typeof body?.activityId === "string" ? body.activityId : null,
|
|
4372
|
+
sessionId: typeof body?.sessionId === "string" ? body.sessionId : null,
|
|
4373
|
+
expiresAt: numberOrNull(body?.expiresAt),
|
|
4374
|
+
staleDate: numberOrNull(body?.staleDate),
|
|
4375
|
+
startedAt: numberOrNull(body?.startedAt)
|
|
4376
|
+
});
|
|
4377
|
+
return c.json({ ok: true });
|
|
4378
|
+
});
|
|
4379
|
+
app.get("/api/push/health", (c) => {
|
|
4380
|
+
const repo = deps.pushRepo();
|
|
4381
|
+
if (!repo) return c.json({ tokens: [], available: false });
|
|
4382
|
+
return c.json({ tokens: repo.listHealth(), available: true });
|
|
4383
|
+
});
|
|
3447
4384
|
app.post("/api/__update", async (c) => {
|
|
3448
4385
|
const cfg = loadUpdateConfig();
|
|
3449
4386
|
if (!cfg?.webhook_secret) {
|
|
@@ -3451,7 +4388,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3451
4388
|
}
|
|
3452
4389
|
let body;
|
|
3453
4390
|
try {
|
|
3454
|
-
body = await
|
|
4391
|
+
body = await readRawBody4(c.env.incoming);
|
|
3455
4392
|
} catch {
|
|
3456
4393
|
return c.json({ error: "could not read body" }, 400);
|
|
3457
4394
|
}
|
|
@@ -3494,11 +4431,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3494
4431
|
};
|
|
3495
4432
|
|
|
3496
4433
|
// src/api/routes/pair.routes.ts
|
|
3497
|
-
import { Hono as
|
|
4434
|
+
import { Hono as Hono10 } from "hono";
|
|
3498
4435
|
var ALREADY_HANDLED3 = 597;
|
|
3499
4436
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3500
4437
|
var createPairRoutes = (deps) => {
|
|
3501
|
-
const app = new
|
|
4438
|
+
const app = new Hono10();
|
|
3502
4439
|
app.post("/start", (c) => {
|
|
3503
4440
|
deps.handlePairStart(c.env.outgoing);
|
|
3504
4441
|
return alreadyHandled3();
|
|
@@ -3511,11 +4448,11 @@ var createPairRoutes = (deps) => {
|
|
|
3511
4448
|
};
|
|
3512
4449
|
|
|
3513
4450
|
// src/api/routes/projects.routes.ts
|
|
3514
|
-
import { Hono as
|
|
4451
|
+
import { Hono as Hono11 } from "hono";
|
|
3515
4452
|
var ALREADY_HANDLED4 = 597;
|
|
3516
4453
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3517
4454
|
var createProjectRoutes = (deps) => {
|
|
3518
|
-
const app = new
|
|
4455
|
+
const app = new Hono11();
|
|
3519
4456
|
app.get("/", (c) => {
|
|
3520
4457
|
const url = new URL(c.req.url);
|
|
3521
4458
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3529,12 +4466,147 @@ var createProjectRoutes = (deps) => {
|
|
|
3529
4466
|
return app;
|
|
3530
4467
|
};
|
|
3531
4468
|
|
|
4469
|
+
// src/api/routes/providers.routes.ts
|
|
4470
|
+
import { Hono as Hono12 } from "hono";
|
|
4471
|
+
|
|
4472
|
+
// src/services/providers/providerHealth.ts
|
|
4473
|
+
import { execFile as execFile2 } from "child_process";
|
|
4474
|
+
|
|
4475
|
+
// src/services/providers/capabilities.ts
|
|
4476
|
+
var CLAUDE_CODE_CAPABILITIES = {
|
|
4477
|
+
freshSessionId: "explicit",
|
|
4478
|
+
resume: "native",
|
|
4479
|
+
systemPrompt: "flag",
|
|
4480
|
+
structuredQuestions: true,
|
|
4481
|
+
permissionGates: true,
|
|
4482
|
+
liveControl: true
|
|
4483
|
+
};
|
|
4484
|
+
var CODEX_CLI_CAPABILITIES = {
|
|
4485
|
+
freshSessionId: "late-bound",
|
|
4486
|
+
resume: "native",
|
|
4487
|
+
systemPrompt: "positional",
|
|
4488
|
+
structuredQuestions: false,
|
|
4489
|
+
permissionGates: true,
|
|
4490
|
+
liveControl: true
|
|
4491
|
+
};
|
|
4492
|
+
function capabilitiesFor(provider) {
|
|
4493
|
+
switch (provider) {
|
|
4494
|
+
case CLAUDE_CODE_PROVIDER:
|
|
4495
|
+
return CLAUDE_CODE_CAPABILITIES;
|
|
4496
|
+
case CODEX_CLI_PROVIDER:
|
|
4497
|
+
return CODEX_CLI_CAPABILITIES;
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
// src/services/providers/providerHealth.ts
|
|
4502
|
+
var VERIFIED_AGAINST = {
|
|
4503
|
+
[CLAUDE_CODE_PROVIDER]: { captured: ["2.1.214"], min: "2.1.0" },
|
|
4504
|
+
[CODEX_CLI_PROVIDER]: { captured: ["0.140.0-alpha.19"], min: "0.140.0" }
|
|
4505
|
+
};
|
|
4506
|
+
var VERSION_TIMEOUT_MS = 3e3;
|
|
4507
|
+
function parseVersionOutput(output) {
|
|
4508
|
+
const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
4509
|
+
return match ? match[0] : null;
|
|
4510
|
+
}
|
|
4511
|
+
function runVersion(exe) {
|
|
4512
|
+
return new Promise((resolve2) => {
|
|
4513
|
+
execFile2(exe, ["--version"], { timeout: VERSION_TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
4514
|
+
if (err && !stdout && !stderr) return resolve2(null);
|
|
4515
|
+
resolve2(parseVersionOutput(`${stdout}${stderr}`));
|
|
4516
|
+
});
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
function compareToVerified(version, verified) {
|
|
4520
|
+
if (version === null) {
|
|
4521
|
+
return {
|
|
4522
|
+
code: "version_undetectable",
|
|
4523
|
+
message: "Could not determine the installed version, so compatibility is unverified. Parsing and prompt detection may not match this build."
|
|
4524
|
+
};
|
|
4525
|
+
}
|
|
4526
|
+
if (verified.captured.includes(version)) return null;
|
|
4527
|
+
const below = verified.min != null && compareSemver(version, verified.min) < 0;
|
|
4528
|
+
const above = verified.max != null && compareSemver(version, verified.max) > 0;
|
|
4529
|
+
if (!below && !above && verified.max != null) return null;
|
|
4530
|
+
if (!below && verified.max == null && !isNewerThanAllCaptured(version, verified.captured)) {
|
|
4531
|
+
return null;
|
|
4532
|
+
}
|
|
4533
|
+
return {
|
|
4534
|
+
code: "version_unverified",
|
|
4535
|
+
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.`
|
|
4536
|
+
};
|
|
4537
|
+
}
|
|
4538
|
+
function isNewerThanAllCaptured(version, captured) {
|
|
4539
|
+
return captured.every((c) => compareSemver(version, c) > 0);
|
|
4540
|
+
}
|
|
4541
|
+
function compareSemver(a, b) {
|
|
4542
|
+
const parse = (v) => {
|
|
4543
|
+
const [core, pre] = v.split("-", 2);
|
|
4544
|
+
const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
4545
|
+
return { nums, pre: pre ?? null };
|
|
4546
|
+
};
|
|
4547
|
+
const pa = parse(a);
|
|
4548
|
+
const pb = parse(b);
|
|
4549
|
+
for (let i = 0; i < 3; i++) {
|
|
4550
|
+
const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
|
|
4551
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
4552
|
+
}
|
|
4553
|
+
if (pa.pre === pb.pre) return 0;
|
|
4554
|
+
if (pa.pre === null) return 1;
|
|
4555
|
+
if (pb.pre === null) return -1;
|
|
4556
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
4557
|
+
}
|
|
4558
|
+
async function providerHealth(name, resolveExe, detect = runVersion) {
|
|
4559
|
+
const verifiedAgainst = VERIFIED_AGAINST[name];
|
|
4560
|
+
const capabilities = capabilitiesFor(name);
|
|
4561
|
+
let exe;
|
|
4562
|
+
try {
|
|
4563
|
+
exe = resolveExe();
|
|
4564
|
+
} catch {
|
|
4565
|
+
return {
|
|
4566
|
+
name,
|
|
4567
|
+
available: false,
|
|
4568
|
+
version: null,
|
|
4569
|
+
verifiedAgainst,
|
|
4570
|
+
capabilities,
|
|
4571
|
+
warnings: [
|
|
4572
|
+
{
|
|
4573
|
+
code: "provider_not_found",
|
|
4574
|
+
message: `${name} could not be located. Sessions for this provider cannot start.`
|
|
4575
|
+
}
|
|
4576
|
+
]
|
|
4577
|
+
};
|
|
4578
|
+
}
|
|
4579
|
+
const version = await detect(exe);
|
|
4580
|
+
const warning = compareToVerified(version, verifiedAgainst);
|
|
4581
|
+
return {
|
|
4582
|
+
name,
|
|
4583
|
+
available: true,
|
|
4584
|
+
version,
|
|
4585
|
+
verifiedAgainst,
|
|
4586
|
+
capabilities,
|
|
4587
|
+
warnings: warning ? [warning] : []
|
|
4588
|
+
};
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4591
|
+
// src/api/routes/providers.routes.ts
|
|
4592
|
+
var createProviderRoutes = () => {
|
|
4593
|
+
const app = new Hono12();
|
|
4594
|
+
app.get("/", async (c) => {
|
|
4595
|
+
const providers = await Promise.all([
|
|
4596
|
+
providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
|
|
4597
|
+
providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
|
|
4598
|
+
]);
|
|
4599
|
+
return c.json({ providers });
|
|
4600
|
+
});
|
|
4601
|
+
return app;
|
|
4602
|
+
};
|
|
4603
|
+
|
|
3532
4604
|
// src/api/routes/scanner.routes.ts
|
|
3533
|
-
import { Hono as
|
|
4605
|
+
import { Hono as Hono13 } from "hono";
|
|
3534
4606
|
var ALREADY_HANDLED5 = 597;
|
|
3535
4607
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3536
4608
|
var createScannerRoutes = (deps) => {
|
|
3537
|
-
const app = new
|
|
4609
|
+
const app = new Hono13();
|
|
3538
4610
|
app.get("/api/search", async (c) => {
|
|
3539
4611
|
const url = new URL(c.req.url);
|
|
3540
4612
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3544,11 +4616,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3544
4616
|
};
|
|
3545
4617
|
|
|
3546
4618
|
// src/api/routes/sessions.routes.ts
|
|
3547
|
-
import { Hono as
|
|
4619
|
+
import { Hono as Hono14 } from "hono";
|
|
3548
4620
|
var ALREADY_HANDLED6 = 597;
|
|
3549
4621
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3550
4622
|
var createSessionRoutes = (deps) => {
|
|
3551
|
-
const app = new
|
|
4623
|
+
const app = new Hono14();
|
|
3552
4624
|
app.get("/count", (c) => {
|
|
3553
4625
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3554
4626
|
return alreadyHandled6();
|
|
@@ -3615,9 +4687,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3615
4687
|
};
|
|
3616
4688
|
|
|
3617
4689
|
// src/api/routes/ws.routes.ts
|
|
3618
|
-
import { Hono as
|
|
4690
|
+
import { Hono as Hono15 } from "hono";
|
|
3619
4691
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3620
|
-
const app = new
|
|
4692
|
+
const app = new Hono15();
|
|
3621
4693
|
app.get(
|
|
3622
4694
|
"/ws",
|
|
3623
4695
|
upgradeWebSocket(() => {
|
|
@@ -3643,7 +4715,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3643
4715
|
|
|
3644
4716
|
// src/api/app.ts
|
|
3645
4717
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3646
|
-
const app = new
|
|
4718
|
+
const app = new Hono16();
|
|
3647
4719
|
const httpLog = getLogger("http");
|
|
3648
4720
|
app.use("*", async (c, next) => {
|
|
3649
4721
|
const start = Date.now();
|
|
@@ -3668,7 +4740,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3668
4740
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3669
4741
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3670
4742
|
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
4743
|
+
app.route("/api/config", createConfigRoutes(deps));
|
|
3671
4744
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
4745
|
+
app.route("/api/providers", createProviderRoutes());
|
|
4746
|
+
app.route("/api/devices", createDeviceRoutes(deps));
|
|
3672
4747
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3673
4748
|
app.route("/api", createBrowseRoutes(deps));
|
|
3674
4749
|
app.route("/", createScannerRoutes(deps));
|
|
@@ -3878,11 +4953,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
|
3878
4953
|
}
|
|
3879
4954
|
|
|
3880
4955
|
// src/utils/fileIdentity.ts
|
|
3881
|
-
import { createHash } from "crypto";
|
|
4956
|
+
import { createHash as createHash2 } from "crypto";
|
|
3882
4957
|
function fileIdentity(stat3, headBytes) {
|
|
3883
4958
|
if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
|
|
3884
4959
|
const head = headBytes ?? Buffer.alloc(0);
|
|
3885
|
-
return `fp:${
|
|
4960
|
+
return `fp:${createHash2("sha1").update(head).digest("hex")}`;
|
|
3886
4961
|
}
|
|
3887
4962
|
function splitCompleteLines(buf, baseOffset) {
|
|
3888
4963
|
const spans = [];
|
|
@@ -5164,8 +6239,122 @@ var ConversationsRepository = class {
|
|
|
5164
6239
|
}
|
|
5165
6240
|
};
|
|
5166
6241
|
|
|
6242
|
+
// src/db/repositories/managed-sessions.repository.ts
|
|
6243
|
+
var ManagedSessionsRepository = class {
|
|
6244
|
+
upsertStmt;
|
|
6245
|
+
updateStatusStmt;
|
|
6246
|
+
getStmt;
|
|
6247
|
+
listNonTerminalStmt;
|
|
6248
|
+
deleteStmt;
|
|
6249
|
+
constructor(db) {
|
|
6250
|
+
this.upsertStmt = db.prepare(`
|
|
6251
|
+
INSERT INTO managed_sessions (
|
|
6252
|
+
session_id, provider, pid, cmdline, project_path, project_name, branch,
|
|
6253
|
+
status, status_source, status_updated_at, started_at, completed_at,
|
|
6254
|
+
last_activity_at, prompt_count, session_name, project_id,
|
|
6255
|
+
bound_conversation_id, resumed_from_conversation_id, failure_reason,
|
|
6256
|
+
streamer_instance_id
|
|
6257
|
+
) VALUES (
|
|
6258
|
+
@session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
|
|
6259
|
+
@status, @status_source, @status_updated_at, @started_at, @completed_at,
|
|
6260
|
+
@last_activity_at, @prompt_count, @session_name, @project_id,
|
|
6261
|
+
@bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
|
|
6262
|
+
@streamer_instance_id
|
|
6263
|
+
)
|
|
6264
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
6265
|
+
pid = excluded.pid,
|
|
6266
|
+
cmdline = excluded.cmdline,
|
|
6267
|
+
project_path = excluded.project_path,
|
|
6268
|
+
project_name = excluded.project_name,
|
|
6269
|
+
branch = excluded.branch,
|
|
6270
|
+
status = excluded.status,
|
|
6271
|
+
status_source = excluded.status_source,
|
|
6272
|
+
status_updated_at = excluded.status_updated_at,
|
|
6273
|
+
completed_at = excluded.completed_at,
|
|
6274
|
+
last_activity_at = excluded.last_activity_at,
|
|
6275
|
+
prompt_count = excluded.prompt_count,
|
|
6276
|
+
session_name = excluded.session_name,
|
|
6277
|
+
project_id = excluded.project_id,
|
|
6278
|
+
bound_conversation_id = excluded.bound_conversation_id,
|
|
6279
|
+
resumed_from_conversation_id = excluded.resumed_from_conversation_id,
|
|
6280
|
+
failure_reason = excluded.failure_reason,
|
|
6281
|
+
streamer_instance_id = excluded.streamer_instance_id
|
|
6282
|
+
`);
|
|
6283
|
+
this.updateStatusStmt = db.prepare(`
|
|
6284
|
+
UPDATE managed_sessions
|
|
6285
|
+
SET status = @status,
|
|
6286
|
+
status_source = @status_source,
|
|
6287
|
+
status_updated_at = @status_updated_at,
|
|
6288
|
+
completed_at = @completed_at,
|
|
6289
|
+
last_activity_at = @last_activity_at,
|
|
6290
|
+
prompt_count = @prompt_count,
|
|
6291
|
+
failure_reason = COALESCE(@failure_reason, failure_reason)
|
|
6292
|
+
WHERE session_id = @session_id
|
|
6293
|
+
`);
|
|
6294
|
+
this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
|
|
6295
|
+
this.listNonTerminalStmt = db.prepare(`
|
|
6296
|
+
SELECT * FROM managed_sessions
|
|
6297
|
+
WHERE completed_at IS NULL
|
|
6298
|
+
ORDER BY started_at ASC
|
|
6299
|
+
`);
|
|
6300
|
+
this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
|
|
6301
|
+
}
|
|
6302
|
+
/** Record a session at spawn, or refresh every field of an existing row. */
|
|
6303
|
+
recordSpawn({ session, pid, cmdline, streamerInstanceId }) {
|
|
6304
|
+
this.upsertStmt.run({
|
|
6305
|
+
session_id: session.id,
|
|
6306
|
+
provider: session.provider ?? "claude-code",
|
|
6307
|
+
pid,
|
|
6308
|
+
cmdline,
|
|
6309
|
+
project_path: session.projectPath,
|
|
6310
|
+
project_name: session.projectName,
|
|
6311
|
+
branch: session.branch ?? "",
|
|
6312
|
+
status: session.status,
|
|
6313
|
+
status_source: "spawn",
|
|
6314
|
+
status_updated_at: Date.now(),
|
|
6315
|
+
started_at: session.startedAt.getTime(),
|
|
6316
|
+
completed_at: session.completedAt?.getTime() ?? null,
|
|
6317
|
+
last_activity_at: session.lastActivityAt?.getTime() ?? null,
|
|
6318
|
+
prompt_count: session.promptCount,
|
|
6319
|
+
session_name: session.sessionName ?? null,
|
|
6320
|
+
project_id: session.projectId ?? null,
|
|
6321
|
+
bound_conversation_id: session.boundConversationId ?? null,
|
|
6322
|
+
resumed_from_conversation_id: session.resumedFromConversationId ?? null,
|
|
6323
|
+
failure_reason: session.failureReason ?? null,
|
|
6324
|
+
streamer_instance_id: streamerInstanceId
|
|
6325
|
+
});
|
|
6326
|
+
}
|
|
6327
|
+
/**
|
|
6328
|
+
* Persist a status transition. `source` is required rather than defaulted:
|
|
6329
|
+
* a status whose provenance is unknown is the thing this table exists to
|
|
6330
|
+
* prevent, and the reconciler reads it to decide how much to trust the value.
|
|
6331
|
+
*/
|
|
6332
|
+
recordStatus(sessionId, status, source, fields = {}) {
|
|
6333
|
+
this.updateStatusStmt.run({
|
|
6334
|
+
session_id: sessionId,
|
|
6335
|
+
status,
|
|
6336
|
+
status_source: source,
|
|
6337
|
+
status_updated_at: Date.now(),
|
|
6338
|
+
completed_at: fields.completedAt?.getTime() ?? null,
|
|
6339
|
+
last_activity_at: fields.lastActivityAt?.getTime() ?? null,
|
|
6340
|
+
prompt_count: fields.promptCount ?? 0,
|
|
6341
|
+
failure_reason: fields.failureReason ?? null
|
|
6342
|
+
});
|
|
6343
|
+
}
|
|
6344
|
+
get(sessionId) {
|
|
6345
|
+
return this.getStmt.get(sessionId) ?? null;
|
|
6346
|
+
}
|
|
6347
|
+
/** Rows with no recorded completion — the reconciler's probe set. */
|
|
6348
|
+
listNonTerminal() {
|
|
6349
|
+
return this.listNonTerminalStmt.all();
|
|
6350
|
+
}
|
|
6351
|
+
delete(sessionId) {
|
|
6352
|
+
this.deleteStmt.run(sessionId);
|
|
6353
|
+
}
|
|
6354
|
+
};
|
|
6355
|
+
|
|
5167
6356
|
// src/db/repositories/projects.repository.ts
|
|
5168
|
-
import { randomUUID as
|
|
6357
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
5169
6358
|
|
|
5170
6359
|
// src/utils/canonicalizeProjectPath.ts
|
|
5171
6360
|
function canonicalizeProjectPath(projectPath) {
|
|
@@ -5258,7 +6447,7 @@ var ProjectsRepository = class {
|
|
|
5258
6447
|
});
|
|
5259
6448
|
return rowToProject(this.getById.get(existing.id));
|
|
5260
6449
|
}
|
|
5261
|
-
const id =
|
|
6450
|
+
const id = randomUUID4();
|
|
5262
6451
|
this.insert.run({
|
|
5263
6452
|
id,
|
|
5264
6453
|
path,
|
|
@@ -5348,8 +6537,20 @@ function handleListProjects(url, res) {
|
|
|
5348
6537
|
res.end(JSON.stringify({ projects: page, total }));
|
|
5349
6538
|
}
|
|
5350
6539
|
|
|
6540
|
+
// src/lifecycle/process-liveness.ts
|
|
6541
|
+
function isPidAlive(pid) {
|
|
6542
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
6543
|
+
try {
|
|
6544
|
+
process.kill(pid, 0);
|
|
6545
|
+
return true;
|
|
6546
|
+
} catch (err) {
|
|
6547
|
+
const code = err.code;
|
|
6548
|
+
return code === "EPERM";
|
|
6549
|
+
}
|
|
6550
|
+
}
|
|
6551
|
+
|
|
5351
6552
|
// src/pair-store.ts
|
|
5352
|
-
import { randomBytes as
|
|
6553
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
5353
6554
|
var DEFAULT_TTL_SECONDS = 180;
|
|
5354
6555
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
5355
6556
|
var PairTokenStore = class {
|
|
@@ -5364,7 +6565,7 @@ var PairTokenStore = class {
|
|
|
5364
6565
|
}
|
|
5365
6566
|
}
|
|
5366
6567
|
mint() {
|
|
5367
|
-
const token = `pt_${
|
|
6568
|
+
const token = `pt_${randomBytes3(16).toString("hex")}`;
|
|
5368
6569
|
const expiresAt = Date.now() + this.ttlMs;
|
|
5369
6570
|
this.current = { token, expiresAt, used: false };
|
|
5370
6571
|
return {
|
|
@@ -5432,7 +6633,7 @@ function setCacheMetadata(repo, key, value) {
|
|
|
5432
6633
|
}
|
|
5433
6634
|
|
|
5434
6635
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5435
|
-
import { createHash as
|
|
6636
|
+
import { createHash as createHash3 } from "crypto";
|
|
5436
6637
|
import { existsSync as existsSync8 } from "fs";
|
|
5437
6638
|
|
|
5438
6639
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -5497,13 +6698,13 @@ function envInt(name, fallback) {
|
|
|
5497
6698
|
}
|
|
5498
6699
|
function fingerprintOf(ids) {
|
|
5499
6700
|
const sorted = [...ids].sort();
|
|
5500
|
-
return `sha256:${
|
|
6701
|
+
return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5501
6702
|
}
|
|
5502
6703
|
var CacheIntegrityMonitor = class {
|
|
5503
|
-
constructor(cache, wsHub,
|
|
6704
|
+
constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
|
|
5504
6705
|
this.cache = cache;
|
|
5505
6706
|
this.wsHub = wsHub;
|
|
5506
|
-
this.log =
|
|
6707
|
+
this.log = log7;
|
|
5507
6708
|
this.cacheDir = cacheDir;
|
|
5508
6709
|
this.rescan = rescan;
|
|
5509
6710
|
this.runDuringReset = runDuringReset;
|
|
@@ -6059,62 +7260,633 @@ function refreshConversationCache(deps) {
|
|
|
6059
7260
|
};
|
|
6060
7261
|
}
|
|
6061
7262
|
|
|
6062
|
-
// src/services/conversations/shouldRefreshProjectsFromHdd.ts
|
|
6063
|
-
import { readdirSync as readdirSync5, statSync as statSync7 } from "fs";
|
|
6064
|
-
import { homedir as homedir8 } from "os";
|
|
6065
|
-
import { join as join16 } from "path";
|
|
6066
|
-
var DEFAULT_PROJECTS_DIR = join16(homedir8(), ".claude", "projects");
|
|
6067
|
-
function maxProjectsTreeMtimeMs(projectsDir) {
|
|
6068
|
-
let maxMs;
|
|
6069
|
-
try {
|
|
6070
|
-
maxMs = statSync7(projectsDir).mtimeMs;
|
|
6071
|
-
} catch {
|
|
6072
|
-
return null;
|
|
7263
|
+
// src/services/conversations/shouldRefreshProjectsFromHdd.ts
|
|
7264
|
+
import { readdirSync as readdirSync5, statSync as statSync7 } from "fs";
|
|
7265
|
+
import { homedir as homedir8 } from "os";
|
|
7266
|
+
import { join as join16 } from "path";
|
|
7267
|
+
var DEFAULT_PROJECTS_DIR = join16(homedir8(), ".claude", "projects");
|
|
7268
|
+
function maxProjectsTreeMtimeMs(projectsDir) {
|
|
7269
|
+
let maxMs;
|
|
7270
|
+
try {
|
|
7271
|
+
maxMs = statSync7(projectsDir).mtimeMs;
|
|
7272
|
+
} catch {
|
|
7273
|
+
return null;
|
|
7274
|
+
}
|
|
7275
|
+
try {
|
|
7276
|
+
for (const ent of readdirSync5(projectsDir, { withFileTypes: true })) {
|
|
7277
|
+
if (!ent.isDirectory()) continue;
|
|
7278
|
+
try {
|
|
7279
|
+
const childMs = statSync7(join16(projectsDir, ent.name)).mtimeMs;
|
|
7280
|
+
if (childMs > maxMs) maxMs = childMs;
|
|
7281
|
+
} catch {
|
|
7282
|
+
}
|
|
7283
|
+
}
|
|
7284
|
+
} catch {
|
|
7285
|
+
}
|
|
7286
|
+
return maxMs;
|
|
7287
|
+
}
|
|
7288
|
+
function shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, opts = {}) {
|
|
7289
|
+
if (conversationsRepo.hasOrphanRows()) return true;
|
|
7290
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
7291
|
+
if (opts.projectsDirs) {
|
|
7292
|
+
for (const d of opts.projectsDirs) dirs.add(d);
|
|
7293
|
+
}
|
|
7294
|
+
dirs.add(opts.projectsDir ?? DEFAULT_PROJECTS_DIR);
|
|
7295
|
+
let newestMs = null;
|
|
7296
|
+
for (const dir of dirs) {
|
|
7297
|
+
const ms = maxProjectsTreeMtimeMs(dir);
|
|
7298
|
+
if (ms === null) continue;
|
|
7299
|
+
if (newestMs === null || ms > newestMs) newestMs = ms;
|
|
7300
|
+
}
|
|
7301
|
+
if (newestMs === null) return false;
|
|
7302
|
+
const lastIndexedIso = getCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at");
|
|
7303
|
+
if (!lastIndexedIso) return true;
|
|
7304
|
+
const lastIndexedMs = Date.parse(lastIndexedIso);
|
|
7305
|
+
if (Number.isNaN(lastIndexedMs)) return true;
|
|
7306
|
+
return newestMs > lastIndexedMs;
|
|
7307
|
+
}
|
|
7308
|
+
|
|
7309
|
+
// src/services/projectChats/deriveProjectChatTitle.ts
|
|
7310
|
+
function deriveProjectChatTitle(input) {
|
|
7311
|
+
const trimmed = input.title?.trim();
|
|
7312
|
+
if (trimmed) return trimmed;
|
|
7313
|
+
const name = input.projectName?.trim();
|
|
7314
|
+
if (name) return name;
|
|
7315
|
+
const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
|
|
7316
|
+
if (pathSuffix) return pathSuffix;
|
|
7317
|
+
return `Untitled \xB7 ${input.id.slice(0, 8)}`;
|
|
7318
|
+
}
|
|
7319
|
+
|
|
7320
|
+
// src/services/push/apnsClient.ts
|
|
7321
|
+
import { createSign } from "crypto";
|
|
7322
|
+
import { connect, constants } from "http2";
|
|
7323
|
+
var log3 = getLogger("apns");
|
|
7324
|
+
var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
|
|
7325
|
+
var APNS_MAX_PAYLOAD_BYTES = 4096;
|
|
7326
|
+
var JWT_TTL_SECONDS = 3e3;
|
|
7327
|
+
var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
|
|
7328
|
+
"BadDeviceToken",
|
|
7329
|
+
"DeviceTokenNotForTopic",
|
|
7330
|
+
"Unregistered",
|
|
7331
|
+
"ExpiredToken"
|
|
7332
|
+
]);
|
|
7333
|
+
function base64url(input) {
|
|
7334
|
+
return Buffer.from(input).toString("base64url");
|
|
7335
|
+
}
|
|
7336
|
+
function readApnsCredentialsFromEnv(env = process.env) {
|
|
7337
|
+
const key = env.APNS_KEY;
|
|
7338
|
+
if (!key || key.trim().length === 0) return null;
|
|
7339
|
+
const keyId = env.APNS_KEY_ID?.trim();
|
|
7340
|
+
const teamId = env.APNS_TEAM_ID?.trim();
|
|
7341
|
+
const bundleId = env.APNS_BUNDLE_ID?.trim();
|
|
7342
|
+
if (!keyId || !teamId || !bundleId) return null;
|
|
7343
|
+
const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
|
|
7344
|
+
return { key, keyId, teamId, bundleId, host };
|
|
7345
|
+
}
|
|
7346
|
+
function describeMissingApnsCredentials(env = process.env) {
|
|
7347
|
+
if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
|
|
7348
|
+
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.";
|
|
7349
|
+
}
|
|
7350
|
+
const missing = [
|
|
7351
|
+
["APNS_KEY_ID", env.APNS_KEY_ID],
|
|
7352
|
+
["APNS_TEAM_ID", env.APNS_TEAM_ID],
|
|
7353
|
+
["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
|
|
7354
|
+
].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
|
|
7355
|
+
if (missing.length === 0) return null;
|
|
7356
|
+
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.`;
|
|
7357
|
+
}
|
|
7358
|
+
var ApnsClient = class {
|
|
7359
|
+
constructor(creds) {
|
|
7360
|
+
this.creds = creds;
|
|
7361
|
+
}
|
|
7362
|
+
creds;
|
|
7363
|
+
session = null;
|
|
7364
|
+
cachedJwt = null;
|
|
7365
|
+
/**
|
|
7366
|
+
* The `apns-topic` for Live Activity pushes.
|
|
7367
|
+
*
|
|
7368
|
+
* The `.push-type.liveactivity` suffix is mandatory and is why the signing key
|
|
7369
|
+
* must be Team Scoped (All Topics) — a key scoped to the bundle id alone
|
|
7370
|
+
* cannot sign this topic.
|
|
7371
|
+
*/
|
|
7372
|
+
get topic() {
|
|
7373
|
+
return `${this.creds.bundleId}.push-type.liveactivity`;
|
|
7374
|
+
}
|
|
7375
|
+
/**
|
|
7376
|
+
* Mint or reuse the provider JWT.
|
|
7377
|
+
*
|
|
7378
|
+
* ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
|
|
7379
|
+
* token older than an hour, but minting one per request is wasteful and can
|
|
7380
|
+
* trip APNs' provider-token-update throttle.
|
|
7381
|
+
*/
|
|
7382
|
+
getJwt(now = Date.now()) {
|
|
7383
|
+
const nowSeconds = Math.floor(now / 1e3);
|
|
7384
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
|
|
7385
|
+
return this.cachedJwt.token;
|
|
7386
|
+
}
|
|
7387
|
+
const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
|
|
7388
|
+
const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
|
|
7389
|
+
const signingInput = `${header}.${payload}`;
|
|
7390
|
+
const signature = createSign("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
|
|
7391
|
+
const token = `${signingInput}.${base64url(signature)}`;
|
|
7392
|
+
this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
|
|
7393
|
+
return token;
|
|
7394
|
+
}
|
|
7395
|
+
/**
|
|
7396
|
+
* Reuse one HTTP/2 session across sends.
|
|
7397
|
+
*
|
|
7398
|
+
* APNs expects a long-lived connection; a fresh TLS handshake per push is slow
|
|
7399
|
+
* and Apple treats connection churn as abuse.
|
|
7400
|
+
*/
|
|
7401
|
+
getSession() {
|
|
7402
|
+
if (this.session && !this.session.closed && !this.session.destroyed) {
|
|
7403
|
+
return this.session;
|
|
7404
|
+
}
|
|
7405
|
+
const session = connect(`https://${this.creds.host}`);
|
|
7406
|
+
session.on("error", (err) => {
|
|
7407
|
+
log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
|
|
7408
|
+
});
|
|
7409
|
+
this.session = session;
|
|
7410
|
+
return session;
|
|
7411
|
+
}
|
|
7412
|
+
/**
|
|
7413
|
+
* Send one push.
|
|
7414
|
+
*
|
|
7415
|
+
* Resolves with a result rather than rejecting on an APNs rejection: a
|
|
7416
|
+
* rejected push is an expected outcome the caller must act on (retire the
|
|
7417
|
+
* token), not an exception. Only a genuinely unexpected local failure throws,
|
|
7418
|
+
* and the caller logs it.
|
|
7419
|
+
*/
|
|
7420
|
+
async send(args) {
|
|
7421
|
+
const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
|
|
7422
|
+
if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
|
|
7423
|
+
throw new Error(
|
|
7424
|
+
`APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
|
|
7425
|
+
);
|
|
7426
|
+
}
|
|
7427
|
+
const session = this.getSession();
|
|
7428
|
+
const headers = {
|
|
7429
|
+
[constants.HTTP2_HEADER_METHOD]: "POST",
|
|
7430
|
+
[constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
|
|
7431
|
+
[constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
|
|
7432
|
+
"apns-push-type": "liveactivity",
|
|
7433
|
+
"apns-topic": this.topic,
|
|
7434
|
+
"apns-priority": String(args.priority ?? 10),
|
|
7435
|
+
...args.expirationSeconds != null && {
|
|
7436
|
+
"apns-expiration": String(args.expirationSeconds)
|
|
7437
|
+
},
|
|
7438
|
+
[constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
|
|
7439
|
+
[constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
|
|
7440
|
+
};
|
|
7441
|
+
return new Promise((resolve2, reject) => {
|
|
7442
|
+
const req = session.request(headers);
|
|
7443
|
+
req.setTimeout(args.timeoutMs ?? 1e4, () => {
|
|
7444
|
+
req.close(constants.NGHTTP2_CANCEL);
|
|
7445
|
+
resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
|
|
7446
|
+
});
|
|
7447
|
+
let status = 0;
|
|
7448
|
+
req.on("response", (resHeaders) => {
|
|
7449
|
+
status = Number(resHeaders[constants.HTTP2_HEADER_STATUS] ?? 0);
|
|
7450
|
+
});
|
|
7451
|
+
const chunks = [];
|
|
7452
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
7453
|
+
req.on("error", reject);
|
|
7454
|
+
req.on("end", () => {
|
|
7455
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
7456
|
+
let reason;
|
|
7457
|
+
if (raw.length > 0) {
|
|
7458
|
+
try {
|
|
7459
|
+
reason = JSON.parse(raw).reason;
|
|
7460
|
+
} catch {
|
|
7461
|
+
reason = raw.slice(0, 200);
|
|
7462
|
+
}
|
|
7463
|
+
}
|
|
7464
|
+
resolve2({
|
|
7465
|
+
ok: status === 200,
|
|
7466
|
+
status,
|
|
7467
|
+
reason,
|
|
7468
|
+
tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
|
|
7469
|
+
});
|
|
7470
|
+
});
|
|
7471
|
+
req.end(body);
|
|
7472
|
+
});
|
|
7473
|
+
}
|
|
7474
|
+
/** Close the shared connection. Called on server shutdown. */
|
|
7475
|
+
close() {
|
|
7476
|
+
this.session?.close();
|
|
7477
|
+
this.session = null;
|
|
7478
|
+
}
|
|
7479
|
+
};
|
|
7480
|
+
|
|
7481
|
+
// src/services/push/liveActivityContentState.ts
|
|
7482
|
+
var LAST_OUTPUT_MAX_LENGTH = 90;
|
|
7483
|
+
function toLiveActivityStatus(status) {
|
|
7484
|
+
return status === "running" || status === "waiting_input" ? status : null;
|
|
7485
|
+
}
|
|
7486
|
+
function truncateLastOutput(raw) {
|
|
7487
|
+
const oneLine = raw.replace(/\s+/g, " ").trim();
|
|
7488
|
+
return oneLine.length <= LAST_OUTPUT_MAX_LENGTH ? oneLine : oneLine.slice(0, LAST_OUTPUT_MAX_LENGTH);
|
|
7489
|
+
}
|
|
7490
|
+
|
|
7491
|
+
// src/services/push/liveActivityNotifier.ts
|
|
7492
|
+
var log4 = getLogger("live-activity");
|
|
7493
|
+
function contentStateForSession(args) {
|
|
7494
|
+
const status = toLiveActivityStatus(args.session.status);
|
|
7495
|
+
if (!status) return null;
|
|
7496
|
+
return {
|
|
7497
|
+
sessionId: args.session.id,
|
|
7498
|
+
serverId: args.serverId,
|
|
7499
|
+
projectName: args.session.projectName,
|
|
7500
|
+
status,
|
|
7501
|
+
startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
|
|
7502
|
+
lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
|
|
7503
|
+
...args.serverLabel != null && { serverLabel: args.serverLabel }
|
|
7504
|
+
};
|
|
7505
|
+
}
|
|
7506
|
+
var LiveActivityNotifier = class {
|
|
7507
|
+
constructor(sender, serverId, serverLabel) {
|
|
7508
|
+
this.sender = sender;
|
|
7509
|
+
this.serverId = serverId;
|
|
7510
|
+
this.serverLabel = serverLabel;
|
|
7511
|
+
}
|
|
7512
|
+
sender;
|
|
7513
|
+
serverId;
|
|
7514
|
+
serverLabel;
|
|
7515
|
+
/**
|
|
7516
|
+
* Last status pushed per session.
|
|
7517
|
+
*
|
|
7518
|
+
* Live Activity pushes are rate-limited by iOS and the surface only renders
|
|
7519
|
+
* `running` vs `waiting_input`, so re-pushing an unchanged status is pure
|
|
7520
|
+
* budget spend for no visible change. This is what makes the notifier
|
|
7521
|
+
* edge-triggered rather than level-triggered.
|
|
7522
|
+
*/
|
|
7523
|
+
lastPushed = /* @__PURE__ */ new Map();
|
|
7524
|
+
/**
|
|
7525
|
+
* React to a session status change.
|
|
7526
|
+
*
|
|
7527
|
+
* Fire-and-forget by design: a push must never delay or fail a session
|
|
7528
|
+
* transition, so this returns a promise the caller may ignore and every error
|
|
7529
|
+
* is logged rather than propagated.
|
|
7530
|
+
*/
|
|
7531
|
+
async onStatusChange(session) {
|
|
7532
|
+
const status = toLiveActivityStatus(session.status);
|
|
7533
|
+
try {
|
|
7534
|
+
if (!status) {
|
|
7535
|
+
await this.endFor(session);
|
|
7536
|
+
return;
|
|
7537
|
+
}
|
|
7538
|
+
if (this.lastPushed.get(session.id) === status) return;
|
|
7539
|
+
const contentState = contentStateForSession({
|
|
7540
|
+
session,
|
|
7541
|
+
serverId: this.serverId,
|
|
7542
|
+
serverLabel: this.serverLabel
|
|
7543
|
+
});
|
|
7544
|
+
if (!contentState) return;
|
|
7545
|
+
const outcome = await this.sender.send({
|
|
7546
|
+
sessionId: session.id,
|
|
7547
|
+
event: "update",
|
|
7548
|
+
contentState
|
|
7549
|
+
});
|
|
7550
|
+
this.lastPushed.set(session.id, status);
|
|
7551
|
+
if (outcome.attempted > 0) {
|
|
7552
|
+
log4.info("live_activity.updated", {
|
|
7553
|
+
event: "live_activity.updated",
|
|
7554
|
+
sessionId: session.id,
|
|
7555
|
+
status,
|
|
7556
|
+
...outcome
|
|
7557
|
+
});
|
|
7558
|
+
}
|
|
7559
|
+
} catch (err) {
|
|
7560
|
+
log4.error("live_activity.notify_failed", {
|
|
7561
|
+
event: "live_activity.notify_failed",
|
|
7562
|
+
sessionId: session.id,
|
|
7563
|
+
status: session.status,
|
|
7564
|
+
err: String(err)
|
|
7565
|
+
});
|
|
7566
|
+
}
|
|
7567
|
+
}
|
|
7568
|
+
async endFor(session) {
|
|
7569
|
+
const lastStatus = this.lastPushed.get(session.id);
|
|
7570
|
+
this.lastPushed.delete(session.id);
|
|
7571
|
+
const contentState = contentStateForSession({
|
|
7572
|
+
session: {
|
|
7573
|
+
...session,
|
|
7574
|
+
status: lastStatus === "waiting_input" ? "waiting_input" : "running"
|
|
7575
|
+
},
|
|
7576
|
+
serverId: this.serverId,
|
|
7577
|
+
serverLabel: this.serverLabel
|
|
7578
|
+
});
|
|
7579
|
+
if (!contentState) return;
|
|
7580
|
+
const outcome = await this.sender.end({ sessionId: session.id, contentState });
|
|
7581
|
+
if (outcome.attempted > 0) {
|
|
7582
|
+
log4.info("live_activity.ended", {
|
|
7583
|
+
event: "live_activity.ended",
|
|
7584
|
+
sessionId: session.id,
|
|
7585
|
+
...outcome
|
|
7586
|
+
});
|
|
7587
|
+
}
|
|
7588
|
+
}
|
|
7589
|
+
/** Drop cached state for a session, so a resume re-pushes its first status. */
|
|
7590
|
+
forget(sessionId) {
|
|
7591
|
+
this.lastPushed.delete(sessionId);
|
|
7592
|
+
}
|
|
7593
|
+
};
|
|
7594
|
+
|
|
7595
|
+
// src/services/push/liveActivitySender.ts
|
|
7596
|
+
var log5 = getLogger("live-activity");
|
|
7597
|
+
var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
|
|
7598
|
+
function buildActivityKitPayload(args) {
|
|
7599
|
+
return {
|
|
7600
|
+
aps: {
|
|
7601
|
+
timestamp: Math.floor(args.now / 1e3),
|
|
7602
|
+
event: args.event,
|
|
7603
|
+
"content-state": args.contentState,
|
|
7604
|
+
...args.staleDate != null && { "stale-date": Math.floor(args.staleDate / 1e3) },
|
|
7605
|
+
...args.dismissalDate != null && {
|
|
7606
|
+
"dismissal-date": Math.floor(args.dismissalDate / 1e3)
|
|
7607
|
+
}
|
|
7608
|
+
}
|
|
7609
|
+
};
|
|
7610
|
+
}
|
|
7611
|
+
var LiveActivitySender = class {
|
|
7612
|
+
constructor(apns, repo) {
|
|
7613
|
+
this.apns = apns;
|
|
7614
|
+
this.repo = repo;
|
|
7615
|
+
}
|
|
7616
|
+
apns;
|
|
7617
|
+
repo;
|
|
7618
|
+
/**
|
|
7619
|
+
* Push to every live activity of a session.
|
|
7620
|
+
*
|
|
7621
|
+
* Sends are independent: one rejected token must not stop the others, because
|
|
7622
|
+
* a single dead device would otherwise silence every other device watching the
|
|
7623
|
+
* same session.
|
|
7624
|
+
*/
|
|
7625
|
+
async send(args) {
|
|
7626
|
+
const now = args.now ?? Date.now();
|
|
7627
|
+
return this.sendToTokens({
|
|
7628
|
+
tokens: this.repo.listForSession("liveactivity_update", args.sessionId, now),
|
|
7629
|
+
sessionId: args.sessionId,
|
|
7630
|
+
event: args.event,
|
|
7631
|
+
contentState: args.contentState,
|
|
7632
|
+
now,
|
|
7633
|
+
priority: args.priority
|
|
7634
|
+
});
|
|
7635
|
+
}
|
|
7636
|
+
/**
|
|
7637
|
+
* Push to an explicit token list.
|
|
7638
|
+
*
|
|
7639
|
+
* Renewal needs this: a replacement activity does not exist yet, so it is
|
|
7640
|
+
* started via the app-wide push-to-start token rather than any per-session
|
|
7641
|
+
* lookup. Shares one fan-out body with `send()` so failure handling cannot
|
|
7642
|
+
* drift between the two paths.
|
|
7643
|
+
*/
|
|
7644
|
+
async sendToTokens(args) {
|
|
7645
|
+
const now = args.now ?? Date.now();
|
|
7646
|
+
const tokens = args.tokens;
|
|
7647
|
+
const outcome = {
|
|
7648
|
+
attempted: tokens.length,
|
|
7649
|
+
succeeded: 0,
|
|
7650
|
+
retired: 0
|
|
7651
|
+
};
|
|
7652
|
+
if (tokens.length === 0) return outcome;
|
|
7653
|
+
const results = await Promise.all(
|
|
7654
|
+
tokens.map(
|
|
7655
|
+
(row) => this.sendToToken(row, args.event, args.contentState, now, args.priority, args.staleDate)
|
|
7656
|
+
)
|
|
7657
|
+
);
|
|
7658
|
+
for (const { row, result, error } of results) {
|
|
7659
|
+
if (error) {
|
|
7660
|
+
log5.error("live_activity.send_failed", {
|
|
7661
|
+
event: "live_activity.send_failed",
|
|
7662
|
+
sessionId: args.sessionId,
|
|
7663
|
+
activityId: row.activity_id,
|
|
7664
|
+
apnsEvent: args.event,
|
|
7665
|
+
err: String(error)
|
|
7666
|
+
});
|
|
7667
|
+
this.repo.recordFailure(row.token, "SendError", now);
|
|
7668
|
+
continue;
|
|
7669
|
+
}
|
|
7670
|
+
if (!result) continue;
|
|
7671
|
+
if (result.ok) {
|
|
7672
|
+
this.repo.recordSuccess(row.token, now);
|
|
7673
|
+
outcome.succeeded += 1;
|
|
7674
|
+
continue;
|
|
7675
|
+
}
|
|
7676
|
+
this.repo.recordFailure(row.token, result.reason ?? `HTTP_${result.status}`, now);
|
|
7677
|
+
if (result.tokenDead) {
|
|
7678
|
+
this.repo.expire(row.token, now);
|
|
7679
|
+
outcome.retired += 1;
|
|
7680
|
+
}
|
|
7681
|
+
log5.warn("live_activity.send_rejected", {
|
|
7682
|
+
event: "live_activity.send_rejected",
|
|
7683
|
+
sessionId: args.sessionId,
|
|
7684
|
+
activityId: row.activity_id,
|
|
7685
|
+
apnsEvent: args.event,
|
|
7686
|
+
status: result.status,
|
|
7687
|
+
reason: result.reason,
|
|
7688
|
+
tokenDead: result.tokenDead
|
|
7689
|
+
});
|
|
7690
|
+
}
|
|
7691
|
+
return outcome;
|
|
7692
|
+
}
|
|
7693
|
+
/**
|
|
7694
|
+
* End every live activity for a session and stop tracking them.
|
|
7695
|
+
*
|
|
7696
|
+
* Expiring locally is what stops the renewal sweep from later resurrecting an
|
|
7697
|
+
* activity for a session that has already finished.
|
|
7698
|
+
*/
|
|
7699
|
+
async end(args) {
|
|
7700
|
+
const now = args.now ?? Date.now();
|
|
7701
|
+
const outcome = await this.send({
|
|
7702
|
+
sessionId: args.sessionId,
|
|
7703
|
+
event: "end",
|
|
7704
|
+
contentState: args.contentState,
|
|
7705
|
+
now
|
|
7706
|
+
});
|
|
7707
|
+
this.repo.expireSessionActivities(args.sessionId, now);
|
|
7708
|
+
return outcome;
|
|
7709
|
+
}
|
|
7710
|
+
async sendToToken(row, event, contentState, now, priority, staleDateOverride) {
|
|
7711
|
+
const staleDate = event === "update" ? staleDateOverride ?? row.stale_date ?? contentState.startedAt + ACTIVITY_MAX_LIFETIME_MS : null;
|
|
7712
|
+
try {
|
|
7713
|
+
const result = await this.apns.send({
|
|
7714
|
+
deviceToken: row.token,
|
|
7715
|
+
payload: buildActivityKitPayload({ event, contentState, now, staleDate }),
|
|
7716
|
+
priority
|
|
7717
|
+
});
|
|
7718
|
+
return { row, result };
|
|
7719
|
+
} catch (error) {
|
|
7720
|
+
return { row, error };
|
|
7721
|
+
}
|
|
7722
|
+
}
|
|
7723
|
+
};
|
|
7724
|
+
|
|
7725
|
+
// src/services/push/liveActivityRenewal.ts
|
|
7726
|
+
var log6 = getLogger("live-activity");
|
|
7727
|
+
var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
|
|
7728
|
+
var MAX_TIMER_MS = 60 * 60 * 1e3;
|
|
7729
|
+
function renewalDueAt(row) {
|
|
7730
|
+
return row.stale_date == null ? null : row.stale_date - RENEWAL_LEAD_MS;
|
|
7731
|
+
}
|
|
7732
|
+
var LiveActivityRenewalScheduler = class {
|
|
7733
|
+
constructor(deps) {
|
|
7734
|
+
this.deps = deps;
|
|
7735
|
+
this.now = deps.now ?? (() => Date.now());
|
|
7736
|
+
}
|
|
7737
|
+
deps;
|
|
7738
|
+
timer = null;
|
|
7739
|
+
stopped = false;
|
|
7740
|
+
now;
|
|
7741
|
+
/**
|
|
7742
|
+
* Arm the scheduler from persisted state.
|
|
7743
|
+
*
|
|
7744
|
+
* Called on boot, which is what makes a renewal survive a restart: the
|
|
7745
|
+
* deadlines were never in memory to begin with.
|
|
7746
|
+
*/
|
|
7747
|
+
start() {
|
|
7748
|
+
this.stopped = false;
|
|
7749
|
+
void this.tick();
|
|
6073
7750
|
}
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
7751
|
+
stop() {
|
|
7752
|
+
this.stopped = true;
|
|
7753
|
+
if (this.timer) {
|
|
7754
|
+
clearTimeout(this.timer);
|
|
7755
|
+
this.timer = null;
|
|
7756
|
+
}
|
|
7757
|
+
}
|
|
7758
|
+
/**
|
|
7759
|
+
* Renew everything due, then sleep until the next deadline.
|
|
7760
|
+
*
|
|
7761
|
+
* Re-reads from the DB every tick rather than caching a schedule in memory, so
|
|
7762
|
+
* an activity registered after boot is picked up without re-arming anything.
|
|
7763
|
+
*/
|
|
7764
|
+
async tick() {
|
|
7765
|
+
if (this.stopped) return;
|
|
7766
|
+
const now = this.now();
|
|
7767
|
+
try {
|
|
7768
|
+
for (const row of this.deps.repo.listRenewable()) {
|
|
7769
|
+
const dueAt = renewalDueAt(row);
|
|
7770
|
+
if (dueAt == null || dueAt > now) continue;
|
|
7771
|
+
await this.renew(row, now);
|
|
6081
7772
|
}
|
|
7773
|
+
} catch (err) {
|
|
7774
|
+
log6.error("live_activity.renewal_sweep_failed", {
|
|
7775
|
+
event: "live_activity.renewal_sweep_failed",
|
|
7776
|
+
err: String(err)
|
|
7777
|
+
});
|
|
6082
7778
|
}
|
|
6083
|
-
|
|
7779
|
+
this.scheduleNext();
|
|
6084
7780
|
}
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
7781
|
+
scheduleNext() {
|
|
7782
|
+
if (this.stopped) return;
|
|
7783
|
+
const now = this.now();
|
|
7784
|
+
const pending = this.deps.repo.listRenewable().map(renewalDueAt).filter((d) => d != null);
|
|
7785
|
+
const nextDue = pending.length > 0 ? Math.min(...pending) : now + MAX_TIMER_MS;
|
|
7786
|
+
const delay = Math.min(Math.max(nextDue - now, 0), MAX_TIMER_MS);
|
|
7787
|
+
this.timer = setTimeout(() => void this.tick(), delay);
|
|
7788
|
+
this.timer.unref?.();
|
|
6092
7789
|
}
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
7790
|
+
/**
|
|
7791
|
+
* Renew one activity.
|
|
7792
|
+
*
|
|
7793
|
+
* Claims first: `claimRenewal()` succeeds exactly once per row, so a timer
|
|
7794
|
+
* re-armed after a restart mid-window cannot send a second time.
|
|
7795
|
+
*/
|
|
7796
|
+
async renew(row, now) {
|
|
7797
|
+
if (!row.session_id) return;
|
|
7798
|
+
const session = this.deps.sessionStore.getManaged(row.session_id);
|
|
7799
|
+
const status = session ? toLiveActivityStatus(session.status) : null;
|
|
7800
|
+
if (!session || !status) {
|
|
7801
|
+
this.deps.repo.claimRenewal(row.token, now);
|
|
7802
|
+
this.deps.repo.expire(row.token, now);
|
|
7803
|
+
log6.info("live_activity.renewal_skipped", {
|
|
7804
|
+
event: "live_activity.renewal_skipped",
|
|
7805
|
+
sessionId: row.session_id,
|
|
7806
|
+
activityId: row.activity_id,
|
|
7807
|
+
reason: session ? `status_${session.status}` : "session_gone"
|
|
7808
|
+
});
|
|
7809
|
+
return;
|
|
7810
|
+
}
|
|
7811
|
+
if (!this.deps.repo.claimRenewal(row.token, now)) {
|
|
7812
|
+
return;
|
|
7813
|
+
}
|
|
7814
|
+
const startedAt = row.started_at ?? session.startedAt.getTime();
|
|
7815
|
+
const contentState = {
|
|
7816
|
+
sessionId: session.id,
|
|
7817
|
+
serverId: this.deps.serverId,
|
|
7818
|
+
projectName: session.projectName,
|
|
7819
|
+
status,
|
|
7820
|
+
startedAt,
|
|
7821
|
+
lastOutput: session.lastOutput ?? "",
|
|
7822
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
7823
|
+
};
|
|
7824
|
+
try {
|
|
7825
|
+
await this.deps.sender.send({
|
|
7826
|
+
sessionId: session.id,
|
|
7827
|
+
event: "end",
|
|
7828
|
+
contentState: { ...contentState, lastOutput: truncateLastOutput(contentState.lastOutput) },
|
|
7829
|
+
now
|
|
7830
|
+
});
|
|
7831
|
+
this.deps.repo.expire(row.token, now);
|
|
7832
|
+
const started = await this.startReplacement({
|
|
7833
|
+
sessionId: session.id,
|
|
7834
|
+
startedAt,
|
|
7835
|
+
now
|
|
7836
|
+
});
|
|
7837
|
+
log6.info("live_activity.renewed", {
|
|
7838
|
+
event: "live_activity.renewed",
|
|
7839
|
+
sessionId: session.id,
|
|
7840
|
+
activityId: row.activity_id,
|
|
7841
|
+
// Logged because a regression here is invisible on the server and only
|
|
7842
|
+
// shows up as a reset timer on someone's Lock Screen.
|
|
7843
|
+
startedAt,
|
|
7844
|
+
replacementRequested: started
|
|
7845
|
+
});
|
|
7846
|
+
} catch (err) {
|
|
7847
|
+
log6.error("live_activity.renewal_failed", {
|
|
7848
|
+
event: "live_activity.renewal_failed",
|
|
7849
|
+
sessionId: session.id,
|
|
7850
|
+
activityId: row.activity_id,
|
|
7851
|
+
err: String(err)
|
|
7852
|
+
});
|
|
7853
|
+
}
|
|
6099
7854
|
}
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
7855
|
+
/**
|
|
7856
|
+
* Ask the device to start a replacement activity.
|
|
7857
|
+
*
|
|
7858
|
+
* Uses the app-wide push-to-start token, because the replacement does not
|
|
7859
|
+
* exist yet and therefore has no per-activity token. Returns false when the
|
|
7860
|
+
* device never registered one, which is not an error: the app simply cannot be
|
|
7861
|
+
* asked to start an activity remotely, and the next foreground WS update
|
|
7862
|
+
* recreates it.
|
|
7863
|
+
*/
|
|
7864
|
+
async startReplacement(args) {
|
|
7865
|
+
const starters = this.deps.repo.listByKind("liveactivity_start", args.now);
|
|
7866
|
+
if (starters.length === 0) return false;
|
|
7867
|
+
const session = this.deps.sessionStore.getManaged(args.sessionId);
|
|
7868
|
+
const status = session ? toLiveActivityStatus(session.status) : null;
|
|
7869
|
+
if (!session || !status) return false;
|
|
7870
|
+
await this.deps.sender.sendToTokens({
|
|
7871
|
+
tokens: starters,
|
|
7872
|
+
event: "update",
|
|
7873
|
+
sessionId: args.sessionId,
|
|
7874
|
+
contentState: {
|
|
7875
|
+
sessionId: session.id,
|
|
7876
|
+
serverId: this.deps.serverId,
|
|
7877
|
+
projectName: session.projectName,
|
|
7878
|
+
status,
|
|
7879
|
+
// Carried through unchanged — the whole point of the renewal.
|
|
7880
|
+
startedAt: args.startedAt,
|
|
7881
|
+
lastOutput: truncateLastOutput(session.lastOutput ?? ""),
|
|
7882
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
7883
|
+
},
|
|
7884
|
+
now: args.now,
|
|
7885
|
+
staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
|
|
7886
|
+
});
|
|
7887
|
+
return true;
|
|
7888
|
+
}
|
|
7889
|
+
};
|
|
6118
7890
|
|
|
6119
7891
|
// src/services/questions/parseStatusLine.ts
|
|
6120
7892
|
var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
|
|
@@ -6311,6 +8083,112 @@ function conversationBusy(input) {
|
|
|
6311
8083
|
};
|
|
6312
8084
|
}
|
|
6313
8085
|
|
|
8086
|
+
// src/services/sessions/idempotency.ts
|
|
8087
|
+
var IDEMPOTENCY_TTL_MS = 10 * 60 * 1e3;
|
|
8088
|
+
var IDEMPOTENCY_MAX_KEYS = 200;
|
|
8089
|
+
var IdempotencyStore = class {
|
|
8090
|
+
constructor(ttlMs = IDEMPOTENCY_TTL_MS, maxKeys = IDEMPOTENCY_MAX_KEYS) {
|
|
8091
|
+
this.ttlMs = ttlMs;
|
|
8092
|
+
this.maxKeys = maxKeys;
|
|
8093
|
+
}
|
|
8094
|
+
ttlMs;
|
|
8095
|
+
maxKeys;
|
|
8096
|
+
bySession = /* @__PURE__ */ new Map();
|
|
8097
|
+
/**
|
|
8098
|
+
* Previously recorded result for this key, or null if the key is new,
|
|
8099
|
+
* expired, or evicted. A miss always means "treat as a fresh request" —
|
|
8100
|
+
* failing open, because dropping a real prompt is far worse than allowing a
|
|
8101
|
+
* rare duplicate.
|
|
8102
|
+
*/
|
|
8103
|
+
get(sessionId, key, now = Date.now()) {
|
|
8104
|
+
const entries = this.bySession.get(sessionId);
|
|
8105
|
+
if (!entries) return null;
|
|
8106
|
+
const hit = entries.find((e) => e.key === key);
|
|
8107
|
+
if (!hit) return null;
|
|
8108
|
+
if (now - hit.at > this.ttlMs) {
|
|
8109
|
+
this.bySession.set(
|
|
8110
|
+
sessionId,
|
|
8111
|
+
entries.filter((e) => e !== hit)
|
|
8112
|
+
);
|
|
8113
|
+
return null;
|
|
8114
|
+
}
|
|
8115
|
+
return hit.result;
|
|
8116
|
+
}
|
|
8117
|
+
/** Record the outcome of an accepted write so a retry can replay it. */
|
|
8118
|
+
set(sessionId, key, result, now = Date.now()) {
|
|
8119
|
+
const entries = this.bySession.get(sessionId) ?? [];
|
|
8120
|
+
const pruned = entries.filter((e) => e.key !== key && now - e.at <= this.ttlMs);
|
|
8121
|
+
pruned.push({ key, at: now, result });
|
|
8122
|
+
this.bySession.set(sessionId, pruned.slice(-this.maxKeys));
|
|
8123
|
+
}
|
|
8124
|
+
/** Drop everything for a session whose PTY is gone. */
|
|
8125
|
+
clear(sessionId) {
|
|
8126
|
+
this.bySession.delete(sessionId);
|
|
8127
|
+
}
|
|
8128
|
+
/** Test/diagnostic helper: how many keys are currently held for a session. */
|
|
8129
|
+
size(sessionId) {
|
|
8130
|
+
return this.bySession.get(sessionId)?.length ?? 0;
|
|
8131
|
+
}
|
|
8132
|
+
};
|
|
8133
|
+
function readIdempotencyKey(body) {
|
|
8134
|
+
const raw = body.idempotencyKey;
|
|
8135
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
8136
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > 200) {
|
|
8137
|
+
throw new Error("idempotencyKey must be a non-empty string of at most 200 characters");
|
|
8138
|
+
}
|
|
8139
|
+
return raw;
|
|
8140
|
+
}
|
|
8141
|
+
|
|
8142
|
+
// src/services/sessions/reconcileSessions.ts
|
|
8143
|
+
async function classifySession(row, probe, currentInstanceId) {
|
|
8144
|
+
const { session_id: sessionId } = row;
|
|
8145
|
+
if (row.completed_at != null) {
|
|
8146
|
+
const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
|
|
8147
|
+
return {
|
|
8148
|
+
sessionId,
|
|
8149
|
+
lifecycle: clean ? "completed" : "failed",
|
|
8150
|
+
reason: `terminal (${row.status_source})`
|
|
8151
|
+
};
|
|
8152
|
+
}
|
|
8153
|
+
if (row.pid == null) {
|
|
8154
|
+
return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
|
|
8155
|
+
}
|
|
8156
|
+
if (!probe.isPidAlive(row.pid)) {
|
|
8157
|
+
const clean = probe.endedCleanly?.(row) ?? false;
|
|
8158
|
+
if (clean) {
|
|
8159
|
+
return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
|
|
8160
|
+
}
|
|
8161
|
+
return {
|
|
8162
|
+
sessionId,
|
|
8163
|
+
lifecycle: "resumable",
|
|
8164
|
+
reason: "process gone, resumable from provider history"
|
|
8165
|
+
};
|
|
8166
|
+
}
|
|
8167
|
+
const args = await probe.getProcessArgs(row.pid);
|
|
8168
|
+
const token = row.cmdline;
|
|
8169
|
+
if (!token || !args?.includes(token)) {
|
|
8170
|
+
return {
|
|
8171
|
+
sessionId,
|
|
8172
|
+
lifecycle: "orphaned",
|
|
8173
|
+
reason: args ? "pid alive but command line does not match" : "pid alive but argv unreadable"
|
|
8174
|
+
};
|
|
8175
|
+
}
|
|
8176
|
+
const sameRun = row.streamer_instance_id === currentInstanceId;
|
|
8177
|
+
return {
|
|
8178
|
+
sessionId,
|
|
8179
|
+
lifecycle: sameRun ? "attached" : "detached",
|
|
8180
|
+
reason: sameRun ? "owned by this run" : "survived a previous streamer run"
|
|
8181
|
+
};
|
|
8182
|
+
}
|
|
8183
|
+
async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
8184
|
+
return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
|
|
8185
|
+
}
|
|
8186
|
+
|
|
8187
|
+
// src/types.ts
|
|
8188
|
+
function confidenceForSource(source) {
|
|
8189
|
+
return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
|
|
8190
|
+
}
|
|
8191
|
+
|
|
6314
8192
|
// src/session-store.ts
|
|
6315
8193
|
var SessionStore = class {
|
|
6316
8194
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -6450,6 +8328,14 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6450
8328
|
conversationId: s.id,
|
|
6451
8329
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
6452
8330
|
status: s.status,
|
|
8331
|
+
// Lifecycle for a session this run knows about. `attached` while we hold
|
|
8332
|
+
// its PTY; once the PTY is gone the session is terminal from this run's
|
|
8333
|
+
// perspective — `failed` when it recorded a reason, else `completed`.
|
|
8334
|
+
// Sessions left by *previous* runs never reach here: they aren't in the
|
|
8335
|
+
// in-memory store, and the boot reconciler classifies them instead
|
|
8336
|
+
// (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
8337
|
+
lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
|
|
8338
|
+
lifecycleSource: ptyAttached ? "spawn" : "exit",
|
|
6453
8339
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6454
8340
|
// `activity` is attached for managed sessions.
|
|
6455
8341
|
ownership: "managed",
|
|
@@ -6473,6 +8359,13 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6473
8359
|
...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
|
|
6474
8360
|
...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
|
|
6475
8361
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt.toISOString() },
|
|
8362
|
+
// C3: how the status was derived, and how far to trust it. Confidence is
|
|
8363
|
+
// derived from the source rather than stored, so the two cannot disagree.
|
|
8364
|
+
...s.statusSource != null && {
|
|
8365
|
+
statusSource: s.statusSource,
|
|
8366
|
+
statusConfidence: confidenceForSource(s.statusSource)
|
|
8367
|
+
},
|
|
8368
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt.toISOString() },
|
|
6476
8369
|
...s.filePath != null && { filePath: s.filePath },
|
|
6477
8370
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
6478
8371
|
...s.resumedFromConversationId != null && {
|
|
@@ -6494,6 +8387,12 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6494
8387
|
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6495
8388
|
// report "gone" here — a vanished process simply stops being listed.
|
|
6496
8389
|
processLiveness: "alive",
|
|
8390
|
+
// Alive, but spawned outside this streamer, so we hold no PTY for it. That
|
|
8391
|
+
// is precisely `detached` — and it is strictly more informative than the
|
|
8392
|
+
// `status: "idle"` above, which discovery is forced to report because it
|
|
8393
|
+
// cannot see the process's prompt state.
|
|
8394
|
+
lifecycle: "detached",
|
|
8395
|
+
lifecycleSource: "probe",
|
|
6497
8396
|
projectPath: d.projectPath,
|
|
6498
8397
|
projectName: d.projectName,
|
|
6499
8398
|
branch: d.branch,
|
|
@@ -6508,7 +8407,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6508
8407
|
}
|
|
6509
8408
|
|
|
6510
8409
|
// src/uploads.ts
|
|
6511
|
-
import { randomBytes as
|
|
8410
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
6512
8411
|
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
6513
8412
|
import heicConvert from "heic-convert";
|
|
6514
8413
|
import { join as join17 } from "path";
|
|
@@ -6542,7 +8441,7 @@ async function saveUploadFile(input) {
|
|
|
6542
8441
|
mimeType = "image/jpeg";
|
|
6543
8442
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
6544
8443
|
}
|
|
6545
|
-
const id = `up_${
|
|
8444
|
+
const id = `up_${randomBytes4(8).toString("hex")}`;
|
|
6546
8445
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
6547
8446
|
const dir = join17(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
6548
8447
|
await mkdir3(dir, { recursive: true });
|
|
@@ -6576,21 +8475,46 @@ function extractCodexText(content) {
|
|
|
6576
8475
|
return "";
|
|
6577
8476
|
}).filter(Boolean).join("").trim();
|
|
6578
8477
|
}
|
|
6579
|
-
|
|
8478
|
+
var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
|
|
8479
|
+
function classifyCodexLine(line) {
|
|
6580
8480
|
let entry;
|
|
6581
8481
|
try {
|
|
6582
8482
|
entry = JSON.parse(line);
|
|
6583
8483
|
} catch {
|
|
6584
|
-
return
|
|
8484
|
+
return { kind: "unknown", raw: line, reason: "line is not valid JSON" };
|
|
8485
|
+
}
|
|
8486
|
+
if (typeof entry.type !== "string" || !KNOWN_CODEX_TYPES.has(entry.type)) {
|
|
8487
|
+
return {
|
|
8488
|
+
kind: "unknown",
|
|
8489
|
+
raw: line,
|
|
8490
|
+
reason: `unrecognized rollout envelope type: ${String(entry.type)}`
|
|
8491
|
+
};
|
|
8492
|
+
}
|
|
8493
|
+
if (entry.type !== "response_item") {
|
|
8494
|
+
return { kind: "ignored", reason: `${entry.type} carries no chat content` };
|
|
6585
8495
|
}
|
|
6586
|
-
if (entry.type !== "response_item") return null;
|
|
6587
8496
|
const payload = entry.payload;
|
|
6588
|
-
if (payload?.type !== "message")
|
|
8497
|
+
if (payload?.type !== "message") {
|
|
8498
|
+
return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
|
|
8499
|
+
}
|
|
6589
8500
|
const role = payload.role;
|
|
6590
|
-
if (role !== "user" && role !== "assistant")
|
|
8501
|
+
if (role !== "user" && role !== "assistant") {
|
|
8502
|
+
return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
|
|
8503
|
+
}
|
|
6591
8504
|
const text = extractCodexText(payload.content);
|
|
6592
|
-
if (!text)
|
|
6593
|
-
|
|
8505
|
+
if (!text) {
|
|
8506
|
+
return { kind: "ignored", reason: "message has no extractable text" };
|
|
8507
|
+
}
|
|
8508
|
+
if (role === "user" && isCodexInjectedContext(text)) {
|
|
8509
|
+
return { kind: "ignored", reason: "synthetic injected context" };
|
|
8510
|
+
}
|
|
8511
|
+
return { kind: "message", line: buildClaudeShapedLine(entry, payload, role, text) };
|
|
8512
|
+
}
|
|
8513
|
+
function normalizeCodexLineToClaudeShape(line) {
|
|
8514
|
+
const result = classifyCodexLine(line);
|
|
8515
|
+
return result.kind === "message" ? result.line : null;
|
|
8516
|
+
}
|
|
8517
|
+
function buildClaudeShapedLine(entry, payload, role, text) {
|
|
6594
8518
|
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6595
8519
|
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
6596
8520
|
return JSON.stringify({
|
|
@@ -6646,13 +8570,13 @@ function hashPrefix(text) {
|
|
|
6646
8570
|
}
|
|
6647
8571
|
|
|
6648
8572
|
// src/utils/conversationEtag.ts
|
|
6649
|
-
import { createHash as
|
|
8573
|
+
import { createHash as createHash4 } from "crypto";
|
|
6650
8574
|
function computeConversationEtag({
|
|
6651
8575
|
filePath,
|
|
6652
8576
|
messageCount,
|
|
6653
8577
|
timestamp: timestamp2
|
|
6654
8578
|
}) {
|
|
6655
|
-
const digest =
|
|
8579
|
+
const digest = createHash4("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
6656
8580
|
return `"${digest}"`;
|
|
6657
8581
|
}
|
|
6658
8582
|
|
|
@@ -6795,6 +8719,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
|
|
|
6795
8719
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
6796
8720
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6797
8721
|
var GRACE_MAX_DEFERS = 4;
|
|
8722
|
+
var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
|
|
8723
|
+
var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
|
|
6798
8724
|
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6799
8725
|
var DISCOVERY_TTL_MS = 15e3;
|
|
6800
8726
|
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
@@ -6913,9 +8839,25 @@ var StreamerServer = class {
|
|
|
6913
8839
|
sessionInputAttempts = /* @__PURE__ */ new Map();
|
|
6914
8840
|
ptyGracePeriodMs;
|
|
6915
8841
|
defaultSystemPrompt;
|
|
8842
|
+
// Resolved once at boot; see src/feature-flags.ts. Total map — every registry
|
|
8843
|
+
// id is present, so indexing it never yields undefined.
|
|
8844
|
+
featureFlags;
|
|
8845
|
+
// Derived from featureFlags.codexSystemPrompt. Kept as its own field so the
|
|
8846
|
+
// read site in startFresh() is unchanged.
|
|
8847
|
+
codexSystemPromptEnabled;
|
|
6916
8848
|
defaultPermissionMode;
|
|
6917
8849
|
defaultModel;
|
|
6918
8850
|
defaultEffort;
|
|
8851
|
+
// Allowlisted Claude CLI flags + free-text escape hatch, applied to every
|
|
8852
|
+
// spawn. Resolved once at startup (flag → server.yaml), then mutated in place
|
|
8853
|
+
// by PUT /api/config/claude-flags so a change applies to the next session
|
|
8854
|
+
// without a restart.
|
|
8855
|
+
claudeFlags;
|
|
8856
|
+
claudeExtraArgs;
|
|
8857
|
+
// True when the values came from server.yaml (and so a write persists).
|
|
8858
|
+
// False when they were pinned by a CLI flag, mirroring the api-key rotate
|
|
8859
|
+
// contract: the write still takes effect in memory but won't survive restart.
|
|
8860
|
+
claudeFlagsPersistable;
|
|
6919
8861
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6920
8862
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6921
8863
|
// Consecutive grace-timer defers for a still-`running` session (see
|
|
@@ -6923,6 +8865,18 @@ var StreamerServer = class {
|
|
|
6923
8865
|
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6924
8866
|
// Map of sessionId → set of subscribed WS clients
|
|
6925
8867
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
8868
|
+
// sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
|
|
8869
|
+
// every provider; read only by the idle reaper. Entries are dropped when the
|
|
8870
|
+
// session leaves the runner (reap/exit/hold).
|
|
8871
|
+
lastAgentChunkAt = /* @__PURE__ */ new Map();
|
|
8872
|
+
// Recently accepted input idempotency keys (C4). A retried POST replays its
|
|
8873
|
+
// original outcome instead of submitting the prompt to the agent twice.
|
|
8874
|
+
idempotency = new IdempotencyStore();
|
|
8875
|
+
// sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
|
|
8876
|
+
// this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
|
|
8877
|
+
sessionLifecycles = /* @__PURE__ */ new Map();
|
|
8878
|
+
// Periodic sweep that releases PTYs no agent is using. Null until listen().
|
|
8879
|
+
idleReaperTimer = null;
|
|
6926
8880
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
6927
8881
|
clientIdToWs = /* @__PURE__ */ new Map();
|
|
6928
8882
|
// Reverse map for cleanup on close
|
|
@@ -6932,7 +8886,26 @@ var StreamerServer = class {
|
|
|
6932
8886
|
projectsRepo = null;
|
|
6933
8887
|
conversationsRepo = null;
|
|
6934
8888
|
sessionsRepo = null;
|
|
8889
|
+
// Durable session registry (C1 Phase 2). Null when the cache DB failed to
|
|
8890
|
+
// open — persistence degrades to today's in-memory-only behaviour rather than
|
|
8891
|
+
// taking the server down with it, so every write goes through `?.`.
|
|
8892
|
+
managedSessionsRepo = null;
|
|
8893
|
+
// Identifies this streamer run. A registry row carrying a different id is a
|
|
8894
|
+
// session that outlived the process that started it.
|
|
8895
|
+
streamerInstanceId = randomUUID5();
|
|
6935
8896
|
cacheMetadataRepo = null;
|
|
8897
|
+
// Push registration + delivery state (C7). Null when the cache DB failed to
|
|
8898
|
+
// open — registration then degrades to a no-op rather than 500ing.
|
|
8899
|
+
pushRepo = null;
|
|
8900
|
+
// Paired-device registry (C5). Null when the cache DB failed to open — auth
|
|
8901
|
+
// then falls back to the shared API key alone, which is the pre-C5 behaviour.
|
|
8902
|
+
devicesRepo = null;
|
|
8903
|
+
// Live Activity push (Feature 12). Null when APNS_KEY is unset — the ordinary
|
|
8904
|
+
// case on a dev machine and in CI, where the feature is simply off. Missing an
|
|
8905
|
+
// optional push credential must never stop the server from booting.
|
|
8906
|
+
apnsClient = null;
|
|
8907
|
+
liveActivityNotifier = null;
|
|
8908
|
+
liveActivityRenewal = null;
|
|
6936
8909
|
discoveryCache = null;
|
|
6937
8910
|
cacheDir;
|
|
6938
8911
|
tailSize;
|
|
@@ -6968,9 +8941,17 @@ var StreamerServer = class {
|
|
|
6968
8941
|
this.codexRoots = config.codexRoots ?? [join18(homedir9(), ".codex", "sessions")];
|
|
6969
8942
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
6970
8943
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
8944
|
+
this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
|
|
8945
|
+
if (config.codexSystemPromptEnabled !== void 0) {
|
|
8946
|
+
this.featureFlags.codexSystemPrompt = config.codexSystemPromptEnabled;
|
|
8947
|
+
}
|
|
8948
|
+
this.codexSystemPromptEnabled = this.featureFlags.codexSystemPrompt;
|
|
6971
8949
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6972
8950
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6973
8951
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
8952
|
+
this.claudeFlagsPersistable = config.claudeFlags === void 0;
|
|
8953
|
+
this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
|
|
8954
|
+
this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
6974
8955
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
|
|
6975
8956
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6976
8957
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
@@ -6980,6 +8961,13 @@ var StreamerServer = class {
|
|
|
6980
8961
|
}, this.directoryDebounceMs);
|
|
6981
8962
|
this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
|
|
6982
8963
|
this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
|
|
8964
|
+
const enabledFlags = nonDefaultFeatureFlags(this.featureFlags);
|
|
8965
|
+
if (enabledFlags.length > 0) {
|
|
8966
|
+
this.log.info(`Feature flags active: ${enabledFlags.join(", ")}`, {
|
|
8967
|
+
event: "config.feature_flags_active",
|
|
8968
|
+
flags: enabledFlags
|
|
8969
|
+
});
|
|
8970
|
+
}
|
|
6983
8971
|
const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
|
|
6984
8972
|
if (rawRoot) {
|
|
6985
8973
|
realpath2(rawRoot).then((resolved) => {
|
|
@@ -7095,6 +9083,7 @@ var StreamerServer = class {
|
|
|
7095
9083
|
this.ptyManager = new LiveSessionManager({
|
|
7096
9084
|
logger: getLogger("pty"),
|
|
7097
9085
|
onOutput: (sessionId, data) => {
|
|
9086
|
+
this.lastAgentChunkAt.set(sessionId, Date.now());
|
|
7098
9087
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
7099
9088
|
},
|
|
7100
9089
|
onUserMessage: (sessionId, text, ts) => {
|
|
@@ -7123,6 +9112,17 @@ var StreamerServer = class {
|
|
|
7123
9112
|
completedAt: session.completedAt,
|
|
7124
9113
|
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
|
|
7125
9114
|
});
|
|
9115
|
+
this.managedSessionsRepo?.recordStatus(
|
|
9116
|
+
session.id,
|
|
9117
|
+
session.status,
|
|
9118
|
+
session.completedAt != null ? "exit" : "transition",
|
|
9119
|
+
{
|
|
9120
|
+
completedAt: session.completedAt,
|
|
9121
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
9122
|
+
promptCount: session.promptCount,
|
|
9123
|
+
failureReason: session.failureReason ?? null
|
|
9124
|
+
}
|
|
9125
|
+
);
|
|
7126
9126
|
if (session.status === "waiting_input" || session.status === "idle") {
|
|
7127
9127
|
const filePath = this.sessionFileMap.get(session.id);
|
|
7128
9128
|
if (filePath) {
|
|
@@ -7161,6 +9161,7 @@ var StreamerServer = class {
|
|
|
7161
9161
|
if (resp) {
|
|
7162
9162
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
7163
9163
|
}
|
|
9164
|
+
void this.liveActivityNotifier?.onStatusChange(session);
|
|
7164
9165
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status);
|
|
7165
9166
|
}
|
|
7166
9167
|
});
|
|
@@ -7194,6 +9195,9 @@ var StreamerServer = class {
|
|
|
7194
9195
|
localNoAuth: this.localNoAuth,
|
|
7195
9196
|
logMenubarRequests: this.logMenubarRequests,
|
|
7196
9197
|
rotateApiKey: () => this.rotateApiKey(),
|
|
9198
|
+
claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
|
|
9199
|
+
featureFlagsConfig: () => this.getFeatureFlagsConfig(),
|
|
9200
|
+
setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
|
|
7197
9201
|
publicUrl: this.publicUrl,
|
|
7198
9202
|
browseRoot: this.browseRoot,
|
|
7199
9203
|
browserCors: this.browserCors,
|
|
@@ -7202,6 +9206,8 @@ var StreamerServer = class {
|
|
|
7202
9206
|
wsHub: this.wsHub,
|
|
7203
9207
|
cache: () => this.cache,
|
|
7204
9208
|
cacheMonitor: () => this.cacheMonitor,
|
|
9209
|
+
pushRepo: () => this.pushRepo,
|
|
9210
|
+
devicesRepo: () => this.devicesRepo,
|
|
7205
9211
|
projectsRepo: () => this.projectsRepo,
|
|
7206
9212
|
conversationsRepo: () => this.conversationsRepo,
|
|
7207
9213
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -7235,7 +9241,9 @@ var StreamerServer = class {
|
|
|
7235
9241
|
handleMkdir: (req, res) => this.handleMkdir(req, res),
|
|
7236
9242
|
handleWsOpen: (ws) => {
|
|
7237
9243
|
this.wsHub.addClient(ws);
|
|
7238
|
-
const sessions = this.
|
|
9244
|
+
const sessions = this.withReconciledLifecycle(
|
|
9245
|
+
this.sessionStore.list(this.ptyAttachedIds())
|
|
9246
|
+
);
|
|
7239
9247
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
7240
9248
|
if (!this.currentWarmupState()) {
|
|
7241
9249
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
@@ -7311,11 +9319,8 @@ var StreamerServer = class {
|
|
|
7311
9319
|
this.clientIdToWs.delete(clientId);
|
|
7312
9320
|
this.wsToClientId.delete(ws);
|
|
7313
9321
|
}
|
|
7314
|
-
for (const
|
|
9322
|
+
for (const subscribers of this.sessionSubscribers.values()) {
|
|
7315
9323
|
subscribers.delete(ws);
|
|
7316
|
-
if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
|
|
7317
|
-
this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
|
|
7318
|
-
}
|
|
7319
9324
|
}
|
|
7320
9325
|
},
|
|
7321
9326
|
agentClient,
|
|
@@ -7378,7 +9383,7 @@ var StreamerServer = class {
|
|
|
7378
9383
|
const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
|
|
7379
9384
|
const payload = {
|
|
7380
9385
|
type: "session_list",
|
|
7381
|
-
sessions: this.sessionStore.list(this.ptyAttachedIds())
|
|
9386
|
+
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
7382
9387
|
};
|
|
7383
9388
|
if (ws) {
|
|
7384
9389
|
this.wsHub.unicast(ws, payload);
|
|
@@ -7386,6 +9391,29 @@ var StreamerServer = class {
|
|
|
7386
9391
|
this.wsHub.broadcast(payload);
|
|
7387
9392
|
}
|
|
7388
9393
|
}
|
|
9394
|
+
/**
|
|
9395
|
+
* Overlay boot-reconciliation verdicts onto session responses.
|
|
9396
|
+
*
|
|
9397
|
+
* A session left by a previous run is not in the in-memory store, so
|
|
9398
|
+
* SessionStore cannot classify it — it only ever sees what this run spawned.
|
|
9399
|
+
* Discovery may still surface the process, in which case the reconciler knows
|
|
9400
|
+
* strictly more about it than discovery does: it can tell `detached` (alive
|
|
9401
|
+
* and confirmed ours) from `orphaned` (alive but identity unconfirmed), which
|
|
9402
|
+
* a pid enumeration alone cannot.
|
|
9403
|
+
*
|
|
9404
|
+
* Only applied when the session is NOT live here: a session this run owns has
|
|
9405
|
+
* an authoritative lifecycle already, and a stale verdict must never override
|
|
9406
|
+
* it.
|
|
9407
|
+
*/
|
|
9408
|
+
withReconciledLifecycle(sessions) {
|
|
9409
|
+
if (this.sessionLifecycles.size === 0) return sessions;
|
|
9410
|
+
return sessions.map((s) => {
|
|
9411
|
+
if (s.ptyAttached) return s;
|
|
9412
|
+
const verdict = this.sessionLifecycles.get(s.id);
|
|
9413
|
+
if (!verdict) return s;
|
|
9414
|
+
return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
|
|
9415
|
+
});
|
|
9416
|
+
}
|
|
7389
9417
|
addSessionSubscriber(sessionId, ws) {
|
|
7390
9418
|
let subs = this.sessionSubscribers.get(sessionId);
|
|
7391
9419
|
if (!subs) {
|
|
@@ -7400,6 +9428,216 @@ var StreamerServer = class {
|
|
|
7400
9428
|
}
|
|
7401
9429
|
this.ptyGraceDeferCounts.delete(sessionId);
|
|
7402
9430
|
}
|
|
9431
|
+
/**
|
|
9432
|
+
* Bring up Live Activity push, if credentials are present (Feature 12).
|
|
9433
|
+
*
|
|
9434
|
+
* APNS_KEY absent is the ordinary case on a dev machine and in CI, so this
|
|
9435
|
+
* logs once at info and leaves the feature off rather than failing: the server
|
|
9436
|
+
* must not refuse to boot over a missing optional push credential.
|
|
9437
|
+
*
|
|
9438
|
+
* The key is read from the environment as PEM contents and never from a path
|
|
9439
|
+
* on disk; neither it nor any device token is ever logged.
|
|
9440
|
+
*/
|
|
9441
|
+
initLiveActivityPush(pushRepo) {
|
|
9442
|
+
const creds = readApnsCredentialsFromEnv();
|
|
9443
|
+
if (!creds) {
|
|
9444
|
+
const why = describeMissingApnsCredentials();
|
|
9445
|
+
if (why) this.log.info(why, { event: "live_activity.disabled" });
|
|
9446
|
+
return;
|
|
9447
|
+
}
|
|
9448
|
+
this.apnsClient = new ApnsClient(creds);
|
|
9449
|
+
const sender = new LiveActivitySender(this.apnsClient, pushRepo);
|
|
9450
|
+
const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname2();
|
|
9451
|
+
this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, hostname2());
|
|
9452
|
+
this.liveActivityRenewal = new LiveActivityRenewalScheduler({
|
|
9453
|
+
repo: pushRepo,
|
|
9454
|
+
sender,
|
|
9455
|
+
sessionStore: this.sessionStore,
|
|
9456
|
+
serverId,
|
|
9457
|
+
serverLabel: hostname2()
|
|
9458
|
+
});
|
|
9459
|
+
this.liveActivityRenewal.start();
|
|
9460
|
+
this.log.info("Live Activity push enabled", {
|
|
9461
|
+
event: "live_activity.enabled",
|
|
9462
|
+
host: creds.host,
|
|
9463
|
+
topic: `${creds.bundleId}.push-type.liveactivity`
|
|
9464
|
+
});
|
|
9465
|
+
}
|
|
9466
|
+
/**
|
|
9467
|
+
* Classify sessions left behind by previous streamer runs (C1 Phase 3a).
|
|
9468
|
+
*
|
|
9469
|
+
* Agents already outlive the streamer today on the crash and dev-takeover
|
|
9470
|
+
* paths, which exit without reaching ptyManager.dispose() — they are just
|
|
9471
|
+
* invisible when they do, because nothing recorded that they existed. This
|
|
9472
|
+
* turns those rows into an explicit verdict per session.
|
|
9473
|
+
*
|
|
9474
|
+
* Read-only with respect to processes: it probes and classifies, and never
|
|
9475
|
+
* signals anything. `orphaned` is a report, not a cleanup trigger.
|
|
9476
|
+
*/
|
|
9477
|
+
async reconcilePreviousSessions() {
|
|
9478
|
+
if (!this.managedSessionsRepo) return [];
|
|
9479
|
+
let verdicts = [];
|
|
9480
|
+
try {
|
|
9481
|
+
const rows = this.managedSessionsRepo.listNonTerminal();
|
|
9482
|
+
if (rows.length === 0) return [];
|
|
9483
|
+
verdicts = await reconcileSessions(
|
|
9484
|
+
rows,
|
|
9485
|
+
{ isPidAlive, getProcessArgs },
|
|
9486
|
+
this.streamerInstanceId
|
|
9487
|
+
);
|
|
9488
|
+
for (const v of verdicts) {
|
|
9489
|
+
this.sessionLifecycles.set(v.sessionId, v.lifecycle);
|
|
9490
|
+
if (v.lifecycle === "completed" || v.lifecycle === "failed") {
|
|
9491
|
+
this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
|
|
9492
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
9493
|
+
});
|
|
9494
|
+
}
|
|
9495
|
+
}
|
|
9496
|
+
this.log.info(`[reconcile] classified ${verdicts.length} session(s) from previous runs`, {
|
|
9497
|
+
event: "sessions.reconciled",
|
|
9498
|
+
counts: verdicts.reduce((acc, v) => {
|
|
9499
|
+
acc[v.lifecycle] = (acc[v.lifecycle] ?? 0) + 1;
|
|
9500
|
+
return acc;
|
|
9501
|
+
}, {})
|
|
9502
|
+
});
|
|
9503
|
+
} catch (err) {
|
|
9504
|
+
this.log.warn("[reconcile] failed to reconcile previous sessions", {
|
|
9505
|
+
event: "sessions.reconcile_failed",
|
|
9506
|
+
err
|
|
9507
|
+
});
|
|
9508
|
+
}
|
|
9509
|
+
return verdicts;
|
|
9510
|
+
}
|
|
9511
|
+
/**
|
|
9512
|
+
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
9513
|
+
* reconciler's pid-reuse guard.
|
|
9514
|
+
*
|
|
9515
|
+
* Claude always passes the session id (`--resume <id>` or `--session-id
|
|
9516
|
+
* <id>`), so it is both present and unique. Codex only does on *resume*
|
|
9517
|
+
* (`codex resume <id>`); a fresh Codex spawn is `codex --cd <path>
|
|
9518
|
+
* --no-alt-screen` with no id at all, because the rollout id does not exist
|
|
9519
|
+
* until the CLI writes it. boundConversationId is what distinguishes the two:
|
|
9520
|
+
* it is set once that rollout has been discovered.
|
|
9521
|
+
*/
|
|
9522
|
+
spawnArgvToken(session) {
|
|
9523
|
+
if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
|
|
9524
|
+
return session.boundConversationId ?? session.projectPath;
|
|
9525
|
+
}
|
|
9526
|
+
/**
|
|
9527
|
+
* Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
|
|
9528
|
+
*
|
|
9529
|
+
* Called at each addManaged() site rather than inside SessionStore, because
|
|
9530
|
+
* the store is a pure in-memory structure with no DB dependency and adding
|
|
9531
|
+
* one would drag persistence into every unit test that touches it.
|
|
9532
|
+
*
|
|
9533
|
+
* Best-effort by design: a failed registry write must never break session
|
|
9534
|
+
* start. Losing a row costs post-restart *visibility* for that session, which
|
|
9535
|
+
* is strictly better than refusing to run the agent at all.
|
|
9536
|
+
*/
|
|
9537
|
+
recordSessionSpawn(session) {
|
|
9538
|
+
if (!this.managedSessionsRepo) return;
|
|
9539
|
+
try {
|
|
9540
|
+
const pid = this.ptyManager.getPid(session.id);
|
|
9541
|
+
this.managedSessionsRepo.recordSpawn({
|
|
9542
|
+
session,
|
|
9543
|
+
pid,
|
|
9544
|
+
// Identity guard against pid reuse: the reconciler requires this token
|
|
9545
|
+
// to appear in the live process's argv before it will claim the pid is
|
|
9546
|
+
// still ours (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
9547
|
+
//
|
|
9548
|
+
// Reading the real argv here would cost an async `ps` per session start
|
|
9549
|
+
// on a path the user is waiting on, so we record a token we already
|
|
9550
|
+
// know is in it. Claude always carries the session id (`--resume <id>`
|
|
9551
|
+
// on resume, `--session-id <id>` on fresh). A *fresh* Codex spawn does
|
|
9552
|
+
// not — its argv is only `--cd <path> --no-alt-screen`, because the
|
|
9553
|
+
// rollout id doesn't exist yet — so fall back to the project path,
|
|
9554
|
+
// which is present in every spawn path for both providers.
|
|
9555
|
+
//
|
|
9556
|
+
// The fallback is weaker: two sessions in one project share a token, so
|
|
9557
|
+
// it proves "a process of ours in this project" rather than "this exact
|
|
9558
|
+
// session". It still rejects an unrelated recycled pid, which is the
|
|
9559
|
+
// failure being guarded against.
|
|
9560
|
+
//
|
|
9561
|
+
// Note the Codex id is always *set* (a local placeholder) — it is just
|
|
9562
|
+
// not in the process's argv — so the choice keys off the provider, not
|
|
9563
|
+
// off the id being null.
|
|
9564
|
+
cmdline: pid != null ? this.spawnArgvToken(session) : null,
|
|
9565
|
+
streamerInstanceId: this.streamerInstanceId
|
|
9566
|
+
});
|
|
9567
|
+
} catch (err) {
|
|
9568
|
+
this.log.warn("[registry] failed to record session spawn", {
|
|
9569
|
+
event: "registry.spawn_write_failed",
|
|
9570
|
+
sessionId: session.id,
|
|
9571
|
+
err
|
|
9572
|
+
});
|
|
9573
|
+
}
|
|
9574
|
+
}
|
|
9575
|
+
/**
|
|
9576
|
+
* Stamp every live session as ended-by-shutdown before dispose() kills it.
|
|
9577
|
+
*
|
|
9578
|
+
* PTYManager.dispose() signals each child directly and fires no
|
|
9579
|
+
* onStatusChange, so the registry would otherwise keep rows sitting at
|
|
9580
|
+
* `running` forever and the next boot could not tell a deliberate restart
|
|
9581
|
+
* from a crash. Recording `shutdown` as the status source makes that
|
|
9582
|
+
* distinction explicit rather than inferred.
|
|
9583
|
+
*
|
|
9584
|
+
* Not a `completed_at` write for the agent's own work — the agent did not
|
|
9585
|
+
* finish, we stopped it — but the session is genuinely terminal, so it must
|
|
9586
|
+
* leave the reconciler's probe set.
|
|
9587
|
+
*/
|
|
9588
|
+
recordShutdownState() {
|
|
9589
|
+
if (!this.managedSessionsRepo) return;
|
|
9590
|
+
const now = /* @__PURE__ */ new Date();
|
|
9591
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
9592
|
+
try {
|
|
9593
|
+
this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
|
|
9594
|
+
completedAt: now,
|
|
9595
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
9596
|
+
promptCount: session.promptCount
|
|
9597
|
+
});
|
|
9598
|
+
} catch (err) {
|
|
9599
|
+
this.log.warn("[registry] failed to record shutdown state", {
|
|
9600
|
+
event: "registry.shutdown_write_failed",
|
|
9601
|
+
sessionId: session.id,
|
|
9602
|
+
err
|
|
9603
|
+
});
|
|
9604
|
+
}
|
|
9605
|
+
}
|
|
9606
|
+
}
|
|
9607
|
+
/**
|
|
9608
|
+
* Release PTYs whose agent has been silent past IDLE_REAP_AFTER_MS.
|
|
9609
|
+
*
|
|
9610
|
+
* This is the bound that lets handleWsClose stop arming kill timers. The
|
|
9611
|
+
* distinction that matters: the old timer measured how long nobody was
|
|
9612
|
+
* *watching*, which is uncorrelated with whether work is in flight. This
|
|
9613
|
+
* measures how long the *agent* has produced nothing, and only ever considers
|
|
9614
|
+
* sessions that are already settled — a `running` PTY is skipped regardless of
|
|
9615
|
+
* age, so a long silent turn is never interrupted.
|
|
9616
|
+
*
|
|
9617
|
+
* Exposed (not private) so tests can drive one sweep deterministically instead
|
|
9618
|
+
* of waiting on the interval.
|
|
9619
|
+
*/
|
|
9620
|
+
reapIdleSessions(now = Date.now()) {
|
|
9621
|
+
const reaped = [];
|
|
9622
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
9623
|
+
if (session.status === "running") continue;
|
|
9624
|
+
const lastActive = this.lastAgentChunkAt.get(session.id) ?? session.lastActivityAt?.getTime() ?? session.startedAt.getTime();
|
|
9625
|
+
if (now - lastActive < IDLE_REAP_AFTER_MS) continue;
|
|
9626
|
+
this.log.info(
|
|
9627
|
+
`[reap] releasing idle PTY for ${session.id} (idle ${Math.round((now - lastActive) / 6e4)}m)`,
|
|
9628
|
+
{ sessionId: session.id, event: "pty.idle_reap", idleMs: now - lastActive },
|
|
9629
|
+
"pino"
|
|
9630
|
+
);
|
|
9631
|
+
this.ptyManager.putOnHold(session.id);
|
|
9632
|
+
this.lastAgentChunkAt.delete(session.id);
|
|
9633
|
+
this.idempotency.clear(session.id);
|
|
9634
|
+
this.sessionSubscribers.delete(session.id);
|
|
9635
|
+
reaped.push(session.id);
|
|
9636
|
+
const held = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
9637
|
+
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
9638
|
+
}
|
|
9639
|
+
return reaped;
|
|
9640
|
+
}
|
|
7403
9641
|
startGraceTimer(sessionId, delayMs) {
|
|
7404
9642
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
7405
9643
|
if (existing) clearTimeout(existing);
|
|
@@ -7494,6 +9732,8 @@ var StreamerServer = class {
|
|
|
7494
9732
|
this.log.info("Database migrations applied", { event: "db.migrations_applied" });
|
|
7495
9733
|
}
|
|
7496
9734
|
await this.bindWithRetry(port);
|
|
9735
|
+
this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
|
|
9736
|
+
this.idleReaperTimer.unref?.();
|
|
7497
9737
|
const warmUp = new Promise((resolveWarm) => {
|
|
7498
9738
|
{
|
|
7499
9739
|
this.log.info(`Streamer server listening on port ${port}`, {
|
|
@@ -7527,7 +9767,12 @@ var StreamerServer = class {
|
|
|
7527
9767
|
this.projectsRepo = new ProjectsRepository(db);
|
|
7528
9768
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
7529
9769
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
9770
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(db);
|
|
9771
|
+
void this.reconcilePreviousSessions();
|
|
7530
9772
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
9773
|
+
this.pushRepo = new PushRepository(db);
|
|
9774
|
+
this.devicesRepo = new DevicesRepository(db);
|
|
9775
|
+
this.initLiveActivityPush(this.pushRepo);
|
|
7531
9776
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7532
9777
|
this.cache,
|
|
7533
9778
|
this.wsHub,
|
|
@@ -7745,6 +9990,12 @@ var StreamerServer = class {
|
|
|
7745
9990
|
async close() {
|
|
7746
9991
|
for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
|
|
7747
9992
|
this.ptyGraceTimers.clear();
|
|
9993
|
+
if (this.idleReaperTimer) {
|
|
9994
|
+
clearInterval(this.idleReaperTimer);
|
|
9995
|
+
this.idleReaperTimer = null;
|
|
9996
|
+
}
|
|
9997
|
+
this.lastAgentChunkAt.clear();
|
|
9998
|
+
this.recordShutdownState();
|
|
7748
9999
|
this.markScannerStaleDebounced.cancel();
|
|
7749
10000
|
await Promise.all([...this.inFlightCacheWrites]);
|
|
7750
10001
|
await Promise.all([...this.allScanners].map((s) => s.close()));
|
|
@@ -7756,6 +10007,8 @@ var StreamerServer = class {
|
|
|
7756
10007
|
this.externalTails.clear();
|
|
7757
10008
|
this.wsHub.dispose();
|
|
7758
10009
|
this.pairTokens.dispose();
|
|
10010
|
+
this.liveActivityRenewal?.stop();
|
|
10011
|
+
this.apnsClient?.close();
|
|
7759
10012
|
if (this.dbPool) {
|
|
7760
10013
|
await this.dbPool.end();
|
|
7761
10014
|
}
|
|
@@ -7827,19 +10080,34 @@ var StreamerServer = class {
|
|
|
7827
10080
|
json(res, 400, { error: message });
|
|
7828
10081
|
return;
|
|
7829
10082
|
}
|
|
7830
|
-
const { hostname: hostname2 } = __require("os");
|
|
7831
10083
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
7832
10084
|
this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
|
|
7833
10085
|
event: "pair.token_exchanged",
|
|
7834
10086
|
ip,
|
|
7835
10087
|
ts
|
|
7836
10088
|
});
|
|
10089
|
+
let device = null;
|
|
10090
|
+
try {
|
|
10091
|
+
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
10092
|
+
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
10093
|
+
device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
|
|
10094
|
+
} catch (err) {
|
|
10095
|
+
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
10096
|
+
event: "pair.device_register_failed",
|
|
10097
|
+
err
|
|
10098
|
+
});
|
|
10099
|
+
}
|
|
7837
10100
|
json(res, 200, {
|
|
7838
10101
|
ciphertext: sealed.ciphertext,
|
|
7839
10102
|
nonce: sealed.nonce,
|
|
7840
10103
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
7841
10104
|
publicUrl: this.publicUrl,
|
|
7842
|
-
machineName: hostname2()
|
|
10105
|
+
machineName: hostname2(),
|
|
10106
|
+
...device && {
|
|
10107
|
+
deviceId: device.deviceId,
|
|
10108
|
+
deviceToken: device.deviceToken,
|
|
10109
|
+
capabilities: device.capabilities
|
|
10110
|
+
}
|
|
7843
10111
|
});
|
|
7844
10112
|
}
|
|
7845
10113
|
rotateApiKey() {
|
|
@@ -7856,6 +10124,58 @@ var StreamerServer = class {
|
|
|
7856
10124
|
});
|
|
7857
10125
|
return { newKey, persisted };
|
|
7858
10126
|
}
|
|
10127
|
+
/**
|
|
10128
|
+
* The registry ships with the values so a client renders the list from one
|
|
10129
|
+
* round-trip, same as getClaudeFlagsConfig().
|
|
10130
|
+
*
|
|
10131
|
+
* Deliberately no `persisted` field: unlike claude-flags there is no PUT, and
|
|
10132
|
+
* the absence of that field is the signal that this endpoint is read-only.
|
|
10133
|
+
*/
|
|
10134
|
+
getFeatureFlagsConfig() {
|
|
10135
|
+
return { registry: FEATURE_FLAGS, values: this.featureFlags };
|
|
10136
|
+
}
|
|
10137
|
+
getClaudeFlagsConfig() {
|
|
10138
|
+
return {
|
|
10139
|
+
registry: CLAUDE_FLAGS,
|
|
10140
|
+
values: this.claudeFlags,
|
|
10141
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
10142
|
+
persisted: this.claudeFlagsPersistable
|
|
10143
|
+
};
|
|
10144
|
+
}
|
|
10145
|
+
/**
|
|
10146
|
+
* Replace the per-server flag set. Applies to the NEXT spawn — a live PTY
|
|
10147
|
+
* keeps the argv it was started with.
|
|
10148
|
+
*
|
|
10149
|
+
* Mirrors rotateApiKey(): when the values were pinned by a CLI flag we still
|
|
10150
|
+
* apply them in memory but skip the server.yaml write, because the flag would
|
|
10151
|
+
* win again on restart and silently revert them.
|
|
10152
|
+
*
|
|
10153
|
+
* Logged with old→new at info level on purpose: this can disable the
|
|
10154
|
+
* permission prompts entirely, so it needs a forensic trail.
|
|
10155
|
+
*/
|
|
10156
|
+
setClaudeFlagsConfig(values, extraArgs) {
|
|
10157
|
+
const safe = validateFlagValues(values);
|
|
10158
|
+
const previous = { values: this.claudeFlags, extraArgs: this.claudeExtraArgs };
|
|
10159
|
+
if (this.claudeFlagsPersistable) {
|
|
10160
|
+
setClaudeExtraArgs(extraArgs);
|
|
10161
|
+
setClaudeFlags(safe);
|
|
10162
|
+
}
|
|
10163
|
+
this.claudeFlags = safe;
|
|
10164
|
+
this.claudeExtraArgs = extraArgs?.trim() ? extraArgs.trim() : void 0;
|
|
10165
|
+
this.log.info("Claude CLI flags updated", {
|
|
10166
|
+
event: "config.claude_flags_updated",
|
|
10167
|
+
persisted: this.claudeFlagsPersistable,
|
|
10168
|
+
previousValues: previous.values,
|
|
10169
|
+
previousExtraArgs: previous.extraArgs ?? null,
|
|
10170
|
+
values: this.claudeFlags,
|
|
10171
|
+
extraArgs: this.claudeExtraArgs ?? null
|
|
10172
|
+
});
|
|
10173
|
+
return {
|
|
10174
|
+
values: this.claudeFlags,
|
|
10175
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
10176
|
+
persisted: this.claudeFlagsPersistable
|
|
10177
|
+
};
|
|
10178
|
+
}
|
|
7859
10179
|
checkRateLimit(map, key, limit, windowMs) {
|
|
7860
10180
|
const now = Date.now();
|
|
7861
10181
|
const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
@@ -8873,7 +11193,13 @@ var StreamerServer = class {
|
|
|
8873
11193
|
}
|
|
8874
11194
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
8875
11195
|
if (!hasPaginationParams) {
|
|
8876
|
-
json(
|
|
11196
|
+
json(
|
|
11197
|
+
res,
|
|
11198
|
+
200,
|
|
11199
|
+
this.withExternalActivity(
|
|
11200
|
+
this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
11201
|
+
)
|
|
11202
|
+
);
|
|
8877
11203
|
return;
|
|
8878
11204
|
}
|
|
8879
11205
|
const parsed = parseSessionListQuery(url);
|
|
@@ -8883,7 +11209,7 @@ var StreamerServer = class {
|
|
|
8883
11209
|
}
|
|
8884
11210
|
try {
|
|
8885
11211
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8886
|
-
page.sessions = this.withExternalActivity(page.sessions);
|
|
11212
|
+
page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
|
|
8887
11213
|
json(res, 200, page);
|
|
8888
11214
|
} catch (err) {
|
|
8889
11215
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -8900,6 +11226,9 @@ var StreamerServer = class {
|
|
|
8900
11226
|
if (!existsSync10(session.projectPath)) {
|
|
8901
11227
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
8902
11228
|
}
|
|
11229
|
+
const reconciled = this.withReconciledLifecycle([session])[0];
|
|
11230
|
+
session.lifecycle = reconciled.lifecycle;
|
|
11231
|
+
session.lifecycleSource = reconciled.lifecycleSource;
|
|
8903
11232
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
8904
11233
|
try {
|
|
8905
11234
|
const lines = await this.ptyManager.getOutputLines(sessionId, 10);
|
|
@@ -8997,10 +11326,13 @@ var StreamerServer = class {
|
|
|
8997
11326
|
projectName: body.projectName,
|
|
8998
11327
|
branch: body.branch,
|
|
8999
11328
|
permissionMode: this.defaultPermissionMode,
|
|
11329
|
+
claudeFlags: this.claudeFlags,
|
|
11330
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9000
11331
|
model: this.defaultModel,
|
|
9001
11332
|
effort: this.defaultEffort
|
|
9002
11333
|
});
|
|
9003
11334
|
this.sessionStore.addManaged(session);
|
|
11335
|
+
this.recordSessionSpawn(session);
|
|
9004
11336
|
void this.watchConversationFile(sessionId);
|
|
9005
11337
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
9006
11338
|
this.broadcastOrUnicastSessionList(req);
|
|
@@ -9073,6 +11405,24 @@ var StreamerServer = class {
|
|
|
9073
11405
|
}
|
|
9074
11406
|
const body = await readBody(req);
|
|
9075
11407
|
const { input, keys } = body;
|
|
11408
|
+
let idempotencyKey;
|
|
11409
|
+
try {
|
|
11410
|
+
idempotencyKey = readIdempotencyKey(body);
|
|
11411
|
+
} catch (err) {
|
|
11412
|
+
json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
|
|
11413
|
+
return;
|
|
11414
|
+
}
|
|
11415
|
+
if (idempotencyKey) {
|
|
11416
|
+
const replayed = this.idempotency.get(sessionId, idempotencyKey);
|
|
11417
|
+
if (replayed) {
|
|
11418
|
+
this.log.info(`[input.replay] ${sessionId.slice(0, 8)} duplicate idempotencyKey`, {
|
|
11419
|
+
event: "input.idempotent_replay",
|
|
11420
|
+
sessionId
|
|
11421
|
+
});
|
|
11422
|
+
json(res, replayed.status, replayed.body);
|
|
11423
|
+
return;
|
|
11424
|
+
}
|
|
11425
|
+
}
|
|
9076
11426
|
if (typeof keys === "string") {
|
|
9077
11427
|
try {
|
|
9078
11428
|
this.ptyManager.sendKeys(sessionId, keys);
|
|
@@ -9080,7 +11430,9 @@ var StreamerServer = class {
|
|
|
9080
11430
|
if (updated) {
|
|
9081
11431
|
this.wsHub.broadcast({ type: "session_update", session: updated });
|
|
9082
11432
|
}
|
|
9083
|
-
|
|
11433
|
+
const result = { status: 200, body: { ok: true } };
|
|
11434
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
11435
|
+
json(res, result.status, result.body);
|
|
9084
11436
|
} catch (err) {
|
|
9085
11437
|
const message = err instanceof Error ? err.message : "Failed to send keys";
|
|
9086
11438
|
json(res, 400, { error: message });
|
|
@@ -9118,7 +11470,9 @@ var StreamerServer = class {
|
|
|
9118
11470
|
});
|
|
9119
11471
|
});
|
|
9120
11472
|
}
|
|
9121
|
-
|
|
11473
|
+
const result = { status: 200, body: { ok: true } };
|
|
11474
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
11475
|
+
json(res, result.status, result.body);
|
|
9122
11476
|
} catch (err) {
|
|
9123
11477
|
const message = err instanceof Error ? err.message : "Failed to send input";
|
|
9124
11478
|
json(res, 400, { error: message });
|
|
@@ -9423,10 +11777,13 @@ var StreamerServer = class {
|
|
|
9423
11777
|
projectName,
|
|
9424
11778
|
branch,
|
|
9425
11779
|
permissionMode: this.defaultPermissionMode,
|
|
11780
|
+
claudeFlags: this.claudeFlags,
|
|
11781
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9426
11782
|
model: this.defaultModel,
|
|
9427
11783
|
effort: this.defaultEffort
|
|
9428
11784
|
});
|
|
9429
11785
|
this.sessionStore.addManaged(session);
|
|
11786
|
+
this.recordSessionSpawn(session);
|
|
9430
11787
|
void this.watchConversationFile(session.id);
|
|
9431
11788
|
this.wsHub.broadcast({
|
|
9432
11789
|
type: "session_list",
|
|
@@ -9489,17 +11846,21 @@ var StreamerServer = class {
|
|
|
9489
11846
|
BROWSE_SYSTEM_PROMPT(this.browseRoot),
|
|
9490
11847
|
typeof clientPrompt === "string" ? clientPrompt : null
|
|
9491
11848
|
].filter(Boolean);
|
|
11849
|
+
const includeSystemPrompt = provider !== CODEX_CLI_PROVIDER || this.codexSystemPromptEnabled;
|
|
9492
11850
|
try {
|
|
9493
11851
|
const session = await this.ptyManager.startFresh({
|
|
9494
11852
|
provider,
|
|
9495
11853
|
projectPath: resolvedPath,
|
|
9496
11854
|
projectName: body.projectName,
|
|
9497
|
-
systemPrompt: systemPromptParts.join("\n"),
|
|
11855
|
+
...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
|
|
9498
11856
|
permissionMode: this.defaultPermissionMode,
|
|
11857
|
+
claudeFlags: this.claudeFlags,
|
|
11858
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9499
11859
|
model: this.defaultModel,
|
|
9500
11860
|
effort: this.defaultEffort
|
|
9501
11861
|
});
|
|
9502
11862
|
this.sessionStore.addManaged(session);
|
|
11863
|
+
this.recordSessionSpawn(session);
|
|
9503
11864
|
const readyOrFailed = new Promise((resolve2) => {
|
|
9504
11865
|
const handler = (status) => {
|
|
9505
11866
|
if (status === "waiting_input" || status === "idle") {
|
|
@@ -10020,6 +12381,7 @@ export {
|
|
|
10020
12381
|
SessionStore,
|
|
10021
12382
|
StreamerServer,
|
|
10022
12383
|
WSHub,
|
|
12384
|
+
confidenceForSource,
|
|
10023
12385
|
createAgentClient,
|
|
10024
12386
|
createConversationWriter,
|
|
10025
12387
|
createPool,
|