@threadbase-sh/streamer 1.36.3 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +26490 -24984
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1648 -149
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +378 -4
- package/dist/index.d.ts +378 -4
- package/dist/index.js +1651 -153
- package/dist/index.js.map +1 -1
- package/dist/migrations/010_create_managed_sessions.sql +65 -0
- package/dist/migrations/011_create_devices.sql +39 -0
- package/dist/migrations/012_create_push_tokens.sql +49 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -278,6 +278,165 @@ 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/auth.ts
|
|
281
440
|
function configDir() {
|
|
282
441
|
return process.env.THREADBASE_CONFIG_DIR ?? join2(homedir(), ".threadbase");
|
|
283
442
|
}
|
|
@@ -356,11 +515,79 @@ function loadDefaultPermissionMode() {
|
|
|
356
515
|
const content = readFileSync(configFile(), "utf-8");
|
|
357
516
|
const match = content.match(/default_permission_mode:\s*(\S+)/);
|
|
358
517
|
const value = match?.[1]?.trim();
|
|
359
|
-
if (value
|
|
518
|
+
if (isPermissionMode(value)) return value;
|
|
360
519
|
} catch {
|
|
361
520
|
}
|
|
362
521
|
return void 0;
|
|
363
522
|
}
|
|
523
|
+
function setConfigValue(key, value) {
|
|
524
|
+
const file = configFile();
|
|
525
|
+
mkdirSync(configDir(), { recursive: true });
|
|
526
|
+
let content = "";
|
|
527
|
+
try {
|
|
528
|
+
content = readFileSync(file, "utf-8");
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (err.code !== "ENOENT") throw err;
|
|
531
|
+
}
|
|
532
|
+
const lineRe = new RegExp(`^${key}:\\s*.*$\\n?`, "m");
|
|
533
|
+
let updated;
|
|
534
|
+
if (value === void 0) {
|
|
535
|
+
updated = content.replace(lineRe, "");
|
|
536
|
+
} else {
|
|
537
|
+
const line = `${key}: ${value}`;
|
|
538
|
+
if (lineRe.test(content)) {
|
|
539
|
+
updated = content.replace(lineRe, `${line}
|
|
540
|
+
`);
|
|
541
|
+
} else if (content.length === 0 || content.endsWith("\n")) {
|
|
542
|
+
updated = `${content}${line}
|
|
543
|
+
`;
|
|
544
|
+
} else {
|
|
545
|
+
updated = `${content}
|
|
546
|
+
${line}
|
|
547
|
+
`;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const tmpFile = `${file}.tmp`;
|
|
551
|
+
writeFileSync(tmpFile, updated, { encoding: "utf-8", mode: 384 });
|
|
552
|
+
chmodSync(tmpFile, 384);
|
|
553
|
+
renameSync(tmpFile, file);
|
|
554
|
+
}
|
|
555
|
+
function loadClaudeFlags() {
|
|
556
|
+
try {
|
|
557
|
+
const content = readFileSync(configFile(), "utf-8");
|
|
558
|
+
const match = content.match(/^claude_flags:\s*(.+)$/m);
|
|
559
|
+
if (!match?.[1]) return {};
|
|
560
|
+
return validateFlagValues(JSON.parse(match[1].trim()));
|
|
561
|
+
} catch (err) {
|
|
562
|
+
if (err.code !== "ENOENT") {
|
|
563
|
+
getLogger("auth").warn(`Ignoring unreadable claude_flags in server.yaml: ${String(err)}`, {
|
|
564
|
+
event: "config.claude_flags_parse_failed"
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return {};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function setClaudeFlags(values) {
|
|
571
|
+
const safe = validateFlagValues(values);
|
|
572
|
+
setConfigValue("claude_flags", Object.keys(safe).length === 0 ? void 0 : JSON.stringify(safe));
|
|
573
|
+
}
|
|
574
|
+
function loadClaudeExtraArgs() {
|
|
575
|
+
try {
|
|
576
|
+
const content = readFileSync(configFile(), "utf-8");
|
|
577
|
+
const match = content.match(/^claude_extra_args:\s*(.+)$/m);
|
|
578
|
+
const value = match?.[1]?.trim();
|
|
579
|
+
return value && value.length > 0 ? value : void 0;
|
|
580
|
+
} catch {
|
|
581
|
+
}
|
|
582
|
+
return void 0;
|
|
583
|
+
}
|
|
584
|
+
function setClaudeExtraArgs(text) {
|
|
585
|
+
const trimmed = text?.trim();
|
|
586
|
+
if (trimmed && /[\r\n]/.test(trimmed)) {
|
|
587
|
+
throw new Error("claude_extra_args must not contain newlines");
|
|
588
|
+
}
|
|
589
|
+
setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
|
|
590
|
+
}
|
|
364
591
|
function validatePublicUrl(raw) {
|
|
365
592
|
let parsed;
|
|
366
593
|
try {
|
|
@@ -508,42 +735,6 @@ import { randomUUID } from "crypto";
|
|
|
508
735
|
import { existsSync as existsSync2 } from "fs";
|
|
509
736
|
import { basename } from "path";
|
|
510
737
|
|
|
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
738
|
// src/platform.ts
|
|
548
739
|
import { execFileSync } from "child_process";
|
|
549
740
|
import { existsSync } from "fs";
|
|
@@ -895,6 +1086,8 @@ var CodexPtyRunner = class {
|
|
|
895
1086
|
projectName,
|
|
896
1087
|
branch: options.branch ?? "",
|
|
897
1088
|
status: "running",
|
|
1089
|
+
statusSource: "spawn",
|
|
1090
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
898
1091
|
startedAt: /* @__PURE__ */ new Date(),
|
|
899
1092
|
completedAt: null,
|
|
900
1093
|
promptCount: 0,
|
|
@@ -942,6 +1135,8 @@ var CodexPtyRunner = class {
|
|
|
942
1135
|
projectName,
|
|
943
1136
|
branch: "",
|
|
944
1137
|
status: "running",
|
|
1138
|
+
statusSource: "spawn",
|
|
1139
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
945
1140
|
startedAt: /* @__PURE__ */ new Date(),
|
|
946
1141
|
completedAt: null,
|
|
947
1142
|
promptCount: 0,
|
|
@@ -972,7 +1167,7 @@ var CodexPtyRunner = class {
|
|
|
972
1167
|
this.readyFallbackTimers.delete(sessionId);
|
|
973
1168
|
const session = this.sessions.get(sessionId);
|
|
974
1169
|
if (session?.status === "running" && this.pendingReady.has(sessionId)) {
|
|
975
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
1170
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
976
1171
|
}
|
|
977
1172
|
}, CODEX_READY_FALLBACK_MS);
|
|
978
1173
|
timer.unref?.();
|
|
@@ -987,6 +1182,8 @@ var CodexPtyRunner = class {
|
|
|
987
1182
|
}
|
|
988
1183
|
if (session.status === "waiting_input") {
|
|
989
1184
|
session.status = "running";
|
|
1185
|
+
session.statusSource = "user-input";
|
|
1186
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
990
1187
|
this.onStatusChange?.(toPublicSession(session));
|
|
991
1188
|
}
|
|
992
1189
|
const gate = this.openGate.get(sessionId);
|
|
@@ -1056,6 +1253,8 @@ var CodexPtyRunner = class {
|
|
|
1056
1253
|
}
|
|
1057
1254
|
if (session.status === "waiting_input") {
|
|
1058
1255
|
session.status = "running";
|
|
1256
|
+
session.statusSource = "user-input";
|
|
1257
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1059
1258
|
this.onStatusChange?.(toPublicSession(session));
|
|
1060
1259
|
}
|
|
1061
1260
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -1156,6 +1355,8 @@ var CodexPtyRunner = class {
|
|
|
1156
1355
|
} catch {
|
|
1157
1356
|
}
|
|
1158
1357
|
session.status = "idle";
|
|
1358
|
+
session.statusSource = "shutdown";
|
|
1359
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1159
1360
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1160
1361
|
session.screen.dispose();
|
|
1161
1362
|
this.sessions.delete(sessionId);
|
|
@@ -1200,6 +1401,11 @@ var CodexPtyRunner = class {
|
|
|
1200
1401
|
getInputHistory(sessionId) {
|
|
1201
1402
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
1202
1403
|
}
|
|
1404
|
+
// OS pid of the spawned agent, or null if the session isn't live here.
|
|
1405
|
+
// Mirrors PTYManager.getPid — see there for why the registry records it.
|
|
1406
|
+
getPid(sessionId) {
|
|
1407
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
1408
|
+
}
|
|
1203
1409
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
1204
1410
|
// Called from writeSubmit (direct and flush paths) — never from sendKeys.
|
|
1205
1411
|
recordUserMessage(session, text) {
|
|
@@ -1301,9 +1507,9 @@ var CodexPtyRunner = class {
|
|
|
1301
1507
|
if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
|
|
1302
1508
|
const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
|
|
1303
1509
|
if (lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) {
|
|
1304
|
-
this.markReady(sessionId, session, `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1510
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${CODEX_PROMPT_READY_TEXT}`);
|
|
1305
1511
|
} else if (trigger === "quiet") {
|
|
1306
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
1512
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
1307
1513
|
}
|
|
1308
1514
|
}
|
|
1309
1515
|
// Answer a gate from the persisted remember-store, or surface it as a
|
|
@@ -1334,9 +1540,11 @@ var CodexPtyRunner = class {
|
|
|
1334
1540
|
});
|
|
1335
1541
|
this.onPermissionChange?.(sessionId, card);
|
|
1336
1542
|
}
|
|
1337
|
-
markReady(sessionId, session, reason) {
|
|
1543
|
+
markReady(sessionId, session, source, reason) {
|
|
1338
1544
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
1339
1545
|
session.status = "waiting_input";
|
|
1546
|
+
session.statusSource = source;
|
|
1547
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1340
1548
|
this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
|
|
1341
1549
|
event: "codex.ready",
|
|
1342
1550
|
sessionId,
|
|
@@ -1354,6 +1562,8 @@ var CodexPtyRunner = class {
|
|
|
1354
1562
|
if (!session) return;
|
|
1355
1563
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
1356
1564
|
session.status = "idle";
|
|
1565
|
+
session.statusSource = "process-exit";
|
|
1566
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1357
1567
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
1358
1568
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
1359
1569
|
if (!existsSync2(session.projectPath)) {
|
|
@@ -1383,6 +1593,8 @@ function toPublicSession(s) {
|
|
|
1383
1593
|
lastOutput: s.lastOutput,
|
|
1384
1594
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
1385
1595
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
1596
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
1597
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
1386
1598
|
...s.filePath != null && { filePath: s.filePath }
|
|
1387
1599
|
};
|
|
1388
1600
|
}
|
|
@@ -1397,6 +1609,9 @@ import { existsSync as existsSync3 } from "fs";
|
|
|
1397
1609
|
import { basename as basename2 } from "path";
|
|
1398
1610
|
|
|
1399
1611
|
// src/services/questions/detectPermissionGate.ts
|
|
1612
|
+
function permissionContentKey(gate) {
|
|
1613
|
+
return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
|
|
1614
|
+
}
|
|
1400
1615
|
var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
|
|
1401
1616
|
var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
|
|
1402
1617
|
function hasPermissionOsc(rawData) {
|
|
@@ -1708,16 +1923,17 @@ var PTYManager = class {
|
|
|
1708
1923
|
}
|
|
1709
1924
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
1710
1925
|
//
|
|
1711
|
-
// options.permissionMode defaults to `acceptEdits`
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
// `
|
|
1720
|
-
//
|
|
1926
|
+
// options.permissionMode defaults to `acceptEdits` — the safe default that
|
|
1927
|
+
// auto-approves file edits while still prompting for shell commands. All six
|
|
1928
|
+
// Claude CLI modes are accepted (see PERMISSION_MODES in claude-flags.ts).
|
|
1929
|
+
//
|
|
1930
|
+
// On the bypass modes: `bypassPermissions`/`dontAsk` DO trigger a blocking
|
|
1931
|
+
// "Bypass Permissions mode" warning menu at boot ("1. No, exit" /
|
|
1932
|
+
// "2. Yes, I accept") which would strand the PTY and leave mobile on an empty
|
|
1933
|
+
// screen. buildSettingsJson() suppresses it by adding
|
|
1934
|
+
// `skipDangerousModePermissionPrompt` to the `--settings` blob for exactly
|
|
1935
|
+
// those modes — probe-verified on Claude Code v2.1.218. We never pass
|
|
1936
|
+
// `--dangerously-skip-permissions`; bypass is requested via --permission-mode.
|
|
1721
1937
|
// (The other first-run gates — onboarding/theme, workspace trust,
|
|
1722
1938
|
// custom-API-key — are cleared by the seeded ~/.claude.json in
|
|
1723
1939
|
// docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
|
|
@@ -1735,28 +1951,27 @@ var PTYManager = class {
|
|
|
1735
1951
|
async doStart(sessionId, options) {
|
|
1736
1952
|
const nodePty = await loadPty2();
|
|
1737
1953
|
const projectName = options.projectName ?? basename2(options.projectPath);
|
|
1738
|
-
const
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
);
|
|
1954
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
1955
|
+
const args = [
|
|
1956
|
+
"--permission-mode",
|
|
1957
|
+
permissionMode,
|
|
1958
|
+
"--settings",
|
|
1959
|
+
buildSettingsJson(permissionMode),
|
|
1960
|
+
"--model",
|
|
1961
|
+
options.model ?? "sonnet",
|
|
1962
|
+
"--effort",
|
|
1963
|
+
options.effort ?? "low",
|
|
1964
|
+
"--resume",
|
|
1965
|
+
sessionId
|
|
1966
|
+
];
|
|
1967
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
1968
|
+
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
1969
|
+
name: "xterm-256color",
|
|
1970
|
+
cols: 120,
|
|
1971
|
+
rows: 40,
|
|
1972
|
+
cwd: options.projectPath,
|
|
1973
|
+
env: buildSpawnEnv()
|
|
1974
|
+
});
|
|
1760
1975
|
const session = {
|
|
1761
1976
|
id: sessionId,
|
|
1762
1977
|
provider: CLAUDE_CODE_PROVIDER,
|
|
@@ -1764,6 +1979,8 @@ var PTYManager = class {
|
|
|
1764
1979
|
projectName,
|
|
1765
1980
|
branch: options.branch ?? "",
|
|
1766
1981
|
status: "running",
|
|
1982
|
+
statusSource: "spawn",
|
|
1983
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1767
1984
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1768
1985
|
completedAt: null,
|
|
1769
1986
|
promptCount: 0,
|
|
@@ -1791,11 +2008,12 @@ var PTYManager = class {
|
|
|
1791
2008
|
const nodePty = await loadPty2();
|
|
1792
2009
|
const sessionId = randomUUID2();
|
|
1793
2010
|
const projectName = options.projectName ?? basename2(options.projectPath);
|
|
2011
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
1794
2012
|
const args = [
|
|
1795
2013
|
"--permission-mode",
|
|
1796
|
-
|
|
2014
|
+
permissionMode,
|
|
1797
2015
|
"--settings",
|
|
1798
|
-
|
|
2016
|
+
buildSettingsJson(permissionMode),
|
|
1799
2017
|
"--model",
|
|
1800
2018
|
options.model ?? "sonnet",
|
|
1801
2019
|
"--effort",
|
|
@@ -1806,6 +2024,7 @@ var PTYManager = class {
|
|
|
1806
2024
|
if (options.systemPrompt) {
|
|
1807
2025
|
args.push("--system-prompt", options.systemPrompt);
|
|
1808
2026
|
}
|
|
2027
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
1809
2028
|
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
1810
2029
|
name: "xterm-256color",
|
|
1811
2030
|
cols: 120,
|
|
@@ -1820,6 +2039,8 @@ var PTYManager = class {
|
|
|
1820
2039
|
projectName,
|
|
1821
2040
|
branch: "",
|
|
1822
2041
|
status: "running",
|
|
2042
|
+
statusSource: "spawn",
|
|
2043
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1823
2044
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1824
2045
|
completedAt: null,
|
|
1825
2046
|
promptCount: 0,
|
|
@@ -1850,6 +2071,8 @@ var PTYManager = class {
|
|
|
1850
2071
|
}
|
|
1851
2072
|
if (session.status === "waiting_input") {
|
|
1852
2073
|
session.status = "running";
|
|
2074
|
+
session.statusSource = "user-input";
|
|
2075
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1853
2076
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1854
2077
|
}
|
|
1855
2078
|
this.log.info(
|
|
@@ -1885,6 +2108,8 @@ var PTYManager = class {
|
|
|
1885
2108
|
}
|
|
1886
2109
|
if (session.status === "waiting_input") {
|
|
1887
2110
|
session.status = "running";
|
|
2111
|
+
session.statusSource = "user-input";
|
|
2112
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1888
2113
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1889
2114
|
}
|
|
1890
2115
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -2006,6 +2231,8 @@ var PTYManager = class {
|
|
|
2006
2231
|
} catch {
|
|
2007
2232
|
}
|
|
2008
2233
|
session.status = "idle";
|
|
2234
|
+
session.statusSource = "shutdown";
|
|
2235
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2009
2236
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2010
2237
|
session.screen.dispose();
|
|
2011
2238
|
this.sessions.delete(sessionId);
|
|
@@ -2041,6 +2268,13 @@ var PTYManager = class {
|
|
|
2041
2268
|
getInputHistory(sessionId) {
|
|
2042
2269
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
2043
2270
|
}
|
|
2271
|
+
// OS pid of the spawned agent, or null if the session isn't live here. The
|
|
2272
|
+
// durable registry records this so a later streamer run can probe whether the
|
|
2273
|
+
// process outlived it. Liveness alone is never identity — a recycled pid is
|
|
2274
|
+
// why the registry stores a cmdline alongside it.
|
|
2275
|
+
getPid(sessionId) {
|
|
2276
|
+
return this.sessions.get(sessionId)?.process?.pid ?? null;
|
|
2277
|
+
}
|
|
2044
2278
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
2045
2279
|
// Called from writeSubmit (both direct and flush paths) — never from
|
|
2046
2280
|
// sendKeys, so raw keystrokes aren't logged as messages.
|
|
@@ -2117,9 +2351,9 @@ var PTYManager = class {
|
|
|
2117
2351
|
session.lastOutput = stripped;
|
|
2118
2352
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
|
|
2119
2353
|
if (session.status === "running" && matchedMarker) {
|
|
2120
|
-
this.markReady(sessionId, session, `marker:${matchedMarker}`);
|
|
2354
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
|
|
2121
2355
|
} else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
|
|
2122
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
2356
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
2123
2357
|
}
|
|
2124
2358
|
this.onOutput?.(sessionId, data);
|
|
2125
2359
|
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
@@ -2219,7 +2453,7 @@ var PTYManager = class {
|
|
|
2219
2453
|
const session = this.sessions.get(sessionId);
|
|
2220
2454
|
if (session?.status !== "running") return;
|
|
2221
2455
|
if (this.pendingReady.has(sessionId)) {
|
|
2222
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
2456
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
2223
2457
|
} else {
|
|
2224
2458
|
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2225
2459
|
this.log.warn("[pty.ready] screen recheck failed", {
|
|
@@ -2248,14 +2482,16 @@ var PTYManager = class {
|
|
|
2248
2482
|
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2249
2483
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2250
2484
|
if (matchedMarker && session.status === "running") {
|
|
2251
|
-
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2485
|
+
this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
|
|
2252
2486
|
}
|
|
2253
2487
|
}
|
|
2254
2488
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2255
2489
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2256
|
-
markReady(sessionId, session, reason) {
|
|
2490
|
+
markReady(sessionId, session, source, reason) {
|
|
2257
2491
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2258
2492
|
session.status = "waiting_input";
|
|
2493
|
+
session.statusSource = source;
|
|
2494
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2259
2495
|
const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
|
|
2260
2496
|
this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
|
|
2261
2497
|
event: "pty.ready",
|
|
@@ -2275,6 +2511,8 @@ var PTYManager = class {
|
|
|
2275
2511
|
if (!session) return;
|
|
2276
2512
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2277
2513
|
session.status = "idle";
|
|
2514
|
+
session.statusSource = "process-exit";
|
|
2515
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2278
2516
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
2279
2517
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
2280
2518
|
if (!existsSync3(session.projectPath)) {
|
|
@@ -2309,6 +2547,8 @@ function toPublicSession2(s) {
|
|
|
2309
2547
|
lastOutput: s.lastOutput,
|
|
2310
2548
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
2311
2549
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2550
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2551
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2312
2552
|
...s.filePath != null && { filePath: s.filePath }
|
|
2313
2553
|
};
|
|
2314
2554
|
}
|
|
@@ -2381,6 +2621,16 @@ var LiveSessionManager = class {
|
|
|
2381
2621
|
}
|
|
2382
2622
|
return null;
|
|
2383
2623
|
}
|
|
2624
|
+
// Scans rather than using runnerFor(): the registry records a pid on a
|
|
2625
|
+
// best-effort basis, so an unknown session must return null rather than
|
|
2626
|
+
// throw the way the input-routing methods do.
|
|
2627
|
+
getPid(sessionId) {
|
|
2628
|
+
for (const runner of this.runners.values()) {
|
|
2629
|
+
const pid = runner.getPid(sessionId);
|
|
2630
|
+
if (pid != null) return pid;
|
|
2631
|
+
}
|
|
2632
|
+
return null;
|
|
2633
|
+
}
|
|
2384
2634
|
hasSession(sessionId) {
|
|
2385
2635
|
for (const runner of this.runners.values()) {
|
|
2386
2636
|
if (runner.hasSession(sessionId)) return true;
|
|
@@ -2557,6 +2807,18 @@ async function getProcessCwdUnix(pid) {
|
|
|
2557
2807
|
async function getProcessArgsUnix(pid) {
|
|
2558
2808
|
return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
|
|
2559
2809
|
}
|
|
2810
|
+
async function getProcessArgs(pid) {
|
|
2811
|
+
if (!Number.isInteger(pid) || pid < 1) return "";
|
|
2812
|
+
try {
|
|
2813
|
+
if (platform2() === "win32") {
|
|
2814
|
+
const info = await getProcessInfoWindows(pid);
|
|
2815
|
+
return info?.args ?? "";
|
|
2816
|
+
}
|
|
2817
|
+
return await getProcessArgsUnix(pid);
|
|
2818
|
+
} catch {
|
|
2819
|
+
return "";
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2560
2822
|
async function getProcessStartTimeUnix(pid) {
|
|
2561
2823
|
const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
|
|
2562
2824
|
const d = new Date(raw);
|
|
@@ -2679,6 +2941,7 @@ import {
|
|
|
2679
2941
|
ConversationScanner,
|
|
2680
2942
|
search
|
|
2681
2943
|
} from "@threadbase-sh/scanner";
|
|
2944
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2682
2945
|
import { EventEmitter } from "events";
|
|
2683
2946
|
import {
|
|
2684
2947
|
createReadStream,
|
|
@@ -2950,7 +3213,202 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2950
3213
|
}
|
|
2951
3214
|
|
|
2952
3215
|
// src/api/app.ts
|
|
2953
|
-
import { Hono as
|
|
3216
|
+
import { Hono as Hono16 } from "hono";
|
|
3217
|
+
|
|
3218
|
+
// src/db/repositories/devices.repository.ts
|
|
3219
|
+
import { createHash, randomBytes as randomBytes2, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3220
|
+
|
|
3221
|
+
// src/services/security/capabilities.ts
|
|
3222
|
+
var CAPABILITIES = [
|
|
3223
|
+
"history:read",
|
|
3224
|
+
// read conversations, search
|
|
3225
|
+
"session:control",
|
|
3226
|
+
// start, resume, send input, interrupt
|
|
3227
|
+
"fs:browse",
|
|
3228
|
+
// browse the project tree
|
|
3229
|
+
"fs:upload",
|
|
3230
|
+
// upload files into a project
|
|
3231
|
+
"notifications",
|
|
3232
|
+
// register for push
|
|
3233
|
+
"admin"
|
|
3234
|
+
// rotate keys, manage devices
|
|
3235
|
+
];
|
|
3236
|
+
function isCapability(value) {
|
|
3237
|
+
return typeof value === "string" && CAPABILITIES.includes(value);
|
|
3238
|
+
}
|
|
3239
|
+
var FULL_CAPABILITIES = [
|
|
3240
|
+
"history:read",
|
|
3241
|
+
"session:control",
|
|
3242
|
+
"fs:browse",
|
|
3243
|
+
"fs:upload",
|
|
3244
|
+
"notifications"
|
|
3245
|
+
];
|
|
3246
|
+
var READ_ONLY_CAPABILITIES = ["history:read"];
|
|
3247
|
+
function capabilitiesForPreset(preset) {
|
|
3248
|
+
return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
|
|
3249
|
+
}
|
|
3250
|
+
function legacyPrincipal() {
|
|
3251
|
+
return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
|
|
3252
|
+
}
|
|
3253
|
+
function hasCapability(principal, required) {
|
|
3254
|
+
return principal.capabilities.includes(required);
|
|
3255
|
+
}
|
|
3256
|
+
var ROUTE_CAPABILITIES = [
|
|
3257
|
+
// Most specific first for readability; matching sorts by length anyway.
|
|
3258
|
+
["/api/sessions/", "session:control"],
|
|
3259
|
+
["/api/sessions", "history:read"],
|
|
3260
|
+
// listing sessions is a read
|
|
3261
|
+
["/api/conversations", "history:read"],
|
|
3262
|
+
["/api/projects", "history:read"],
|
|
3263
|
+
["/api/search", "history:read"],
|
|
3264
|
+
["/api/providers", "history:read"],
|
|
3265
|
+
["/api/browse", "fs:browse"],
|
|
3266
|
+
["/api/upload", "fs:upload"],
|
|
3267
|
+
["/api/push", "notifications"],
|
|
3268
|
+
["/api/devices", "admin"],
|
|
3269
|
+
["/api/config", "admin"],
|
|
3270
|
+
["/api/auth/rotate", "admin"],
|
|
3271
|
+
["/api/backup", "admin"],
|
|
3272
|
+
// Server identity and capability discovery. A read-only device must be able
|
|
3273
|
+
// to see WHICH server it is talking to and what it supports, or it cannot
|
|
3274
|
+
// render anything at all.
|
|
3275
|
+
["/api/info", "history:read"],
|
|
3276
|
+
["/api/profiles", "history:read"],
|
|
3277
|
+
["/api/diagnostics", "history:read"],
|
|
3278
|
+
["/api/cache/alert", "history:read"],
|
|
3279
|
+
// Client log shipping: any authenticated client may report its own errors.
|
|
3280
|
+
// Gating this behind a capability would silence diagnostics from exactly the
|
|
3281
|
+
// devices most likely to be misbehaving.
|
|
3282
|
+
["/api/__client-log", "history:read"],
|
|
3283
|
+
// Logs viewer is localhost-only and already bypasses this middleware; the
|
|
3284
|
+
// mapping exists so a remote request is classified rather than denied as
|
|
3285
|
+
// unclassified.
|
|
3286
|
+
["/api/logs", "admin"],
|
|
3287
|
+
// Pairing routes other than the public exchange (e.g. minting a token).
|
|
3288
|
+
["/api/pair", "admin"],
|
|
3289
|
+
// The live WebSocket. Subscribing is a read — terminal output, session
|
|
3290
|
+
// updates, conversation events. Control still flows through the HTTP input
|
|
3291
|
+
// routes, which carry their own capability check, so a read-only device can
|
|
3292
|
+
// watch a session stream without being able to drive it.
|
|
3293
|
+
["/ws", "history:read"],
|
|
3294
|
+
// Progress webhook (multi-agent). Authenticated by HMAC in the handler and
|
|
3295
|
+
// already skipped by the middleware; classified so a stray request is denied
|
|
3296
|
+
// by rule rather than as "unclassified".
|
|
3297
|
+
["/internal/sessions", "admin"]
|
|
3298
|
+
];
|
|
3299
|
+
function requiredCapability(path, method) {
|
|
3300
|
+
if (path.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
|
|
3301
|
+
return "history:read";
|
|
3302
|
+
}
|
|
3303
|
+
let best = null;
|
|
3304
|
+
for (const [prefix, cap] of ROUTE_CAPABILITIES) {
|
|
3305
|
+
if (path.startsWith(prefix) && (best === null || prefix.length > best.len)) {
|
|
3306
|
+
best = { len: prefix.length, cap };
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
return best?.cap ?? null;
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// src/db/repositories/devices.repository.ts
|
|
3313
|
+
function generateDeviceToken() {
|
|
3314
|
+
return randomBytes2(32).toString("base64url");
|
|
3315
|
+
}
|
|
3316
|
+
function hashDeviceToken(token) {
|
|
3317
|
+
return createHash("sha256").update(token).digest("hex");
|
|
3318
|
+
}
|
|
3319
|
+
function safeHashEquals(a, b) {
|
|
3320
|
+
if (a.length !== b.length) return false;
|
|
3321
|
+
return timingSafeEqual2(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
3322
|
+
}
|
|
3323
|
+
function parseCapabilities(raw) {
|
|
3324
|
+
try {
|
|
3325
|
+
const parsed = JSON.parse(raw);
|
|
3326
|
+
if (!Array.isArray(parsed)) return [];
|
|
3327
|
+
return parsed.filter(isCapability);
|
|
3328
|
+
} catch {
|
|
3329
|
+
return [];
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
function toDeviceView(row) {
|
|
3333
|
+
return {
|
|
3334
|
+
deviceId: row.device_id,
|
|
3335
|
+
name: row.name,
|
|
3336
|
+
capabilities: parseCapabilities(row.capabilities),
|
|
3337
|
+
createdAt: row.created_at,
|
|
3338
|
+
lastSeenAt: row.last_seen_at,
|
|
3339
|
+
revokedAt: row.revoked_at
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
var DevicesRepository = class {
|
|
3343
|
+
insertStmt;
|
|
3344
|
+
byTokenHashStmt;
|
|
3345
|
+
byIdStmt;
|
|
3346
|
+
listStmt;
|
|
3347
|
+
revokeStmt;
|
|
3348
|
+
touchStmt;
|
|
3349
|
+
constructor(db) {
|
|
3350
|
+
this.insertStmt = db.prepare(`
|
|
3351
|
+
INSERT INTO devices (
|
|
3352
|
+
device_id, public_key, token_hash, name, capabilities, created_at
|
|
3353
|
+
) VALUES (
|
|
3354
|
+
@device_id, @public_key, @token_hash, @name, @capabilities, @created_at
|
|
3355
|
+
)
|
|
3356
|
+
`);
|
|
3357
|
+
this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
|
|
3358
|
+
this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
|
|
3359
|
+
this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
|
|
3360
|
+
this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
|
|
3361
|
+
this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
|
|
3362
|
+
}
|
|
3363
|
+
/**
|
|
3364
|
+
* Record a newly paired device and mint its token.
|
|
3365
|
+
*
|
|
3366
|
+
* The raw token is returned to the caller and never stored — this is the only
|
|
3367
|
+
* moment it exists outside the client.
|
|
3368
|
+
*/
|
|
3369
|
+
register(args) {
|
|
3370
|
+
const deviceId = randomUUID3();
|
|
3371
|
+
const deviceToken = generateDeviceToken();
|
|
3372
|
+
const capabilities = capabilitiesForPreset(args.preset ?? "full");
|
|
3373
|
+
this.insertStmt.run({
|
|
3374
|
+
device_id: deviceId,
|
|
3375
|
+
public_key: args.publicKey,
|
|
3376
|
+
token_hash: hashDeviceToken(deviceToken),
|
|
3377
|
+
name: args.name ?? null,
|
|
3378
|
+
capabilities: JSON.stringify(capabilities),
|
|
3379
|
+
created_at: args.now ?? Date.now()
|
|
3380
|
+
});
|
|
3381
|
+
return { deviceId, deviceToken, capabilities };
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Resolve a presented token to a device, or null.
|
|
3385
|
+
*
|
|
3386
|
+
* Returns null for a revoked device, so revocation takes effect on the very
|
|
3387
|
+
* next request with no cache to go stale.
|
|
3388
|
+
*/
|
|
3389
|
+
authenticate(token) {
|
|
3390
|
+
const hash = hashDeviceToken(token);
|
|
3391
|
+
const row = this.byTokenHashStmt.get(hash);
|
|
3392
|
+
if (!row) return null;
|
|
3393
|
+
if (!safeHashEquals(row.token_hash, hash)) return null;
|
|
3394
|
+
if (row.revoked_at != null) return null;
|
|
3395
|
+
return row;
|
|
3396
|
+
}
|
|
3397
|
+
get(deviceId) {
|
|
3398
|
+
return this.byIdStmt.get(deviceId) ?? null;
|
|
3399
|
+
}
|
|
3400
|
+
/** All devices, including revoked ones — an audit surface needs the history. */
|
|
3401
|
+
list() {
|
|
3402
|
+
return this.listStmt.all().map(toDeviceView);
|
|
3403
|
+
}
|
|
3404
|
+
/** Revoke one device. Others are untouched — no key rotation, no collateral. */
|
|
3405
|
+
revoke(deviceId, now = Date.now()) {
|
|
3406
|
+
return this.revokeStmt.run(now, deviceId).changes > 0;
|
|
3407
|
+
}
|
|
3408
|
+
touch(deviceId, now = Date.now()) {
|
|
3409
|
+
this.touchStmt.run(now, deviceId);
|
|
3410
|
+
}
|
|
3411
|
+
};
|
|
2954
3412
|
|
|
2955
3413
|
// src/api/middleware/auth.middleware.ts
|
|
2956
3414
|
function isLocalRequest(remoteAddr) {
|
|
@@ -2981,19 +3439,40 @@ var authMiddleware = (deps) => async (c, next) => {
|
|
|
2981
3439
|
}
|
|
2982
3440
|
}
|
|
2983
3441
|
const authorization = c.req.header("authorization");
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3442
|
+
const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : void 0;
|
|
3443
|
+
const queryKey = c.req.query("key") ?? void 0;
|
|
3444
|
+
const presented = bearer ?? queryKey;
|
|
3445
|
+
if (!presented) {
|
|
3446
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
3447
|
+
}
|
|
3448
|
+
let principal = null;
|
|
3449
|
+
const device = deps.devicesRepo()?.authenticate(presented) ?? null;
|
|
3450
|
+
if (device) {
|
|
3451
|
+
principal = {
|
|
3452
|
+
kind: "device",
|
|
3453
|
+
deviceId: device.device_id,
|
|
3454
|
+
capabilities: parseCapabilities(device.capabilities)
|
|
3455
|
+
};
|
|
3456
|
+
try {
|
|
3457
|
+
deps.devicesRepo()?.touch(device.device_id);
|
|
3458
|
+
} catch {
|
|
2989
3459
|
}
|
|
3460
|
+
} else if (validateApiKey(presented, deps.apiKey)) {
|
|
3461
|
+
principal = legacyPrincipal();
|
|
3462
|
+
}
|
|
3463
|
+
if (!principal) {
|
|
3464
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
2990
3465
|
}
|
|
2991
|
-
const
|
|
2992
|
-
if (
|
|
3466
|
+
const required = requiredCapability(path, method);
|
|
3467
|
+
if (required === null) {
|
|
2993
3468
|
await next();
|
|
2994
3469
|
return;
|
|
2995
3470
|
}
|
|
2996
|
-
|
|
3471
|
+
if (!hasCapability(principal, required)) {
|
|
3472
|
+
return c.json({ error: "Forbidden", code: "MISSING_CAPABILITY", required }, 403);
|
|
3473
|
+
}
|
|
3474
|
+
c.set("principal", principal);
|
|
3475
|
+
await next();
|
|
2997
3476
|
};
|
|
2998
3477
|
|
|
2999
3478
|
// src/api/middleware/cors.middleware.ts
|
|
@@ -3132,12 +3611,67 @@ var createCacheAlertRoutes = (deps) => {
|
|
|
3132
3611
|
return app;
|
|
3133
3612
|
};
|
|
3134
3613
|
|
|
3135
|
-
// src/api/routes/
|
|
3614
|
+
// src/api/routes/config.routes.ts
|
|
3136
3615
|
import { Hono as Hono4 } from "hono";
|
|
3616
|
+
|
|
3617
|
+
// src/schemas/claudeFlags.schema.ts
|
|
3618
|
+
import { z as z2 } from "zod";
|
|
3619
|
+
var ClaudeFlagsBodySchema = z2.object({
|
|
3620
|
+
values: z2.record(z2.string(), z2.union([z2.string(), z2.boolean(), z2.array(z2.string())])).default({}),
|
|
3621
|
+
// A newline would corrupt the flat one-line-per-key server.yaml, so reject
|
|
3622
|
+
// it here with a field error instead of silently stripping it.
|
|
3623
|
+
extraArgs: z2.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
|
|
3624
|
+
}).strict();
|
|
3625
|
+
|
|
3626
|
+
// src/api/routes/config.routes.ts
|
|
3627
|
+
function readRawBody3(req) {
|
|
3628
|
+
return new Promise((resolve2, reject) => {
|
|
3629
|
+
const chunks = [];
|
|
3630
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3631
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
3632
|
+
req.on("error", reject);
|
|
3633
|
+
});
|
|
3634
|
+
}
|
|
3635
|
+
var createConfigRoutes = (deps) => {
|
|
3636
|
+
const app = new Hono4();
|
|
3637
|
+
app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
|
|
3638
|
+
app.put("/claude-flags", async (c) => {
|
|
3639
|
+
if (deps.localNoAuth) {
|
|
3640
|
+
return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
|
|
3641
|
+
}
|
|
3642
|
+
let body;
|
|
3643
|
+
try {
|
|
3644
|
+
const incoming = c.env?.incoming;
|
|
3645
|
+
const raw = incoming ? await readRawBody3(incoming) : Buffer.from(await c.req.arrayBuffer()).toString("utf-8");
|
|
3646
|
+
body = raw ? JSON.parse(raw) : {};
|
|
3647
|
+
} catch {
|
|
3648
|
+
return c.json({ error: "invalid json" }, 400);
|
|
3649
|
+
}
|
|
3650
|
+
const parsed = ClaudeFlagsBodySchema.safeParse(body);
|
|
3651
|
+
if (!parsed.success) {
|
|
3652
|
+
return c.json({ error: "invalid body", details: parsed.error.flatten() }, 400);
|
|
3653
|
+
}
|
|
3654
|
+
try {
|
|
3655
|
+
const result = deps.setClaudeFlagsConfig(parsed.data.values, parsed.data.extraArgs);
|
|
3656
|
+
return c.json({
|
|
3657
|
+
...result,
|
|
3658
|
+
...result.persisted ? {} : {
|
|
3659
|
+
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."
|
|
3660
|
+
}
|
|
3661
|
+
});
|
|
3662
|
+
} catch (err) {
|
|
3663
|
+
return c.json({ error: err instanceof Error ? err.message : "could not apply flags" }, 400);
|
|
3664
|
+
}
|
|
3665
|
+
});
|
|
3666
|
+
return app;
|
|
3667
|
+
};
|
|
3668
|
+
|
|
3669
|
+
// src/api/routes/conversations.routes.ts
|
|
3670
|
+
import { Hono as Hono5 } from "hono";
|
|
3137
3671
|
var ALREADY_HANDLED2 = 597;
|
|
3138
3672
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
3139
3673
|
var createConversationRoutes = (deps) => {
|
|
3140
|
-
const app = new
|
|
3674
|
+
const app = new Hono5();
|
|
3141
3675
|
app.get("/count", async (c) => {
|
|
3142
3676
|
const url = new URL(c.req.url);
|
|
3143
3677
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -3163,8 +3697,34 @@ var createConversationRoutes = (deps) => {
|
|
|
3163
3697
|
return app;
|
|
3164
3698
|
};
|
|
3165
3699
|
|
|
3700
|
+
// src/api/routes/devices.routes.ts
|
|
3701
|
+
import { Hono as Hono6 } from "hono";
|
|
3702
|
+
var createDeviceRoutes = (deps) => {
|
|
3703
|
+
const app = new Hono6();
|
|
3704
|
+
app.get("/", (c) => {
|
|
3705
|
+
const repo = deps.devicesRepo();
|
|
3706
|
+
if (!repo) return c.json({ devices: [], available: false });
|
|
3707
|
+
return c.json({ devices: repo.list(), available: true });
|
|
3708
|
+
});
|
|
3709
|
+
app.post("/:id/revoke", (c) => {
|
|
3710
|
+
const repo = deps.devicesRepo();
|
|
3711
|
+
if (!repo) {
|
|
3712
|
+
return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
3713
|
+
}
|
|
3714
|
+
const id = c.req.param("id");
|
|
3715
|
+
const existing = repo.get(id);
|
|
3716
|
+
if (!existing) return c.json({ error: "Device not found" }, 404);
|
|
3717
|
+
if (existing.revoked_at != null) {
|
|
3718
|
+
return c.json({ ok: true, alreadyRevoked: true });
|
|
3719
|
+
}
|
|
3720
|
+
repo.revoke(id);
|
|
3721
|
+
return c.json({ ok: true, alreadyRevoked: false });
|
|
3722
|
+
});
|
|
3723
|
+
return app;
|
|
3724
|
+
};
|
|
3725
|
+
|
|
3166
3726
|
// src/api/routes/health.routes.ts
|
|
3167
|
-
import { Hono as
|
|
3727
|
+
import { Hono as Hono7 } from "hono";
|
|
3168
3728
|
|
|
3169
3729
|
// src/version.ts
|
|
3170
3730
|
import { readFileSync as readFileSync4, realpathSync } from "fs";
|
|
@@ -3201,7 +3761,7 @@ function resolveVersion() {
|
|
|
3201
3761
|
|
|
3202
3762
|
// src/api/routes/health.routes.ts
|
|
3203
3763
|
var createHealthRoutes = (deps) => {
|
|
3204
|
-
const app = new
|
|
3764
|
+
const app = new Hono7();
|
|
3205
3765
|
app.get("/", (c) => {
|
|
3206
3766
|
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3207
3767
|
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
@@ -3212,7 +3772,7 @@ var createHealthRoutes = (deps) => {
|
|
|
3212
3772
|
// src/api/routes/logs.routes.ts
|
|
3213
3773
|
import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
|
|
3214
3774
|
import { join as join9 } from "path";
|
|
3215
|
-
import { Hono as
|
|
3775
|
+
import { Hono as Hono8 } from "hono";
|
|
3216
3776
|
|
|
3217
3777
|
// src/lifecycle/constants.ts
|
|
3218
3778
|
import { homedir as homedir4 } from "os";
|
|
@@ -3270,7 +3830,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3270
3830
|
}
|
|
3271
3831
|
}
|
|
3272
3832
|
function createLogsRoutes() {
|
|
3273
|
-
const app = new
|
|
3833
|
+
const app = new Hono8();
|
|
3274
3834
|
app.get("/", (c) => {
|
|
3275
3835
|
try {
|
|
3276
3836
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3340,8 +3900,8 @@ function createLogsRoutes() {
|
|
|
3340
3900
|
|
|
3341
3901
|
// src/api/routes/misc.routes.ts
|
|
3342
3902
|
import { spawn } from "child_process";
|
|
3343
|
-
import { createHmac, timingSafeEqual as
|
|
3344
|
-
import { Hono as
|
|
3903
|
+
import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
3904
|
+
import { Hono as Hono9 } from "hono";
|
|
3345
3905
|
import { hostname } from "os";
|
|
3346
3906
|
|
|
3347
3907
|
// src/config/update-config.ts
|
|
@@ -3351,15 +3911,15 @@ import { join as join10 } from "path";
|
|
|
3351
3911
|
import { parse as parseYaml } from "yaml";
|
|
3352
3912
|
|
|
3353
3913
|
// src/schemas/updateConfig.schema.ts
|
|
3354
|
-
import { z as
|
|
3355
|
-
var UpdateConfigSchema =
|
|
3356
|
-
auto_update:
|
|
3357
|
-
channel:
|
|
3358
|
-
allow:
|
|
3359
|
-
poll_interval_minutes:
|
|
3360
|
-
defer_if_active_sessions:
|
|
3361
|
-
github_repo:
|
|
3362
|
-
webhook_secret:
|
|
3914
|
+
import { z as z3 } from "zod";
|
|
3915
|
+
var UpdateConfigSchema = z3.object({
|
|
3916
|
+
auto_update: z3.boolean().default(false),
|
|
3917
|
+
channel: z3.enum(["stable", "next"]).default("stable"),
|
|
3918
|
+
allow: z3.array(z3.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
|
|
3919
|
+
poll_interval_minutes: z3.number().int().min(0).default(1440),
|
|
3920
|
+
defer_if_active_sessions: z3.boolean().default(true),
|
|
3921
|
+
github_repo: z3.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
|
|
3922
|
+
webhook_secret: z3.string().min(1).nullable().default(null)
|
|
3363
3923
|
}).strict();
|
|
3364
3924
|
|
|
3365
3925
|
// src/config/update-config.ts
|
|
@@ -3396,7 +3956,7 @@ function readJsonBody(req) {
|
|
|
3396
3956
|
req.on("error", reject);
|
|
3397
3957
|
});
|
|
3398
3958
|
}
|
|
3399
|
-
function
|
|
3959
|
+
function readRawBody4(req) {
|
|
3400
3960
|
return new Promise((resolve2, reject) => {
|
|
3401
3961
|
const chunks = [];
|
|
3402
3962
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3411,11 +3971,11 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3411
3971
|
const a = Buffer.from(provided, "utf-8");
|
|
3412
3972
|
const b = Buffer.from(expected, "utf-8");
|
|
3413
3973
|
if (a.length !== b.length) return false;
|
|
3414
|
-
return
|
|
3974
|
+
return timingSafeEqual3(a, b);
|
|
3415
3975
|
}
|
|
3416
3976
|
var clientLog = getLogger("client");
|
|
3417
3977
|
var createMiscRoutes = (deps) => {
|
|
3418
|
-
const app = new
|
|
3978
|
+
const app = new Hono9();
|
|
3419
3979
|
app.get("/api/info", (c) => {
|
|
3420
3980
|
const ptyIds = deps.ptyAttachedIds();
|
|
3421
3981
|
return c.json({
|
|
@@ -3423,7 +3983,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3423
3983
|
machineName: hostname(),
|
|
3424
3984
|
platform: process.platform,
|
|
3425
3985
|
activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
|
|
3426
|
-
publicUrl: deps.publicUrl
|
|
3986
|
+
publicUrl: deps.publicUrl,
|
|
3987
|
+
// Capability flag: this server serves /api/config/claude-flags. Additive —
|
|
3988
|
+
// older clients ignore it, and clients talking to an older server see it
|
|
3989
|
+
// absent and hide the UI rather than 404ing.
|
|
3990
|
+
claudeFlags: true
|
|
3427
3991
|
});
|
|
3428
3992
|
});
|
|
3429
3993
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -3440,7 +4004,32 @@ var createMiscRoutes = (deps) => {
|
|
|
3440
4004
|
}
|
|
3441
4005
|
});
|
|
3442
4006
|
});
|
|
3443
|
-
app.post("/api/push/register", (c) =>
|
|
4007
|
+
app.post("/api/push/register", async (c) => {
|
|
4008
|
+
const body = await readJsonBody(c.env.incoming).catch(() => null);
|
|
4009
|
+
const token = body?.token;
|
|
4010
|
+
const platform3 = body?.platform;
|
|
4011
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
4012
|
+
return c.json({ error: "Missing token" }, 400);
|
|
4013
|
+
}
|
|
4014
|
+
if (platform3 !== "ios" && platform3 !== "android") {
|
|
4015
|
+
return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
|
|
4016
|
+
}
|
|
4017
|
+
const repo = deps.pushRepo();
|
|
4018
|
+
if (!repo) {
|
|
4019
|
+
return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
4020
|
+
}
|
|
4021
|
+
repo.register({
|
|
4022
|
+
token,
|
|
4023
|
+
platform: platform3,
|
|
4024
|
+
deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
|
|
4025
|
+
});
|
|
4026
|
+
return c.json({ ok: true });
|
|
4027
|
+
});
|
|
4028
|
+
app.get("/api/push/health", (c) => {
|
|
4029
|
+
const repo = deps.pushRepo();
|
|
4030
|
+
if (!repo) return c.json({ tokens: [], available: false });
|
|
4031
|
+
return c.json({ tokens: repo.listHealth(), available: true });
|
|
4032
|
+
});
|
|
3444
4033
|
app.post("/api/__update", async (c) => {
|
|
3445
4034
|
const cfg = loadUpdateConfig();
|
|
3446
4035
|
if (!cfg?.webhook_secret) {
|
|
@@ -3448,7 +4037,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3448
4037
|
}
|
|
3449
4038
|
let body;
|
|
3450
4039
|
try {
|
|
3451
|
-
body = await
|
|
4040
|
+
body = await readRawBody4(c.env.incoming);
|
|
3452
4041
|
} catch {
|
|
3453
4042
|
return c.json({ error: "could not read body" }, 400);
|
|
3454
4043
|
}
|
|
@@ -3491,11 +4080,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3491
4080
|
};
|
|
3492
4081
|
|
|
3493
4082
|
// src/api/routes/pair.routes.ts
|
|
3494
|
-
import { Hono as
|
|
4083
|
+
import { Hono as Hono10 } from "hono";
|
|
3495
4084
|
var ALREADY_HANDLED3 = 597;
|
|
3496
4085
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3497
4086
|
var createPairRoutes = (deps) => {
|
|
3498
|
-
const app = new
|
|
4087
|
+
const app = new Hono10();
|
|
3499
4088
|
app.post("/start", (c) => {
|
|
3500
4089
|
deps.handlePairStart(c.env.outgoing);
|
|
3501
4090
|
return alreadyHandled3();
|
|
@@ -3508,11 +4097,11 @@ var createPairRoutes = (deps) => {
|
|
|
3508
4097
|
};
|
|
3509
4098
|
|
|
3510
4099
|
// src/api/routes/projects.routes.ts
|
|
3511
|
-
import { Hono as
|
|
4100
|
+
import { Hono as Hono11 } from "hono";
|
|
3512
4101
|
var ALREADY_HANDLED4 = 597;
|
|
3513
4102
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3514
4103
|
var createProjectRoutes = (deps) => {
|
|
3515
|
-
const app = new
|
|
4104
|
+
const app = new Hono11();
|
|
3516
4105
|
app.get("/", (c) => {
|
|
3517
4106
|
const url = new URL(c.req.url);
|
|
3518
4107
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3526,12 +4115,147 @@ var createProjectRoutes = (deps) => {
|
|
|
3526
4115
|
return app;
|
|
3527
4116
|
};
|
|
3528
4117
|
|
|
4118
|
+
// src/api/routes/providers.routes.ts
|
|
4119
|
+
import { Hono as Hono12 } from "hono";
|
|
4120
|
+
|
|
4121
|
+
// src/services/providers/providerHealth.ts
|
|
4122
|
+
import { execFile as execFile2 } from "child_process";
|
|
4123
|
+
|
|
4124
|
+
// src/services/providers/capabilities.ts
|
|
4125
|
+
var CLAUDE_CODE_CAPABILITIES = {
|
|
4126
|
+
freshSessionId: "explicit",
|
|
4127
|
+
resume: "native",
|
|
4128
|
+
systemPrompt: "flag",
|
|
4129
|
+
structuredQuestions: true,
|
|
4130
|
+
permissionGates: true,
|
|
4131
|
+
liveControl: true
|
|
4132
|
+
};
|
|
4133
|
+
var CODEX_CLI_CAPABILITIES = {
|
|
4134
|
+
freshSessionId: "late-bound",
|
|
4135
|
+
resume: "native",
|
|
4136
|
+
systemPrompt: "positional",
|
|
4137
|
+
structuredQuestions: false,
|
|
4138
|
+
permissionGates: true,
|
|
4139
|
+
liveControl: true
|
|
4140
|
+
};
|
|
4141
|
+
function capabilitiesFor(provider) {
|
|
4142
|
+
switch (provider) {
|
|
4143
|
+
case CLAUDE_CODE_PROVIDER:
|
|
4144
|
+
return CLAUDE_CODE_CAPABILITIES;
|
|
4145
|
+
case CODEX_CLI_PROVIDER:
|
|
4146
|
+
return CODEX_CLI_CAPABILITIES;
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
// src/services/providers/providerHealth.ts
|
|
4151
|
+
var VERIFIED_AGAINST = {
|
|
4152
|
+
[CLAUDE_CODE_PROVIDER]: { captured: ["2.1.214"], min: "2.1.0" },
|
|
4153
|
+
[CODEX_CLI_PROVIDER]: { captured: ["0.140.0-alpha.19"], min: "0.140.0" }
|
|
4154
|
+
};
|
|
4155
|
+
var VERSION_TIMEOUT_MS = 3e3;
|
|
4156
|
+
function parseVersionOutput(output) {
|
|
4157
|
+
const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
4158
|
+
return match ? match[0] : null;
|
|
4159
|
+
}
|
|
4160
|
+
function runVersion(exe) {
|
|
4161
|
+
return new Promise((resolve2) => {
|
|
4162
|
+
execFile2(exe, ["--version"], { timeout: VERSION_TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
4163
|
+
if (err && !stdout && !stderr) return resolve2(null);
|
|
4164
|
+
resolve2(parseVersionOutput(`${stdout}${stderr}`));
|
|
4165
|
+
});
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
function compareToVerified(version, verified) {
|
|
4169
|
+
if (version === null) {
|
|
4170
|
+
return {
|
|
4171
|
+
code: "version_undetectable",
|
|
4172
|
+
message: "Could not determine the installed version, so compatibility is unverified. Parsing and prompt detection may not match this build."
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
if (verified.captured.includes(version)) return null;
|
|
4176
|
+
const below = verified.min != null && compareSemver(version, verified.min) < 0;
|
|
4177
|
+
const above = verified.max != null && compareSemver(version, verified.max) > 0;
|
|
4178
|
+
if (!below && !above && verified.max != null) return null;
|
|
4179
|
+
if (!below && verified.max == null && !isNewerThanAllCaptured(version, verified.captured)) {
|
|
4180
|
+
return null;
|
|
4181
|
+
}
|
|
4182
|
+
return {
|
|
4183
|
+
code: "version_unverified",
|
|
4184
|
+
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.`
|
|
4185
|
+
};
|
|
4186
|
+
}
|
|
4187
|
+
function isNewerThanAllCaptured(version, captured) {
|
|
4188
|
+
return captured.every((c) => compareSemver(version, c) > 0);
|
|
4189
|
+
}
|
|
4190
|
+
function compareSemver(a, b) {
|
|
4191
|
+
const parse = (v) => {
|
|
4192
|
+
const [core, pre] = v.split("-", 2);
|
|
4193
|
+
const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
4194
|
+
return { nums, pre: pre ?? null };
|
|
4195
|
+
};
|
|
4196
|
+
const pa = parse(a);
|
|
4197
|
+
const pb = parse(b);
|
|
4198
|
+
for (let i = 0; i < 3; i++) {
|
|
4199
|
+
const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
|
|
4200
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
4201
|
+
}
|
|
4202
|
+
if (pa.pre === pb.pre) return 0;
|
|
4203
|
+
if (pa.pre === null) return 1;
|
|
4204
|
+
if (pb.pre === null) return -1;
|
|
4205
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
4206
|
+
}
|
|
4207
|
+
async function providerHealth(name, resolveExe, detect = runVersion) {
|
|
4208
|
+
const verifiedAgainst = VERIFIED_AGAINST[name];
|
|
4209
|
+
const capabilities = capabilitiesFor(name);
|
|
4210
|
+
let exe;
|
|
4211
|
+
try {
|
|
4212
|
+
exe = resolveExe();
|
|
4213
|
+
} catch {
|
|
4214
|
+
return {
|
|
4215
|
+
name,
|
|
4216
|
+
available: false,
|
|
4217
|
+
version: null,
|
|
4218
|
+
verifiedAgainst,
|
|
4219
|
+
capabilities,
|
|
4220
|
+
warnings: [
|
|
4221
|
+
{
|
|
4222
|
+
code: "provider_not_found",
|
|
4223
|
+
message: `${name} could not be located. Sessions for this provider cannot start.`
|
|
4224
|
+
}
|
|
4225
|
+
]
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
4228
|
+
const version = await detect(exe);
|
|
4229
|
+
const warning = compareToVerified(version, verifiedAgainst);
|
|
4230
|
+
return {
|
|
4231
|
+
name,
|
|
4232
|
+
available: true,
|
|
4233
|
+
version,
|
|
4234
|
+
verifiedAgainst,
|
|
4235
|
+
capabilities,
|
|
4236
|
+
warnings: warning ? [warning] : []
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
// src/api/routes/providers.routes.ts
|
|
4241
|
+
var createProviderRoutes = () => {
|
|
4242
|
+
const app = new Hono12();
|
|
4243
|
+
app.get("/", async (c) => {
|
|
4244
|
+
const providers = await Promise.all([
|
|
4245
|
+
providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
|
|
4246
|
+
providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
|
|
4247
|
+
]);
|
|
4248
|
+
return c.json({ providers });
|
|
4249
|
+
});
|
|
4250
|
+
return app;
|
|
4251
|
+
};
|
|
4252
|
+
|
|
3529
4253
|
// src/api/routes/scanner.routes.ts
|
|
3530
|
-
import { Hono as
|
|
4254
|
+
import { Hono as Hono13 } from "hono";
|
|
3531
4255
|
var ALREADY_HANDLED5 = 597;
|
|
3532
4256
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3533
4257
|
var createScannerRoutes = (deps) => {
|
|
3534
|
-
const app = new
|
|
4258
|
+
const app = new Hono13();
|
|
3535
4259
|
app.get("/api/search", async (c) => {
|
|
3536
4260
|
const url = new URL(c.req.url);
|
|
3537
4261
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3541,11 +4265,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3541
4265
|
};
|
|
3542
4266
|
|
|
3543
4267
|
// src/api/routes/sessions.routes.ts
|
|
3544
|
-
import { Hono as
|
|
4268
|
+
import { Hono as Hono14 } from "hono";
|
|
3545
4269
|
var ALREADY_HANDLED6 = 597;
|
|
3546
4270
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3547
4271
|
var createSessionRoutes = (deps) => {
|
|
3548
|
-
const app = new
|
|
4272
|
+
const app = new Hono14();
|
|
3549
4273
|
app.get("/count", (c) => {
|
|
3550
4274
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3551
4275
|
return alreadyHandled6();
|
|
@@ -3612,9 +4336,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3612
4336
|
};
|
|
3613
4337
|
|
|
3614
4338
|
// src/api/routes/ws.routes.ts
|
|
3615
|
-
import { Hono as
|
|
4339
|
+
import { Hono as Hono15 } from "hono";
|
|
3616
4340
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3617
|
-
const app = new
|
|
4341
|
+
const app = new Hono15();
|
|
3618
4342
|
app.get(
|
|
3619
4343
|
"/ws",
|
|
3620
4344
|
upgradeWebSocket(() => {
|
|
@@ -3640,7 +4364,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3640
4364
|
|
|
3641
4365
|
// src/api/app.ts
|
|
3642
4366
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3643
|
-
const app = new
|
|
4367
|
+
const app = new Hono16();
|
|
3644
4368
|
const httpLog = getLogger("http");
|
|
3645
4369
|
app.use("*", async (c, next) => {
|
|
3646
4370
|
const start = Date.now();
|
|
@@ -3665,7 +4389,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3665
4389
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3666
4390
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3667
4391
|
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
4392
|
+
app.route("/api/config", createConfigRoutes(deps));
|
|
3668
4393
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
4394
|
+
app.route("/api/providers", createProviderRoutes());
|
|
4395
|
+
app.route("/api/devices", createDeviceRoutes(deps));
|
|
3669
4396
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3670
4397
|
app.route("/api", createBrowseRoutes(deps));
|
|
3671
4398
|
app.route("/", createScannerRoutes(deps));
|
|
@@ -3875,11 +4602,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
|
3875
4602
|
}
|
|
3876
4603
|
|
|
3877
4604
|
// src/utils/fileIdentity.ts
|
|
3878
|
-
import { createHash } from "crypto";
|
|
4605
|
+
import { createHash as createHash2 } from "crypto";
|
|
3879
4606
|
function fileIdentity(stat3, headBytes) {
|
|
3880
4607
|
if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
|
|
3881
4608
|
const head = headBytes ?? Buffer.alloc(0);
|
|
3882
|
-
return `fp:${
|
|
4609
|
+
return `fp:${createHash2("sha1").update(head).digest("hex")}`;
|
|
3883
4610
|
}
|
|
3884
4611
|
function splitCompleteLines(buf, baseOffset) {
|
|
3885
4612
|
const spans = [];
|
|
@@ -5161,8 +5888,122 @@ var ConversationsRepository = class {
|
|
|
5161
5888
|
}
|
|
5162
5889
|
};
|
|
5163
5890
|
|
|
5891
|
+
// src/db/repositories/managed-sessions.repository.ts
|
|
5892
|
+
var ManagedSessionsRepository = class {
|
|
5893
|
+
upsertStmt;
|
|
5894
|
+
updateStatusStmt;
|
|
5895
|
+
getStmt;
|
|
5896
|
+
listNonTerminalStmt;
|
|
5897
|
+
deleteStmt;
|
|
5898
|
+
constructor(db) {
|
|
5899
|
+
this.upsertStmt = db.prepare(`
|
|
5900
|
+
INSERT INTO managed_sessions (
|
|
5901
|
+
session_id, provider, pid, cmdline, project_path, project_name, branch,
|
|
5902
|
+
status, status_source, status_updated_at, started_at, completed_at,
|
|
5903
|
+
last_activity_at, prompt_count, session_name, project_id,
|
|
5904
|
+
bound_conversation_id, resumed_from_conversation_id, failure_reason,
|
|
5905
|
+
streamer_instance_id
|
|
5906
|
+
) VALUES (
|
|
5907
|
+
@session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
|
|
5908
|
+
@status, @status_source, @status_updated_at, @started_at, @completed_at,
|
|
5909
|
+
@last_activity_at, @prompt_count, @session_name, @project_id,
|
|
5910
|
+
@bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
|
|
5911
|
+
@streamer_instance_id
|
|
5912
|
+
)
|
|
5913
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
5914
|
+
pid = excluded.pid,
|
|
5915
|
+
cmdline = excluded.cmdline,
|
|
5916
|
+
project_path = excluded.project_path,
|
|
5917
|
+
project_name = excluded.project_name,
|
|
5918
|
+
branch = excluded.branch,
|
|
5919
|
+
status = excluded.status,
|
|
5920
|
+
status_source = excluded.status_source,
|
|
5921
|
+
status_updated_at = excluded.status_updated_at,
|
|
5922
|
+
completed_at = excluded.completed_at,
|
|
5923
|
+
last_activity_at = excluded.last_activity_at,
|
|
5924
|
+
prompt_count = excluded.prompt_count,
|
|
5925
|
+
session_name = excluded.session_name,
|
|
5926
|
+
project_id = excluded.project_id,
|
|
5927
|
+
bound_conversation_id = excluded.bound_conversation_id,
|
|
5928
|
+
resumed_from_conversation_id = excluded.resumed_from_conversation_id,
|
|
5929
|
+
failure_reason = excluded.failure_reason,
|
|
5930
|
+
streamer_instance_id = excluded.streamer_instance_id
|
|
5931
|
+
`);
|
|
5932
|
+
this.updateStatusStmt = db.prepare(`
|
|
5933
|
+
UPDATE managed_sessions
|
|
5934
|
+
SET status = @status,
|
|
5935
|
+
status_source = @status_source,
|
|
5936
|
+
status_updated_at = @status_updated_at,
|
|
5937
|
+
completed_at = @completed_at,
|
|
5938
|
+
last_activity_at = @last_activity_at,
|
|
5939
|
+
prompt_count = @prompt_count,
|
|
5940
|
+
failure_reason = COALESCE(@failure_reason, failure_reason)
|
|
5941
|
+
WHERE session_id = @session_id
|
|
5942
|
+
`);
|
|
5943
|
+
this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
|
|
5944
|
+
this.listNonTerminalStmt = db.prepare(`
|
|
5945
|
+
SELECT * FROM managed_sessions
|
|
5946
|
+
WHERE completed_at IS NULL
|
|
5947
|
+
ORDER BY started_at ASC
|
|
5948
|
+
`);
|
|
5949
|
+
this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
|
|
5950
|
+
}
|
|
5951
|
+
/** Record a session at spawn, or refresh every field of an existing row. */
|
|
5952
|
+
recordSpawn({ session, pid, cmdline, streamerInstanceId }) {
|
|
5953
|
+
this.upsertStmt.run({
|
|
5954
|
+
session_id: session.id,
|
|
5955
|
+
provider: session.provider ?? "claude-code",
|
|
5956
|
+
pid,
|
|
5957
|
+
cmdline,
|
|
5958
|
+
project_path: session.projectPath,
|
|
5959
|
+
project_name: session.projectName,
|
|
5960
|
+
branch: session.branch ?? "",
|
|
5961
|
+
status: session.status,
|
|
5962
|
+
status_source: "spawn",
|
|
5963
|
+
status_updated_at: Date.now(),
|
|
5964
|
+
started_at: session.startedAt.getTime(),
|
|
5965
|
+
completed_at: session.completedAt?.getTime() ?? null,
|
|
5966
|
+
last_activity_at: session.lastActivityAt?.getTime() ?? null,
|
|
5967
|
+
prompt_count: session.promptCount,
|
|
5968
|
+
session_name: session.sessionName ?? null,
|
|
5969
|
+
project_id: session.projectId ?? null,
|
|
5970
|
+
bound_conversation_id: session.boundConversationId ?? null,
|
|
5971
|
+
resumed_from_conversation_id: session.resumedFromConversationId ?? null,
|
|
5972
|
+
failure_reason: session.failureReason ?? null,
|
|
5973
|
+
streamer_instance_id: streamerInstanceId
|
|
5974
|
+
});
|
|
5975
|
+
}
|
|
5976
|
+
/**
|
|
5977
|
+
* Persist a status transition. `source` is required rather than defaulted:
|
|
5978
|
+
* a status whose provenance is unknown is the thing this table exists to
|
|
5979
|
+
* prevent, and the reconciler reads it to decide how much to trust the value.
|
|
5980
|
+
*/
|
|
5981
|
+
recordStatus(sessionId, status, source, fields = {}) {
|
|
5982
|
+
this.updateStatusStmt.run({
|
|
5983
|
+
session_id: sessionId,
|
|
5984
|
+
status,
|
|
5985
|
+
status_source: source,
|
|
5986
|
+
status_updated_at: Date.now(),
|
|
5987
|
+
completed_at: fields.completedAt?.getTime() ?? null,
|
|
5988
|
+
last_activity_at: fields.lastActivityAt?.getTime() ?? null,
|
|
5989
|
+
prompt_count: fields.promptCount ?? 0,
|
|
5990
|
+
failure_reason: fields.failureReason ?? null
|
|
5991
|
+
});
|
|
5992
|
+
}
|
|
5993
|
+
get(sessionId) {
|
|
5994
|
+
return this.getStmt.get(sessionId) ?? null;
|
|
5995
|
+
}
|
|
5996
|
+
/** Rows with no recorded completion — the reconciler's probe set. */
|
|
5997
|
+
listNonTerminal() {
|
|
5998
|
+
return this.listNonTerminalStmt.all();
|
|
5999
|
+
}
|
|
6000
|
+
delete(sessionId) {
|
|
6001
|
+
this.deleteStmt.run(sessionId);
|
|
6002
|
+
}
|
|
6003
|
+
};
|
|
6004
|
+
|
|
5164
6005
|
// src/db/repositories/projects.repository.ts
|
|
5165
|
-
import { randomUUID as
|
|
6006
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
5166
6007
|
|
|
5167
6008
|
// src/utils/canonicalizeProjectPath.ts
|
|
5168
6009
|
function canonicalizeProjectPath(projectPath) {
|
|
@@ -5255,7 +6096,7 @@ var ProjectsRepository = class {
|
|
|
5255
6096
|
});
|
|
5256
6097
|
return rowToProject(this.getById.get(existing.id));
|
|
5257
6098
|
}
|
|
5258
|
-
const id =
|
|
6099
|
+
const id = randomUUID4();
|
|
5259
6100
|
this.insert.run({
|
|
5260
6101
|
id,
|
|
5261
6102
|
path,
|
|
@@ -5277,6 +6118,125 @@ function deriveNameFromPath(path) {
|
|
|
5277
6118
|
return parts.length > 0 ? parts[parts.length - 1] : null;
|
|
5278
6119
|
}
|
|
5279
6120
|
|
|
6121
|
+
// src/db/repositories/push.repository.ts
|
|
6122
|
+
var FAILURE_STREAK_LIMIT = 5;
|
|
6123
|
+
function tokenState(row) {
|
|
6124
|
+
if (row.revoked_at != null) return "revoked";
|
|
6125
|
+
if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
|
|
6126
|
+
if (row.failure_streak > 0) return "failing";
|
|
6127
|
+
if (row.last_success_at == null) return "never-delivered";
|
|
6128
|
+
return "healthy";
|
|
6129
|
+
}
|
|
6130
|
+
function toHealth(row) {
|
|
6131
|
+
return {
|
|
6132
|
+
platform: row.platform,
|
|
6133
|
+
deviceId: row.device_id,
|
|
6134
|
+
registeredAt: row.registered_at,
|
|
6135
|
+
lastSuccessAt: row.last_success_at,
|
|
6136
|
+
lastFailureAt: row.last_failure_at,
|
|
6137
|
+
lastFailureCode: row.last_failure_code,
|
|
6138
|
+
failureStreak: row.failure_streak,
|
|
6139
|
+
revokedAt: row.revoked_at,
|
|
6140
|
+
state: tokenState(row)
|
|
6141
|
+
};
|
|
6142
|
+
}
|
|
6143
|
+
var PushRepository = class {
|
|
6144
|
+
upsertStmt;
|
|
6145
|
+
getStmt;
|
|
6146
|
+
listActiveStmt;
|
|
6147
|
+
listAllStmt;
|
|
6148
|
+
successStmt;
|
|
6149
|
+
failureStmt;
|
|
6150
|
+
revokeStmt;
|
|
6151
|
+
claimEventStmt;
|
|
6152
|
+
markDeliveredStmt;
|
|
6153
|
+
constructor(db) {
|
|
6154
|
+
this.upsertStmt = db.prepare(`
|
|
6155
|
+
INSERT INTO push_tokens (token, platform, device_id, registered_at)
|
|
6156
|
+
VALUES (@token, @platform, @device_id, @registered_at)
|
|
6157
|
+
ON CONFLICT(token) DO UPDATE SET
|
|
6158
|
+
platform = excluded.platform,
|
|
6159
|
+
device_id = COALESCE(excluded.device_id, push_tokens.device_id),
|
|
6160
|
+
registered_at = excluded.registered_at,
|
|
6161
|
+
-- A fresh registration clears prior failure state and any revocation:
|
|
6162
|
+
-- the client is telling us this token is live again.
|
|
6163
|
+
failure_streak = 0,
|
|
6164
|
+
last_failure_at = NULL,
|
|
6165
|
+
last_failure_code = NULL,
|
|
6166
|
+
revoked_at = NULL
|
|
6167
|
+
`);
|
|
6168
|
+
this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
|
|
6169
|
+
this.listActiveStmt = db.prepare(`
|
|
6170
|
+
SELECT * FROM push_tokens
|
|
6171
|
+
WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
6172
|
+
ORDER BY registered_at ASC
|
|
6173
|
+
`);
|
|
6174
|
+
this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
|
|
6175
|
+
this.successStmt = db.prepare(`
|
|
6176
|
+
UPDATE push_tokens
|
|
6177
|
+
SET last_success_at = @at, failure_streak = 0,
|
|
6178
|
+
last_failure_code = NULL
|
|
6179
|
+
WHERE token = @token
|
|
6180
|
+
`);
|
|
6181
|
+
this.failureStmt = db.prepare(`
|
|
6182
|
+
UPDATE push_tokens
|
|
6183
|
+
SET last_failure_at = @at, last_failure_code = @code,
|
|
6184
|
+
failure_streak = failure_streak + 1
|
|
6185
|
+
WHERE token = @token
|
|
6186
|
+
`);
|
|
6187
|
+
this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
|
|
6188
|
+
this.claimEventStmt = db.prepare(`
|
|
6189
|
+
INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
|
|
6190
|
+
VALUES (@event_id, @session_id, @created_at)
|
|
6191
|
+
`);
|
|
6192
|
+
this.markDeliveredStmt = db.prepare(
|
|
6193
|
+
"UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
|
|
6194
|
+
);
|
|
6195
|
+
}
|
|
6196
|
+
register(args) {
|
|
6197
|
+
this.upsertStmt.run({
|
|
6198
|
+
token: args.token,
|
|
6199
|
+
platform: args.platform,
|
|
6200
|
+
device_id: args.deviceId ?? null,
|
|
6201
|
+
registered_at: args.now ?? Date.now()
|
|
6202
|
+
});
|
|
6203
|
+
}
|
|
6204
|
+
get(token) {
|
|
6205
|
+
return this.getStmt.get(token) ?? null;
|
|
6206
|
+
}
|
|
6207
|
+
/** Tokens eligible for delivery — not revoked, not past the failure limit. */
|
|
6208
|
+
listDeliverable() {
|
|
6209
|
+
return this.listActiveStmt.all();
|
|
6210
|
+
}
|
|
6211
|
+
/** Every token, including dead and revoked ones, for the health report. */
|
|
6212
|
+
listHealth() {
|
|
6213
|
+
return this.listAllStmt.all().map(toHealth);
|
|
6214
|
+
}
|
|
6215
|
+
recordSuccess(token, now = Date.now()) {
|
|
6216
|
+
this.successStmt.run({ token, at: now });
|
|
6217
|
+
}
|
|
6218
|
+
recordFailure(token, code, now = Date.now()) {
|
|
6219
|
+
this.failureStmt.run({ token, at: now, code });
|
|
6220
|
+
}
|
|
6221
|
+
revoke(token, now = Date.now()) {
|
|
6222
|
+
return this.revokeStmt.run(now, token).changes > 0;
|
|
6223
|
+
}
|
|
6224
|
+
/**
|
|
6225
|
+
* Claim an event id for delivery.
|
|
6226
|
+
*
|
|
6227
|
+
* Returns true exactly once per event id. A retry, a reconnect
|
|
6228
|
+
* reconciliation, or two triggers firing for the same underlying event all
|
|
6229
|
+
* get false and must not notify — the user should never be told twice about
|
|
6230
|
+
* one thing.
|
|
6231
|
+
*/
|
|
6232
|
+
claimEvent(eventId, sessionId, now = Date.now()) {
|
|
6233
|
+
return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
|
|
6234
|
+
}
|
|
6235
|
+
markDelivered(eventId, now = Date.now()) {
|
|
6236
|
+
this.markDeliveredStmt.run(now, eventId);
|
|
6237
|
+
}
|
|
6238
|
+
};
|
|
6239
|
+
|
|
5280
6240
|
// src/db/repositories/sessions.repository.ts
|
|
5281
6241
|
var SessionsRepository = class {
|
|
5282
6242
|
constructor(store) {
|
|
@@ -5345,8 +6305,20 @@ function handleListProjects(url, res) {
|
|
|
5345
6305
|
res.end(JSON.stringify({ projects: page, total }));
|
|
5346
6306
|
}
|
|
5347
6307
|
|
|
6308
|
+
// src/lifecycle/process-liveness.ts
|
|
6309
|
+
function isPidAlive(pid) {
|
|
6310
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
6311
|
+
try {
|
|
6312
|
+
process.kill(pid, 0);
|
|
6313
|
+
return true;
|
|
6314
|
+
} catch (err) {
|
|
6315
|
+
const code = err.code;
|
|
6316
|
+
return code === "EPERM";
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
|
|
5348
6320
|
// src/pair-store.ts
|
|
5349
|
-
import { randomBytes as
|
|
6321
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
5350
6322
|
var DEFAULT_TTL_SECONDS = 180;
|
|
5351
6323
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
5352
6324
|
var PairTokenStore = class {
|
|
@@ -5361,7 +6333,7 @@ var PairTokenStore = class {
|
|
|
5361
6333
|
}
|
|
5362
6334
|
}
|
|
5363
6335
|
mint() {
|
|
5364
|
-
const token = `pt_${
|
|
6336
|
+
const token = `pt_${randomBytes3(16).toString("hex")}`;
|
|
5365
6337
|
const expiresAt = Date.now() + this.ttlMs;
|
|
5366
6338
|
this.current = { token, expiresAt, used: false };
|
|
5367
6339
|
return {
|
|
@@ -5429,7 +6401,7 @@ function setCacheMetadata(repo, key, value) {
|
|
|
5429
6401
|
}
|
|
5430
6402
|
|
|
5431
6403
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5432
|
-
import { createHash as
|
|
6404
|
+
import { createHash as createHash3 } from "crypto";
|
|
5433
6405
|
import { existsSync as existsSync8 } from "fs";
|
|
5434
6406
|
|
|
5435
6407
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -5494,7 +6466,7 @@ function envInt(name, fallback) {
|
|
|
5494
6466
|
}
|
|
5495
6467
|
function fingerprintOf(ids) {
|
|
5496
6468
|
const sorted = [...ids].sort();
|
|
5497
|
-
return `sha256:${
|
|
6469
|
+
return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5498
6470
|
}
|
|
5499
6471
|
var CacheIntegrityMonitor = class {
|
|
5500
6472
|
constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
|
|
@@ -6308,6 +7280,112 @@ function conversationBusy(input) {
|
|
|
6308
7280
|
};
|
|
6309
7281
|
}
|
|
6310
7282
|
|
|
7283
|
+
// src/services/sessions/idempotency.ts
|
|
7284
|
+
var IDEMPOTENCY_TTL_MS = 10 * 60 * 1e3;
|
|
7285
|
+
var IDEMPOTENCY_MAX_KEYS = 200;
|
|
7286
|
+
var IdempotencyStore = class {
|
|
7287
|
+
constructor(ttlMs = IDEMPOTENCY_TTL_MS, maxKeys = IDEMPOTENCY_MAX_KEYS) {
|
|
7288
|
+
this.ttlMs = ttlMs;
|
|
7289
|
+
this.maxKeys = maxKeys;
|
|
7290
|
+
}
|
|
7291
|
+
ttlMs;
|
|
7292
|
+
maxKeys;
|
|
7293
|
+
bySession = /* @__PURE__ */ new Map();
|
|
7294
|
+
/**
|
|
7295
|
+
* Previously recorded result for this key, or null if the key is new,
|
|
7296
|
+
* expired, or evicted. A miss always means "treat as a fresh request" —
|
|
7297
|
+
* failing open, because dropping a real prompt is far worse than allowing a
|
|
7298
|
+
* rare duplicate.
|
|
7299
|
+
*/
|
|
7300
|
+
get(sessionId, key, now = Date.now()) {
|
|
7301
|
+
const entries = this.bySession.get(sessionId);
|
|
7302
|
+
if (!entries) return null;
|
|
7303
|
+
const hit = entries.find((e) => e.key === key);
|
|
7304
|
+
if (!hit) return null;
|
|
7305
|
+
if (now - hit.at > this.ttlMs) {
|
|
7306
|
+
this.bySession.set(
|
|
7307
|
+
sessionId,
|
|
7308
|
+
entries.filter((e) => e !== hit)
|
|
7309
|
+
);
|
|
7310
|
+
return null;
|
|
7311
|
+
}
|
|
7312
|
+
return hit.result;
|
|
7313
|
+
}
|
|
7314
|
+
/** Record the outcome of an accepted write so a retry can replay it. */
|
|
7315
|
+
set(sessionId, key, result, now = Date.now()) {
|
|
7316
|
+
const entries = this.bySession.get(sessionId) ?? [];
|
|
7317
|
+
const pruned = entries.filter((e) => e.key !== key && now - e.at <= this.ttlMs);
|
|
7318
|
+
pruned.push({ key, at: now, result });
|
|
7319
|
+
this.bySession.set(sessionId, pruned.slice(-this.maxKeys));
|
|
7320
|
+
}
|
|
7321
|
+
/** Drop everything for a session whose PTY is gone. */
|
|
7322
|
+
clear(sessionId) {
|
|
7323
|
+
this.bySession.delete(sessionId);
|
|
7324
|
+
}
|
|
7325
|
+
/** Test/diagnostic helper: how many keys are currently held for a session. */
|
|
7326
|
+
size(sessionId) {
|
|
7327
|
+
return this.bySession.get(sessionId)?.length ?? 0;
|
|
7328
|
+
}
|
|
7329
|
+
};
|
|
7330
|
+
function readIdempotencyKey(body) {
|
|
7331
|
+
const raw = body.idempotencyKey;
|
|
7332
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
7333
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > 200) {
|
|
7334
|
+
throw new Error("idempotencyKey must be a non-empty string of at most 200 characters");
|
|
7335
|
+
}
|
|
7336
|
+
return raw;
|
|
7337
|
+
}
|
|
7338
|
+
|
|
7339
|
+
// src/services/sessions/reconcileSessions.ts
|
|
7340
|
+
async function classifySession(row, probe, currentInstanceId) {
|
|
7341
|
+
const { session_id: sessionId } = row;
|
|
7342
|
+
if (row.completed_at != null) {
|
|
7343
|
+
const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
|
|
7344
|
+
return {
|
|
7345
|
+
sessionId,
|
|
7346
|
+
lifecycle: clean ? "completed" : "failed",
|
|
7347
|
+
reason: `terminal (${row.status_source})`
|
|
7348
|
+
};
|
|
7349
|
+
}
|
|
7350
|
+
if (row.pid == null) {
|
|
7351
|
+
return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
|
|
7352
|
+
}
|
|
7353
|
+
if (!probe.isPidAlive(row.pid)) {
|
|
7354
|
+
const clean = probe.endedCleanly?.(row) ?? false;
|
|
7355
|
+
if (clean) {
|
|
7356
|
+
return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
|
|
7357
|
+
}
|
|
7358
|
+
return {
|
|
7359
|
+
sessionId,
|
|
7360
|
+
lifecycle: "resumable",
|
|
7361
|
+
reason: "process gone, resumable from provider history"
|
|
7362
|
+
};
|
|
7363
|
+
}
|
|
7364
|
+
const args = await probe.getProcessArgs(row.pid);
|
|
7365
|
+
const token = row.cmdline;
|
|
7366
|
+
if (!token || !args?.includes(token)) {
|
|
7367
|
+
return {
|
|
7368
|
+
sessionId,
|
|
7369
|
+
lifecycle: "orphaned",
|
|
7370
|
+
reason: args ? "pid alive but command line does not match" : "pid alive but argv unreadable"
|
|
7371
|
+
};
|
|
7372
|
+
}
|
|
7373
|
+
const sameRun = row.streamer_instance_id === currentInstanceId;
|
|
7374
|
+
return {
|
|
7375
|
+
sessionId,
|
|
7376
|
+
lifecycle: sameRun ? "attached" : "detached",
|
|
7377
|
+
reason: sameRun ? "owned by this run" : "survived a previous streamer run"
|
|
7378
|
+
};
|
|
7379
|
+
}
|
|
7380
|
+
async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
7381
|
+
return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
|
|
7382
|
+
}
|
|
7383
|
+
|
|
7384
|
+
// src/types.ts
|
|
7385
|
+
function confidenceForSource(source) {
|
|
7386
|
+
return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
|
|
7387
|
+
}
|
|
7388
|
+
|
|
6311
7389
|
// src/session-store.ts
|
|
6312
7390
|
var SessionStore = class {
|
|
6313
7391
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -6447,6 +7525,14 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6447
7525
|
conversationId: s.id,
|
|
6448
7526
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
6449
7527
|
status: s.status,
|
|
7528
|
+
// Lifecycle for a session this run knows about. `attached` while we hold
|
|
7529
|
+
// its PTY; once the PTY is gone the session is terminal from this run's
|
|
7530
|
+
// perspective — `failed` when it recorded a reason, else `completed`.
|
|
7531
|
+
// Sessions left by *previous* runs never reach here: they aren't in the
|
|
7532
|
+
// in-memory store, and the boot reconciler classifies them instead
|
|
7533
|
+
// (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
7534
|
+
lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
|
|
7535
|
+
lifecycleSource: ptyAttached ? "spawn" : "exit",
|
|
6450
7536
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6451
7537
|
// `activity` is attached for managed sessions.
|
|
6452
7538
|
ownership: "managed",
|
|
@@ -6470,6 +7556,13 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6470
7556
|
...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
|
|
6471
7557
|
...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
|
|
6472
7558
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt.toISOString() },
|
|
7559
|
+
// C3: how the status was derived, and how far to trust it. Confidence is
|
|
7560
|
+
// derived from the source rather than stored, so the two cannot disagree.
|
|
7561
|
+
...s.statusSource != null && {
|
|
7562
|
+
statusSource: s.statusSource,
|
|
7563
|
+
statusConfidence: confidenceForSource(s.statusSource)
|
|
7564
|
+
},
|
|
7565
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt.toISOString() },
|
|
6473
7566
|
...s.filePath != null && { filePath: s.filePath },
|
|
6474
7567
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
6475
7568
|
...s.resumedFromConversationId != null && {
|
|
@@ -6491,6 +7584,12 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6491
7584
|
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6492
7585
|
// report "gone" here — a vanished process simply stops being listed.
|
|
6493
7586
|
processLiveness: "alive",
|
|
7587
|
+
// Alive, but spawned outside this streamer, so we hold no PTY for it. That
|
|
7588
|
+
// is precisely `detached` — and it is strictly more informative than the
|
|
7589
|
+
// `status: "idle"` above, which discovery is forced to report because it
|
|
7590
|
+
// cannot see the process's prompt state.
|
|
7591
|
+
lifecycle: "detached",
|
|
7592
|
+
lifecycleSource: "probe",
|
|
6494
7593
|
projectPath: d.projectPath,
|
|
6495
7594
|
projectName: d.projectName,
|
|
6496
7595
|
branch: d.branch,
|
|
@@ -6505,7 +7604,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6505
7604
|
}
|
|
6506
7605
|
|
|
6507
7606
|
// src/uploads.ts
|
|
6508
|
-
import { randomBytes as
|
|
7607
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
6509
7608
|
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
6510
7609
|
import heicConvert from "heic-convert";
|
|
6511
7610
|
import { join as join17 } from "path";
|
|
@@ -6539,7 +7638,7 @@ async function saveUploadFile(input) {
|
|
|
6539
7638
|
mimeType = "image/jpeg";
|
|
6540
7639
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
6541
7640
|
}
|
|
6542
|
-
const id = `up_${
|
|
7641
|
+
const id = `up_${randomBytes4(8).toString("hex")}`;
|
|
6543
7642
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
6544
7643
|
const dir = join17(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
6545
7644
|
await mkdir3(dir, { recursive: true });
|
|
@@ -6573,21 +7672,46 @@ function extractCodexText(content) {
|
|
|
6573
7672
|
return "";
|
|
6574
7673
|
}).filter(Boolean).join("").trim();
|
|
6575
7674
|
}
|
|
6576
|
-
|
|
7675
|
+
var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
|
|
7676
|
+
function classifyCodexLine(line) {
|
|
6577
7677
|
let entry;
|
|
6578
7678
|
try {
|
|
6579
7679
|
entry = JSON.parse(line);
|
|
6580
7680
|
} catch {
|
|
6581
|
-
return
|
|
7681
|
+
return { kind: "unknown", raw: line, reason: "line is not valid JSON" };
|
|
7682
|
+
}
|
|
7683
|
+
if (typeof entry.type !== "string" || !KNOWN_CODEX_TYPES.has(entry.type)) {
|
|
7684
|
+
return {
|
|
7685
|
+
kind: "unknown",
|
|
7686
|
+
raw: line,
|
|
7687
|
+
reason: `unrecognized rollout envelope type: ${String(entry.type)}`
|
|
7688
|
+
};
|
|
7689
|
+
}
|
|
7690
|
+
if (entry.type !== "response_item") {
|
|
7691
|
+
return { kind: "ignored", reason: `${entry.type} carries no chat content` };
|
|
6582
7692
|
}
|
|
6583
|
-
if (entry.type !== "response_item") return null;
|
|
6584
7693
|
const payload = entry.payload;
|
|
6585
|
-
if (payload?.type !== "message")
|
|
7694
|
+
if (payload?.type !== "message") {
|
|
7695
|
+
return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
|
|
7696
|
+
}
|
|
6586
7697
|
const role = payload.role;
|
|
6587
|
-
if (role !== "user" && role !== "assistant")
|
|
7698
|
+
if (role !== "user" && role !== "assistant") {
|
|
7699
|
+
return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
|
|
7700
|
+
}
|
|
6588
7701
|
const text = extractCodexText(payload.content);
|
|
6589
|
-
if (!text)
|
|
6590
|
-
|
|
7702
|
+
if (!text) {
|
|
7703
|
+
return { kind: "ignored", reason: "message has no extractable text" };
|
|
7704
|
+
}
|
|
7705
|
+
if (role === "user" && isCodexInjectedContext(text)) {
|
|
7706
|
+
return { kind: "ignored", reason: "synthetic injected context" };
|
|
7707
|
+
}
|
|
7708
|
+
return { kind: "message", line: buildClaudeShapedLine(entry, payload, role, text) };
|
|
7709
|
+
}
|
|
7710
|
+
function normalizeCodexLineToClaudeShape(line) {
|
|
7711
|
+
const result = classifyCodexLine(line);
|
|
7712
|
+
return result.kind === "message" ? result.line : null;
|
|
7713
|
+
}
|
|
7714
|
+
function buildClaudeShapedLine(entry, payload, role, text) {
|
|
6591
7715
|
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6592
7716
|
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
6593
7717
|
return JSON.stringify({
|
|
@@ -6643,13 +7767,13 @@ function hashPrefix(text) {
|
|
|
6643
7767
|
}
|
|
6644
7768
|
|
|
6645
7769
|
// src/utils/conversationEtag.ts
|
|
6646
|
-
import { createHash as
|
|
7770
|
+
import { createHash as createHash4 } from "crypto";
|
|
6647
7771
|
function computeConversationEtag({
|
|
6648
7772
|
filePath,
|
|
6649
7773
|
messageCount,
|
|
6650
7774
|
timestamp: timestamp2
|
|
6651
7775
|
}) {
|
|
6652
|
-
const digest =
|
|
7776
|
+
const digest = createHash4("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
6653
7777
|
return `"${digest}"`;
|
|
6654
7778
|
}
|
|
6655
7779
|
|
|
@@ -6792,6 +7916,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
|
|
|
6792
7916
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
6793
7917
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6794
7918
|
var GRACE_MAX_DEFERS = 4;
|
|
7919
|
+
var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
|
|
7920
|
+
var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
|
|
6795
7921
|
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6796
7922
|
var DISCOVERY_TTL_MS = 15e3;
|
|
6797
7923
|
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
@@ -6848,6 +7974,11 @@ var StreamerServer = class {
|
|
|
6848
7974
|
// to pendingQuestions; mobile answers it by sending the option index via
|
|
6849
7975
|
// /input { keys }. Cleared when the gate closes.
|
|
6850
7976
|
pendingPermission = /* @__PURE__ */ new Map();
|
|
7977
|
+
// Content key (prompt + detail + options + cursor) of the permission gate
|
|
7978
|
+
// currently broadcast for a session — mirrors pendingQuestionKey so a PTY
|
|
7979
|
+
// repaint of the same gate doesn't re-broadcast on every tick. Cleared
|
|
7980
|
+
// alongside pendingPermission.
|
|
7981
|
+
pendingPermissionKey = /* @__PURE__ */ new Map();
|
|
6851
7982
|
scanner = null;
|
|
6852
7983
|
// Set when better-sqlite3 is unusable (e.g. node ABI mismatch made
|
|
6853
7984
|
// ConversationCache.open throw), or when config.scannerPersistent is false
|
|
@@ -6908,6 +8039,16 @@ var StreamerServer = class {
|
|
|
6908
8039
|
defaultPermissionMode;
|
|
6909
8040
|
defaultModel;
|
|
6910
8041
|
defaultEffort;
|
|
8042
|
+
// Allowlisted Claude CLI flags + free-text escape hatch, applied to every
|
|
8043
|
+
// spawn. Resolved once at startup (flag → server.yaml), then mutated in place
|
|
8044
|
+
// by PUT /api/config/claude-flags so a change applies to the next session
|
|
8045
|
+
// without a restart.
|
|
8046
|
+
claudeFlags;
|
|
8047
|
+
claudeExtraArgs;
|
|
8048
|
+
// True when the values came from server.yaml (and so a write persists).
|
|
8049
|
+
// False when they were pinned by a CLI flag, mirroring the api-key rotate
|
|
8050
|
+
// contract: the write still takes effect in memory but won't survive restart.
|
|
8051
|
+
claudeFlagsPersistable;
|
|
6911
8052
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6912
8053
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6913
8054
|
// Consecutive grace-timer defers for a still-`running` session (see
|
|
@@ -6915,6 +8056,18 @@ var StreamerServer = class {
|
|
|
6915
8056
|
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6916
8057
|
// Map of sessionId → set of subscribed WS clients
|
|
6917
8058
|
sessionSubscribers = /* @__PURE__ */ new Map();
|
|
8059
|
+
// sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
|
|
8060
|
+
// every provider; read only by the idle reaper. Entries are dropped when the
|
|
8061
|
+
// session leaves the runner (reap/exit/hold).
|
|
8062
|
+
lastAgentChunkAt = /* @__PURE__ */ new Map();
|
|
8063
|
+
// Recently accepted input idempotency keys (C4). A retried POST replays its
|
|
8064
|
+
// original outcome instead of submitting the prompt to the agent twice.
|
|
8065
|
+
idempotency = new IdempotencyStore();
|
|
8066
|
+
// sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
|
|
8067
|
+
// this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
|
|
8068
|
+
sessionLifecycles = /* @__PURE__ */ new Map();
|
|
8069
|
+
// Periodic sweep that releases PTYs no agent is using. Null until listen().
|
|
8070
|
+
idleReaperTimer = null;
|
|
6918
8071
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
6919
8072
|
clientIdToWs = /* @__PURE__ */ new Map();
|
|
6920
8073
|
// Reverse map for cleanup on close
|
|
@@ -6924,7 +8077,20 @@ var StreamerServer = class {
|
|
|
6924
8077
|
projectsRepo = null;
|
|
6925
8078
|
conversationsRepo = null;
|
|
6926
8079
|
sessionsRepo = null;
|
|
8080
|
+
// Durable session registry (C1 Phase 2). Null when the cache DB failed to
|
|
8081
|
+
// open — persistence degrades to today's in-memory-only behaviour rather than
|
|
8082
|
+
// taking the server down with it, so every write goes through `?.`.
|
|
8083
|
+
managedSessionsRepo = null;
|
|
8084
|
+
// Identifies this streamer run. A registry row carrying a different id is a
|
|
8085
|
+
// session that outlived the process that started it.
|
|
8086
|
+
streamerInstanceId = randomUUID5();
|
|
6927
8087
|
cacheMetadataRepo = null;
|
|
8088
|
+
// Push registration + delivery state (C7). Null when the cache DB failed to
|
|
8089
|
+
// open — registration then degrades to a no-op rather than 500ing.
|
|
8090
|
+
pushRepo = null;
|
|
8091
|
+
// Paired-device registry (C5). Null when the cache DB failed to open — auth
|
|
8092
|
+
// then falls back to the shared API key alone, which is the pre-C5 behaviour.
|
|
8093
|
+
devicesRepo = null;
|
|
6928
8094
|
discoveryCache = null;
|
|
6929
8095
|
cacheDir;
|
|
6930
8096
|
tailSize;
|
|
@@ -6963,6 +8129,9 @@ var StreamerServer = class {
|
|
|
6963
8129
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6964
8130
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6965
8131
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
8132
|
+
this.claudeFlagsPersistable = config.claudeFlags === void 0;
|
|
8133
|
+
this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
|
|
8134
|
+
this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
6966
8135
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
|
|
6967
8136
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6968
8137
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
@@ -7087,6 +8256,7 @@ var StreamerServer = class {
|
|
|
7087
8256
|
this.ptyManager = new LiveSessionManager({
|
|
7088
8257
|
logger: getLogger("pty"),
|
|
7089
8258
|
onOutput: (sessionId, data) => {
|
|
8259
|
+
this.lastAgentChunkAt.set(sessionId, Date.now());
|
|
7090
8260
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
7091
8261
|
},
|
|
7092
8262
|
onUserMessage: (sessionId, text, ts) => {
|
|
@@ -7115,6 +8285,17 @@ var StreamerServer = class {
|
|
|
7115
8285
|
completedAt: session.completedAt,
|
|
7116
8286
|
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
|
|
7117
8287
|
});
|
|
8288
|
+
this.managedSessionsRepo?.recordStatus(
|
|
8289
|
+
session.id,
|
|
8290
|
+
session.status,
|
|
8291
|
+
session.completedAt != null ? "exit" : "transition",
|
|
8292
|
+
{
|
|
8293
|
+
completedAt: session.completedAt,
|
|
8294
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
8295
|
+
promptCount: session.promptCount,
|
|
8296
|
+
failureReason: session.failureReason ?? null
|
|
8297
|
+
}
|
|
8298
|
+
);
|
|
7118
8299
|
if (session.status === "waiting_input" || session.status === "idle") {
|
|
7119
8300
|
const filePath = this.sessionFileMap.get(session.id);
|
|
7120
8301
|
if (filePath) {
|
|
@@ -7145,6 +8326,7 @@ var StreamerServer = class {
|
|
|
7145
8326
|
this.cancelPendingQuestion(session.id);
|
|
7146
8327
|
}
|
|
7147
8328
|
this.pendingPermission.delete(session.id);
|
|
8329
|
+
this.pendingPermissionKey.delete(session.id);
|
|
7148
8330
|
this.contendedSessions.delete(session.id);
|
|
7149
8331
|
this.rememberSelfPtyEnded(session.id);
|
|
7150
8332
|
}
|
|
@@ -7185,6 +8367,8 @@ var StreamerServer = class {
|
|
|
7185
8367
|
localNoAuth: this.localNoAuth,
|
|
7186
8368
|
logMenubarRequests: this.logMenubarRequests,
|
|
7187
8369
|
rotateApiKey: () => this.rotateApiKey(),
|
|
8370
|
+
claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
|
|
8371
|
+
setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
|
|
7188
8372
|
publicUrl: this.publicUrl,
|
|
7189
8373
|
browseRoot: this.browseRoot,
|
|
7190
8374
|
browserCors: this.browserCors,
|
|
@@ -7193,6 +8377,8 @@ var StreamerServer = class {
|
|
|
7193
8377
|
wsHub: this.wsHub,
|
|
7194
8378
|
cache: () => this.cache,
|
|
7195
8379
|
cacheMonitor: () => this.cacheMonitor,
|
|
8380
|
+
pushRepo: () => this.pushRepo,
|
|
8381
|
+
devicesRepo: () => this.devicesRepo,
|
|
7196
8382
|
projectsRepo: () => this.projectsRepo,
|
|
7197
8383
|
conversationsRepo: () => this.conversationsRepo,
|
|
7198
8384
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -7226,7 +8412,9 @@ var StreamerServer = class {
|
|
|
7226
8412
|
handleMkdir: (req, res) => this.handleMkdir(req, res),
|
|
7227
8413
|
handleWsOpen: (ws) => {
|
|
7228
8414
|
this.wsHub.addClient(ws);
|
|
7229
|
-
const sessions = this.
|
|
8415
|
+
const sessions = this.withReconciledLifecycle(
|
|
8416
|
+
this.sessionStore.list(this.ptyAttachedIds())
|
|
8417
|
+
);
|
|
7230
8418
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
7231
8419
|
if (!this.currentWarmupState()) {
|
|
7232
8420
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
@@ -7302,11 +8490,8 @@ var StreamerServer = class {
|
|
|
7302
8490
|
this.clientIdToWs.delete(clientId);
|
|
7303
8491
|
this.wsToClientId.delete(ws);
|
|
7304
8492
|
}
|
|
7305
|
-
for (const
|
|
8493
|
+
for (const subscribers of this.sessionSubscribers.values()) {
|
|
7306
8494
|
subscribers.delete(ws);
|
|
7307
|
-
if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
|
|
7308
|
-
this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
|
|
7309
|
-
}
|
|
7310
8495
|
}
|
|
7311
8496
|
},
|
|
7312
8497
|
agentClient,
|
|
@@ -7369,7 +8554,7 @@ var StreamerServer = class {
|
|
|
7369
8554
|
const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
|
|
7370
8555
|
const payload = {
|
|
7371
8556
|
type: "session_list",
|
|
7372
|
-
sessions: this.sessionStore.list(this.ptyAttachedIds())
|
|
8557
|
+
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
7373
8558
|
};
|
|
7374
8559
|
if (ws) {
|
|
7375
8560
|
this.wsHub.unicast(ws, payload);
|
|
@@ -7377,6 +8562,29 @@ var StreamerServer = class {
|
|
|
7377
8562
|
this.wsHub.broadcast(payload);
|
|
7378
8563
|
}
|
|
7379
8564
|
}
|
|
8565
|
+
/**
|
|
8566
|
+
* Overlay boot-reconciliation verdicts onto session responses.
|
|
8567
|
+
*
|
|
8568
|
+
* A session left by a previous run is not in the in-memory store, so
|
|
8569
|
+
* SessionStore cannot classify it — it only ever sees what this run spawned.
|
|
8570
|
+
* Discovery may still surface the process, in which case the reconciler knows
|
|
8571
|
+
* strictly more about it than discovery does: it can tell `detached` (alive
|
|
8572
|
+
* and confirmed ours) from `orphaned` (alive but identity unconfirmed), which
|
|
8573
|
+
* a pid enumeration alone cannot.
|
|
8574
|
+
*
|
|
8575
|
+
* Only applied when the session is NOT live here: a session this run owns has
|
|
8576
|
+
* an authoritative lifecycle already, and a stale verdict must never override
|
|
8577
|
+
* it.
|
|
8578
|
+
*/
|
|
8579
|
+
withReconciledLifecycle(sessions) {
|
|
8580
|
+
if (this.sessionLifecycles.size === 0) return sessions;
|
|
8581
|
+
return sessions.map((s) => {
|
|
8582
|
+
if (s.ptyAttached) return s;
|
|
8583
|
+
const verdict = this.sessionLifecycles.get(s.id);
|
|
8584
|
+
if (!verdict) return s;
|
|
8585
|
+
return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
|
|
8586
|
+
});
|
|
8587
|
+
}
|
|
7380
8588
|
addSessionSubscriber(sessionId, ws) {
|
|
7381
8589
|
let subs = this.sessionSubscribers.get(sessionId);
|
|
7382
8590
|
if (!subs) {
|
|
@@ -7391,6 +8599,181 @@ var StreamerServer = class {
|
|
|
7391
8599
|
}
|
|
7392
8600
|
this.ptyGraceDeferCounts.delete(sessionId);
|
|
7393
8601
|
}
|
|
8602
|
+
/**
|
|
8603
|
+
* Classify sessions left behind by previous streamer runs (C1 Phase 3a).
|
|
8604
|
+
*
|
|
8605
|
+
* Agents already outlive the streamer today on the crash and dev-takeover
|
|
8606
|
+
* paths, which exit without reaching ptyManager.dispose() — they are just
|
|
8607
|
+
* invisible when they do, because nothing recorded that they existed. This
|
|
8608
|
+
* turns those rows into an explicit verdict per session.
|
|
8609
|
+
*
|
|
8610
|
+
* Read-only with respect to processes: it probes and classifies, and never
|
|
8611
|
+
* signals anything. `orphaned` is a report, not a cleanup trigger.
|
|
8612
|
+
*/
|
|
8613
|
+
async reconcilePreviousSessions() {
|
|
8614
|
+
if (!this.managedSessionsRepo) return [];
|
|
8615
|
+
let verdicts = [];
|
|
8616
|
+
try {
|
|
8617
|
+
const rows = this.managedSessionsRepo.listNonTerminal();
|
|
8618
|
+
if (rows.length === 0) return [];
|
|
8619
|
+
verdicts = await reconcileSessions(
|
|
8620
|
+
rows,
|
|
8621
|
+
{ isPidAlive, getProcessArgs },
|
|
8622
|
+
this.streamerInstanceId
|
|
8623
|
+
);
|
|
8624
|
+
for (const v of verdicts) {
|
|
8625
|
+
this.sessionLifecycles.set(v.sessionId, v.lifecycle);
|
|
8626
|
+
if (v.lifecycle === "completed" || v.lifecycle === "failed") {
|
|
8627
|
+
this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
|
|
8628
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
8629
|
+
});
|
|
8630
|
+
}
|
|
8631
|
+
}
|
|
8632
|
+
this.log.info(`[reconcile] classified ${verdicts.length} session(s) from previous runs`, {
|
|
8633
|
+
event: "sessions.reconciled",
|
|
8634
|
+
counts: verdicts.reduce((acc, v) => {
|
|
8635
|
+
acc[v.lifecycle] = (acc[v.lifecycle] ?? 0) + 1;
|
|
8636
|
+
return acc;
|
|
8637
|
+
}, {})
|
|
8638
|
+
});
|
|
8639
|
+
} catch (err) {
|
|
8640
|
+
this.log.warn("[reconcile] failed to reconcile previous sessions", {
|
|
8641
|
+
event: "sessions.reconcile_failed",
|
|
8642
|
+
err
|
|
8643
|
+
});
|
|
8644
|
+
}
|
|
8645
|
+
return verdicts;
|
|
8646
|
+
}
|
|
8647
|
+
/**
|
|
8648
|
+
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
8649
|
+
* reconciler's pid-reuse guard.
|
|
8650
|
+
*
|
|
8651
|
+
* Claude always passes the session id (`--resume <id>` or `--session-id
|
|
8652
|
+
* <id>`), so it is both present and unique. Codex only does on *resume*
|
|
8653
|
+
* (`codex resume <id>`); a fresh Codex spawn is `codex --cd <path>
|
|
8654
|
+
* --no-alt-screen` with no id at all, because the rollout id does not exist
|
|
8655
|
+
* until the CLI writes it. boundConversationId is what distinguishes the two:
|
|
8656
|
+
* it is set once that rollout has been discovered.
|
|
8657
|
+
*/
|
|
8658
|
+
spawnArgvToken(session) {
|
|
8659
|
+
if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
|
|
8660
|
+
return session.boundConversationId ?? session.projectPath;
|
|
8661
|
+
}
|
|
8662
|
+
/**
|
|
8663
|
+
* Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
|
|
8664
|
+
*
|
|
8665
|
+
* Called at each addManaged() site rather than inside SessionStore, because
|
|
8666
|
+
* the store is a pure in-memory structure with no DB dependency and adding
|
|
8667
|
+
* one would drag persistence into every unit test that touches it.
|
|
8668
|
+
*
|
|
8669
|
+
* Best-effort by design: a failed registry write must never break session
|
|
8670
|
+
* start. Losing a row costs post-restart *visibility* for that session, which
|
|
8671
|
+
* is strictly better than refusing to run the agent at all.
|
|
8672
|
+
*/
|
|
8673
|
+
recordSessionSpawn(session) {
|
|
8674
|
+
if (!this.managedSessionsRepo) return;
|
|
8675
|
+
try {
|
|
8676
|
+
const pid = this.ptyManager.getPid(session.id);
|
|
8677
|
+
this.managedSessionsRepo.recordSpawn({
|
|
8678
|
+
session,
|
|
8679
|
+
pid,
|
|
8680
|
+
// Identity guard against pid reuse: the reconciler requires this token
|
|
8681
|
+
// to appear in the live process's argv before it will claim the pid is
|
|
8682
|
+
// still ours (docs/architecture/2026-07-24-durable-session-runtime.md).
|
|
8683
|
+
//
|
|
8684
|
+
// Reading the real argv here would cost an async `ps` per session start
|
|
8685
|
+
// on a path the user is waiting on, so we record a token we already
|
|
8686
|
+
// know is in it. Claude always carries the session id (`--resume <id>`
|
|
8687
|
+
// on resume, `--session-id <id>` on fresh). A *fresh* Codex spawn does
|
|
8688
|
+
// not — its argv is only `--cd <path> --no-alt-screen`, because the
|
|
8689
|
+
// rollout id doesn't exist yet — so fall back to the project path,
|
|
8690
|
+
// which is present in every spawn path for both providers.
|
|
8691
|
+
//
|
|
8692
|
+
// The fallback is weaker: two sessions in one project share a token, so
|
|
8693
|
+
// it proves "a process of ours in this project" rather than "this exact
|
|
8694
|
+
// session". It still rejects an unrelated recycled pid, which is the
|
|
8695
|
+
// failure being guarded against.
|
|
8696
|
+
//
|
|
8697
|
+
// Note the Codex id is always *set* (a local placeholder) — it is just
|
|
8698
|
+
// not in the process's argv — so the choice keys off the provider, not
|
|
8699
|
+
// off the id being null.
|
|
8700
|
+
cmdline: pid != null ? this.spawnArgvToken(session) : null,
|
|
8701
|
+
streamerInstanceId: this.streamerInstanceId
|
|
8702
|
+
});
|
|
8703
|
+
} catch (err) {
|
|
8704
|
+
this.log.warn("[registry] failed to record session spawn", {
|
|
8705
|
+
event: "registry.spawn_write_failed",
|
|
8706
|
+
sessionId: session.id,
|
|
8707
|
+
err
|
|
8708
|
+
});
|
|
8709
|
+
}
|
|
8710
|
+
}
|
|
8711
|
+
/**
|
|
8712
|
+
* Stamp every live session as ended-by-shutdown before dispose() kills it.
|
|
8713
|
+
*
|
|
8714
|
+
* PTYManager.dispose() signals each child directly and fires no
|
|
8715
|
+
* onStatusChange, so the registry would otherwise keep rows sitting at
|
|
8716
|
+
* `running` forever and the next boot could not tell a deliberate restart
|
|
8717
|
+
* from a crash. Recording `shutdown` as the status source makes that
|
|
8718
|
+
* distinction explicit rather than inferred.
|
|
8719
|
+
*
|
|
8720
|
+
* Not a `completed_at` write for the agent's own work — the agent did not
|
|
8721
|
+
* finish, we stopped it — but the session is genuinely terminal, so it must
|
|
8722
|
+
* leave the reconciler's probe set.
|
|
8723
|
+
*/
|
|
8724
|
+
recordShutdownState() {
|
|
8725
|
+
if (!this.managedSessionsRepo) return;
|
|
8726
|
+
const now = /* @__PURE__ */ new Date();
|
|
8727
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
8728
|
+
try {
|
|
8729
|
+
this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
|
|
8730
|
+
completedAt: now,
|
|
8731
|
+
lastActivityAt: session.lastActivityAt ?? null,
|
|
8732
|
+
promptCount: session.promptCount
|
|
8733
|
+
});
|
|
8734
|
+
} catch (err) {
|
|
8735
|
+
this.log.warn("[registry] failed to record shutdown state", {
|
|
8736
|
+
event: "registry.shutdown_write_failed",
|
|
8737
|
+
sessionId: session.id,
|
|
8738
|
+
err
|
|
8739
|
+
});
|
|
8740
|
+
}
|
|
8741
|
+
}
|
|
8742
|
+
}
|
|
8743
|
+
/**
|
|
8744
|
+
* Release PTYs whose agent has been silent past IDLE_REAP_AFTER_MS.
|
|
8745
|
+
*
|
|
8746
|
+
* This is the bound that lets handleWsClose stop arming kill timers. The
|
|
8747
|
+
* distinction that matters: the old timer measured how long nobody was
|
|
8748
|
+
* *watching*, which is uncorrelated with whether work is in flight. This
|
|
8749
|
+
* measures how long the *agent* has produced nothing, and only ever considers
|
|
8750
|
+
* sessions that are already settled — a `running` PTY is skipped regardless of
|
|
8751
|
+
* age, so a long silent turn is never interrupted.
|
|
8752
|
+
*
|
|
8753
|
+
* Exposed (not private) so tests can drive one sweep deterministically instead
|
|
8754
|
+
* of waiting on the interval.
|
|
8755
|
+
*/
|
|
8756
|
+
reapIdleSessions(now = Date.now()) {
|
|
8757
|
+
const reaped = [];
|
|
8758
|
+
for (const session of this.ptyManager.listSessions()) {
|
|
8759
|
+
if (session.status === "running") continue;
|
|
8760
|
+
const lastActive = this.lastAgentChunkAt.get(session.id) ?? session.lastActivityAt?.getTime() ?? session.startedAt.getTime();
|
|
8761
|
+
if (now - lastActive < IDLE_REAP_AFTER_MS) continue;
|
|
8762
|
+
this.log.info(
|
|
8763
|
+
`[reap] releasing idle PTY for ${session.id} (idle ${Math.round((now - lastActive) / 6e4)}m)`,
|
|
8764
|
+
{ sessionId: session.id, event: "pty.idle_reap", idleMs: now - lastActive },
|
|
8765
|
+
"pino"
|
|
8766
|
+
);
|
|
8767
|
+
this.ptyManager.putOnHold(session.id);
|
|
8768
|
+
this.lastAgentChunkAt.delete(session.id);
|
|
8769
|
+
this.idempotency.clear(session.id);
|
|
8770
|
+
this.sessionSubscribers.delete(session.id);
|
|
8771
|
+
reaped.push(session.id);
|
|
8772
|
+
const held = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
8773
|
+
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
8774
|
+
}
|
|
8775
|
+
return reaped;
|
|
8776
|
+
}
|
|
7394
8777
|
startGraceTimer(sessionId, delayMs) {
|
|
7395
8778
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
7396
8779
|
if (existing) clearTimeout(existing);
|
|
@@ -7485,6 +8868,8 @@ var StreamerServer = class {
|
|
|
7485
8868
|
this.log.info("Database migrations applied", { event: "db.migrations_applied" });
|
|
7486
8869
|
}
|
|
7487
8870
|
await this.bindWithRetry(port);
|
|
8871
|
+
this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
|
|
8872
|
+
this.idleReaperTimer.unref?.();
|
|
7488
8873
|
const warmUp = new Promise((resolveWarm) => {
|
|
7489
8874
|
{
|
|
7490
8875
|
this.log.info(`Streamer server listening on port ${port}`, {
|
|
@@ -7518,7 +8903,11 @@ var StreamerServer = class {
|
|
|
7518
8903
|
this.projectsRepo = new ProjectsRepository(db);
|
|
7519
8904
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
7520
8905
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
8906
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(db);
|
|
8907
|
+
void this.reconcilePreviousSessions();
|
|
7521
8908
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
8909
|
+
this.pushRepo = new PushRepository(db);
|
|
8910
|
+
this.devicesRepo = new DevicesRepository(db);
|
|
7522
8911
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7523
8912
|
this.cache,
|
|
7524
8913
|
this.wsHub,
|
|
@@ -7736,6 +9125,12 @@ var StreamerServer = class {
|
|
|
7736
9125
|
async close() {
|
|
7737
9126
|
for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
|
|
7738
9127
|
this.ptyGraceTimers.clear();
|
|
9128
|
+
if (this.idleReaperTimer) {
|
|
9129
|
+
clearInterval(this.idleReaperTimer);
|
|
9130
|
+
this.idleReaperTimer = null;
|
|
9131
|
+
}
|
|
9132
|
+
this.lastAgentChunkAt.clear();
|
|
9133
|
+
this.recordShutdownState();
|
|
7739
9134
|
this.markScannerStaleDebounced.cancel();
|
|
7740
9135
|
await Promise.all([...this.inFlightCacheWrites]);
|
|
7741
9136
|
await Promise.all([...this.allScanners].map((s) => s.close()));
|
|
@@ -7825,12 +9220,28 @@ var StreamerServer = class {
|
|
|
7825
9220
|
ip,
|
|
7826
9221
|
ts
|
|
7827
9222
|
});
|
|
9223
|
+
let device = null;
|
|
9224
|
+
try {
|
|
9225
|
+
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
9226
|
+
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
9227
|
+
device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
|
|
9228
|
+
} catch (err) {
|
|
9229
|
+
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
9230
|
+
event: "pair.device_register_failed",
|
|
9231
|
+
err
|
|
9232
|
+
});
|
|
9233
|
+
}
|
|
7828
9234
|
json(res, 200, {
|
|
7829
9235
|
ciphertext: sealed.ciphertext,
|
|
7830
9236
|
nonce: sealed.nonce,
|
|
7831
9237
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
7832
9238
|
publicUrl: this.publicUrl,
|
|
7833
|
-
machineName: hostname2()
|
|
9239
|
+
machineName: hostname2(),
|
|
9240
|
+
...device && {
|
|
9241
|
+
deviceId: device.deviceId,
|
|
9242
|
+
deviceToken: device.deviceToken,
|
|
9243
|
+
capabilities: device.capabilities
|
|
9244
|
+
}
|
|
7834
9245
|
});
|
|
7835
9246
|
}
|
|
7836
9247
|
rotateApiKey() {
|
|
@@ -7847,6 +9258,48 @@ var StreamerServer = class {
|
|
|
7847
9258
|
});
|
|
7848
9259
|
return { newKey, persisted };
|
|
7849
9260
|
}
|
|
9261
|
+
getClaudeFlagsConfig() {
|
|
9262
|
+
return {
|
|
9263
|
+
registry: CLAUDE_FLAGS,
|
|
9264
|
+
values: this.claudeFlags,
|
|
9265
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
9266
|
+
persisted: this.claudeFlagsPersistable
|
|
9267
|
+
};
|
|
9268
|
+
}
|
|
9269
|
+
/**
|
|
9270
|
+
* Replace the per-server flag set. Applies to the NEXT spawn — a live PTY
|
|
9271
|
+
* keeps the argv it was started with.
|
|
9272
|
+
*
|
|
9273
|
+
* Mirrors rotateApiKey(): when the values were pinned by a CLI flag we still
|
|
9274
|
+
* apply them in memory but skip the server.yaml write, because the flag would
|
|
9275
|
+
* win again on restart and silently revert them.
|
|
9276
|
+
*
|
|
9277
|
+
* Logged with old→new at info level on purpose: this can disable the
|
|
9278
|
+
* permission prompts entirely, so it needs a forensic trail.
|
|
9279
|
+
*/
|
|
9280
|
+
setClaudeFlagsConfig(values, extraArgs) {
|
|
9281
|
+
const safe = validateFlagValues(values);
|
|
9282
|
+
const previous = { values: this.claudeFlags, extraArgs: this.claudeExtraArgs };
|
|
9283
|
+
if (this.claudeFlagsPersistable) {
|
|
9284
|
+
setClaudeExtraArgs(extraArgs);
|
|
9285
|
+
setClaudeFlags(safe);
|
|
9286
|
+
}
|
|
9287
|
+
this.claudeFlags = safe;
|
|
9288
|
+
this.claudeExtraArgs = extraArgs?.trim() ? extraArgs.trim() : void 0;
|
|
9289
|
+
this.log.info("Claude CLI flags updated", {
|
|
9290
|
+
event: "config.claude_flags_updated",
|
|
9291
|
+
persisted: this.claudeFlagsPersistable,
|
|
9292
|
+
previousValues: previous.values,
|
|
9293
|
+
previousExtraArgs: previous.extraArgs ?? null,
|
|
9294
|
+
values: this.claudeFlags,
|
|
9295
|
+
extraArgs: this.claudeExtraArgs ?? null
|
|
9296
|
+
});
|
|
9297
|
+
return {
|
|
9298
|
+
values: this.claudeFlags,
|
|
9299
|
+
extraArgs: this.claudeExtraArgs ?? null,
|
|
9300
|
+
persisted: this.claudeFlagsPersistable
|
|
9301
|
+
};
|
|
9302
|
+
}
|
|
7850
9303
|
checkRateLimit(map, key, limit, windowMs) {
|
|
7851
9304
|
const now = Date.now();
|
|
7852
9305
|
const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
@@ -8864,7 +10317,13 @@ var StreamerServer = class {
|
|
|
8864
10317
|
}
|
|
8865
10318
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
8866
10319
|
if (!hasPaginationParams) {
|
|
8867
|
-
json(
|
|
10320
|
+
json(
|
|
10321
|
+
res,
|
|
10322
|
+
200,
|
|
10323
|
+
this.withExternalActivity(
|
|
10324
|
+
this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
10325
|
+
)
|
|
10326
|
+
);
|
|
8868
10327
|
return;
|
|
8869
10328
|
}
|
|
8870
10329
|
const parsed = parseSessionListQuery(url);
|
|
@@ -8874,7 +10333,7 @@ var StreamerServer = class {
|
|
|
8874
10333
|
}
|
|
8875
10334
|
try {
|
|
8876
10335
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8877
|
-
page.sessions = this.withExternalActivity(page.sessions);
|
|
10336
|
+
page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
|
|
8878
10337
|
json(res, 200, page);
|
|
8879
10338
|
} catch (err) {
|
|
8880
10339
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -8891,6 +10350,9 @@ var StreamerServer = class {
|
|
|
8891
10350
|
if (!existsSync10(session.projectPath)) {
|
|
8892
10351
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
8893
10352
|
}
|
|
10353
|
+
const reconciled = this.withReconciledLifecycle([session])[0];
|
|
10354
|
+
session.lifecycle = reconciled.lifecycle;
|
|
10355
|
+
session.lifecycleSource = reconciled.lifecycleSource;
|
|
8894
10356
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
8895
10357
|
try {
|
|
8896
10358
|
const lines = await this.ptyManager.getOutputLines(sessionId, 10);
|
|
@@ -8988,10 +10450,13 @@ var StreamerServer = class {
|
|
|
8988
10450
|
projectName: body.projectName,
|
|
8989
10451
|
branch: body.branch,
|
|
8990
10452
|
permissionMode: this.defaultPermissionMode,
|
|
10453
|
+
claudeFlags: this.claudeFlags,
|
|
10454
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
8991
10455
|
model: this.defaultModel,
|
|
8992
10456
|
effort: this.defaultEffort
|
|
8993
10457
|
});
|
|
8994
10458
|
this.sessionStore.addManaged(session);
|
|
10459
|
+
this.recordSessionSpawn(session);
|
|
8995
10460
|
void this.watchConversationFile(sessionId);
|
|
8996
10461
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
8997
10462
|
this.broadcastOrUnicastSessionList(req);
|
|
@@ -9064,6 +10529,24 @@ var StreamerServer = class {
|
|
|
9064
10529
|
}
|
|
9065
10530
|
const body = await readBody(req);
|
|
9066
10531
|
const { input, keys } = body;
|
|
10532
|
+
let idempotencyKey;
|
|
10533
|
+
try {
|
|
10534
|
+
idempotencyKey = readIdempotencyKey(body);
|
|
10535
|
+
} catch (err) {
|
|
10536
|
+
json(res, 400, { error: err instanceof Error ? err.message : "Invalid idempotencyKey" });
|
|
10537
|
+
return;
|
|
10538
|
+
}
|
|
10539
|
+
if (idempotencyKey) {
|
|
10540
|
+
const replayed = this.idempotency.get(sessionId, idempotencyKey);
|
|
10541
|
+
if (replayed) {
|
|
10542
|
+
this.log.info(`[input.replay] ${sessionId.slice(0, 8)} duplicate idempotencyKey`, {
|
|
10543
|
+
event: "input.idempotent_replay",
|
|
10544
|
+
sessionId
|
|
10545
|
+
});
|
|
10546
|
+
json(res, replayed.status, replayed.body);
|
|
10547
|
+
return;
|
|
10548
|
+
}
|
|
10549
|
+
}
|
|
9067
10550
|
if (typeof keys === "string") {
|
|
9068
10551
|
try {
|
|
9069
10552
|
this.ptyManager.sendKeys(sessionId, keys);
|
|
@@ -9071,7 +10554,9 @@ var StreamerServer = class {
|
|
|
9071
10554
|
if (updated) {
|
|
9072
10555
|
this.wsHub.broadcast({ type: "session_update", session: updated });
|
|
9073
10556
|
}
|
|
9074
|
-
|
|
10557
|
+
const result = { status: 200, body: { ok: true } };
|
|
10558
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10559
|
+
json(res, result.status, result.body);
|
|
9075
10560
|
} catch (err) {
|
|
9076
10561
|
const message = err instanceof Error ? err.message : "Failed to send keys";
|
|
9077
10562
|
json(res, 400, { error: message });
|
|
@@ -9109,7 +10594,9 @@ var StreamerServer = class {
|
|
|
9109
10594
|
});
|
|
9110
10595
|
});
|
|
9111
10596
|
}
|
|
9112
|
-
|
|
10597
|
+
const result = { status: 200, body: { ok: true } };
|
|
10598
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10599
|
+
json(res, result.status, result.body);
|
|
9113
10600
|
} catch (err) {
|
|
9114
10601
|
const message = err instanceof Error ? err.message : "Failed to send input";
|
|
9115
10602
|
json(res, 400, { error: message });
|
|
@@ -9181,10 +10668,14 @@ var StreamerServer = class {
|
|
|
9181
10668
|
if (gate === null) {
|
|
9182
10669
|
if (!this.pendingPermission.has(sessionId)) return;
|
|
9183
10670
|
this.pendingPermission.delete(sessionId);
|
|
10671
|
+
this.pendingPermissionKey.delete(sessionId);
|
|
9184
10672
|
this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
|
|
9185
10673
|
return;
|
|
9186
10674
|
}
|
|
10675
|
+
const key = permissionContentKey(gate);
|
|
10676
|
+
if (this.pendingPermissionKey.get(sessionId) === key) return;
|
|
9187
10677
|
this.pendingPermission.set(sessionId, gate);
|
|
10678
|
+
this.pendingPermissionKey.set(sessionId, key);
|
|
9188
10679
|
const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
|
|
9189
10680
|
this.log.info(
|
|
9190
10681
|
`[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,
|
|
@@ -9410,10 +10901,13 @@ var StreamerServer = class {
|
|
|
9410
10901
|
projectName,
|
|
9411
10902
|
branch,
|
|
9412
10903
|
permissionMode: this.defaultPermissionMode,
|
|
10904
|
+
claudeFlags: this.claudeFlags,
|
|
10905
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9413
10906
|
model: this.defaultModel,
|
|
9414
10907
|
effort: this.defaultEffort
|
|
9415
10908
|
});
|
|
9416
10909
|
this.sessionStore.addManaged(session);
|
|
10910
|
+
this.recordSessionSpawn(session);
|
|
9417
10911
|
void this.watchConversationFile(session.id);
|
|
9418
10912
|
this.wsHub.broadcast({
|
|
9419
10913
|
type: "session_list",
|
|
@@ -9483,10 +10977,13 @@ var StreamerServer = class {
|
|
|
9483
10977
|
projectName: body.projectName,
|
|
9484
10978
|
systemPrompt: systemPromptParts.join("\n"),
|
|
9485
10979
|
permissionMode: this.defaultPermissionMode,
|
|
10980
|
+
claudeFlags: this.claudeFlags,
|
|
10981
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9486
10982
|
model: this.defaultModel,
|
|
9487
10983
|
effort: this.defaultEffort
|
|
9488
10984
|
});
|
|
9489
10985
|
this.sessionStore.addManaged(session);
|
|
10986
|
+
this.recordSessionSpawn(session);
|
|
9490
10987
|
const readyOrFailed = new Promise((resolve2) => {
|
|
9491
10988
|
const handler = (status) => {
|
|
9492
10989
|
if (status === "waiting_input" || status === "idle") {
|
|
@@ -10007,6 +11504,7 @@ export {
|
|
|
10007
11504
|
SessionStore,
|
|
10008
11505
|
StreamerServer,
|
|
10009
11506
|
WSHub,
|
|
11507
|
+
confidenceForSource,
|
|
10010
11508
|
createAgentClient,
|
|
10011
11509
|
createConversationWriter,
|
|
10012
11510
|
createPool,
|