@threadbase-sh/streamer 1.36.4 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +26477 -24984
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1635 -149
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +377 -4
- package/dist/index.d.ts +377 -4
- package/dist/index.js +1638 -153
- package/dist/index.js.map +1 -1
- package/dist/migrations/010_create_managed_sessions.sql +65 -0
- package/dist/migrations/011_create_devices.sql +39 -0
- package/dist/migrations/012_create_push_tokens.sql +49 -0
- package/package.json +1 -1
package/dist/index.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
|
}
|
|
@@ -1711,16 +1923,17 @@ var PTYManager = class {
|
|
|
1711
1923
|
}
|
|
1712
1924
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
1713
1925
|
//
|
|
1714
|
-
// options.permissionMode defaults to `acceptEdits`
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
1722
|
-
// `
|
|
1723
|
-
//
|
|
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.
|
|
1724
1937
|
// (The other first-run gates — onboarding/theme, workspace trust,
|
|
1725
1938
|
// custom-API-key — are cleared by the seeded ~/.claude.json in
|
|
1726
1939
|
// docker/entrypoint.sh.) startFresh() uses the same default for the same reason.
|
|
@@ -1738,28 +1951,27 @@ var PTYManager = class {
|
|
|
1738
1951
|
async doStart(sessionId, options) {
|
|
1739
1952
|
const nodePty = await loadPty2();
|
|
1740
1953
|
const projectName = options.projectName ?? basename2(options.projectPath);
|
|
1741
|
-
const
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
);
|
|
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
|
+
});
|
|
1763
1975
|
const session = {
|
|
1764
1976
|
id: sessionId,
|
|
1765
1977
|
provider: CLAUDE_CODE_PROVIDER,
|
|
@@ -1767,6 +1979,8 @@ var PTYManager = class {
|
|
|
1767
1979
|
projectName,
|
|
1768
1980
|
branch: options.branch ?? "",
|
|
1769
1981
|
status: "running",
|
|
1982
|
+
statusSource: "spawn",
|
|
1983
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1770
1984
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1771
1985
|
completedAt: null,
|
|
1772
1986
|
promptCount: 0,
|
|
@@ -1794,11 +2008,12 @@ var PTYManager = class {
|
|
|
1794
2008
|
const nodePty = await loadPty2();
|
|
1795
2009
|
const sessionId = randomUUID2();
|
|
1796
2010
|
const projectName = options.projectName ?? basename2(options.projectPath);
|
|
2011
|
+
const permissionMode = options.permissionMode ?? "acceptEdits";
|
|
1797
2012
|
const args = [
|
|
1798
2013
|
"--permission-mode",
|
|
1799
|
-
|
|
2014
|
+
permissionMode,
|
|
1800
2015
|
"--settings",
|
|
1801
|
-
|
|
2016
|
+
buildSettingsJson(permissionMode),
|
|
1802
2017
|
"--model",
|
|
1803
2018
|
options.model ?? "sonnet",
|
|
1804
2019
|
"--effort",
|
|
@@ -1809,6 +2024,7 @@ var PTYManager = class {
|
|
|
1809
2024
|
if (options.systemPrompt) {
|
|
1810
2025
|
args.push("--system-prompt", options.systemPrompt);
|
|
1811
2026
|
}
|
|
2027
|
+
args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
|
|
1812
2028
|
const proc = nodePty.spawn(resolveClaudeExe(), args, {
|
|
1813
2029
|
name: "xterm-256color",
|
|
1814
2030
|
cols: 120,
|
|
@@ -1823,6 +2039,8 @@ var PTYManager = class {
|
|
|
1823
2039
|
projectName,
|
|
1824
2040
|
branch: "",
|
|
1825
2041
|
status: "running",
|
|
2042
|
+
statusSource: "spawn",
|
|
2043
|
+
statusUpdatedAt: /* @__PURE__ */ new Date(),
|
|
1826
2044
|
startedAt: /* @__PURE__ */ new Date(),
|
|
1827
2045
|
completedAt: null,
|
|
1828
2046
|
promptCount: 0,
|
|
@@ -1853,6 +2071,8 @@ var PTYManager = class {
|
|
|
1853
2071
|
}
|
|
1854
2072
|
if (session.status === "waiting_input") {
|
|
1855
2073
|
session.status = "running";
|
|
2074
|
+
session.statusSource = "user-input";
|
|
2075
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1856
2076
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1857
2077
|
}
|
|
1858
2078
|
this.log.info(
|
|
@@ -1888,6 +2108,8 @@ var PTYManager = class {
|
|
|
1888
2108
|
}
|
|
1889
2109
|
if (session.status === "waiting_input") {
|
|
1890
2110
|
session.status = "running";
|
|
2111
|
+
session.statusSource = "user-input";
|
|
2112
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
1891
2113
|
this.onStatusChange?.(toPublicSession2(session));
|
|
1892
2114
|
}
|
|
1893
2115
|
this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
|
|
@@ -2009,6 +2231,8 @@ var PTYManager = class {
|
|
|
2009
2231
|
} catch {
|
|
2010
2232
|
}
|
|
2011
2233
|
session.status = "idle";
|
|
2234
|
+
session.statusSource = "shutdown";
|
|
2235
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2012
2236
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2013
2237
|
session.screen.dispose();
|
|
2014
2238
|
this.sessions.delete(sessionId);
|
|
@@ -2044,6 +2268,13 @@ var PTYManager = class {
|
|
|
2044
2268
|
getInputHistory(sessionId) {
|
|
2045
2269
|
return this.sessions.get(sessionId)?.inputHistory ?? [];
|
|
2046
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
|
+
}
|
|
2047
2278
|
// Record a submitted user message as ground truth and fire onUserMessage.
|
|
2048
2279
|
// Called from writeSubmit (both direct and flush paths) — never from
|
|
2049
2280
|
// sendKeys, so raw keystrokes aren't logged as messages.
|
|
@@ -2120,9 +2351,9 @@ var PTYManager = class {
|
|
|
2120
2351
|
session.lastOutput = stripped;
|
|
2121
2352
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
|
|
2122
2353
|
if (session.status === "running" && matchedMarker) {
|
|
2123
|
-
this.markReady(sessionId, session, `marker:${matchedMarker}`);
|
|
2354
|
+
this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
|
|
2124
2355
|
} else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
|
|
2125
|
-
this.markReady(sessionId, session, "fallback:timeout");
|
|
2356
|
+
this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
|
|
2126
2357
|
}
|
|
2127
2358
|
this.onOutput?.(sessionId, data);
|
|
2128
2359
|
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
@@ -2222,7 +2453,7 @@ var PTYManager = class {
|
|
|
2222
2453
|
const session = this.sessions.get(sessionId);
|
|
2223
2454
|
if (session?.status !== "running") return;
|
|
2224
2455
|
if (this.pendingReady.has(sessionId)) {
|
|
2225
|
-
this.markReady(sessionId, session, "quiet:timeout");
|
|
2456
|
+
this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
|
|
2226
2457
|
} else {
|
|
2227
2458
|
this.recheckReadyFromScreen(sessionId).catch((err) => {
|
|
2228
2459
|
this.log.warn("[pty.ready] screen recheck failed", {
|
|
@@ -2251,14 +2482,16 @@ var PTYManager = class {
|
|
|
2251
2482
|
const lines = await this.getOutputLines(sessionId, PTY_ROWS2);
|
|
2252
2483
|
const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => lines.some((l) => l.includes(m)));
|
|
2253
2484
|
if (matchedMarker && session.status === "running") {
|
|
2254
|
-
this.markReady(sessionId, session, `quiet:screen-marker:${matchedMarker}`);
|
|
2485
|
+
this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
|
|
2255
2486
|
}
|
|
2256
2487
|
}
|
|
2257
2488
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
2258
2489
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
2259
|
-
markReady(sessionId, session, reason) {
|
|
2490
|
+
markReady(sessionId, session, source, reason) {
|
|
2260
2491
|
session.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2261
2492
|
session.status = "waiting_input";
|
|
2493
|
+
session.statusSource = source;
|
|
2494
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2262
2495
|
const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
|
|
2263
2496
|
this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
|
|
2264
2497
|
event: "pty.ready",
|
|
@@ -2278,6 +2511,8 @@ var PTYManager = class {
|
|
|
2278
2511
|
if (!session) return;
|
|
2279
2512
|
session.completedAt = /* @__PURE__ */ new Date();
|
|
2280
2513
|
session.status = "idle";
|
|
2514
|
+
session.statusSource = "process-exit";
|
|
2515
|
+
session.statusUpdatedAt = /* @__PURE__ */ new Date();
|
|
2281
2516
|
const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
|
|
2282
2517
|
if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
|
|
2283
2518
|
if (!existsSync3(session.projectPath)) {
|
|
@@ -2312,6 +2547,8 @@ function toPublicSession2(s) {
|
|
|
2312
2547
|
lastOutput: s.lastOutput,
|
|
2313
2548
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
2314
2549
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2550
|
+
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2551
|
+
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2315
2552
|
...s.filePath != null && { filePath: s.filePath }
|
|
2316
2553
|
};
|
|
2317
2554
|
}
|
|
@@ -2384,6 +2621,16 @@ var LiveSessionManager = class {
|
|
|
2384
2621
|
}
|
|
2385
2622
|
return null;
|
|
2386
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
|
+
}
|
|
2387
2634
|
hasSession(sessionId) {
|
|
2388
2635
|
for (const runner of this.runners.values()) {
|
|
2389
2636
|
if (runner.hasSession(sessionId)) return true;
|
|
@@ -2560,6 +2807,18 @@ async function getProcessCwdUnix(pid) {
|
|
|
2560
2807
|
async function getProcessArgsUnix(pid) {
|
|
2561
2808
|
return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
|
|
2562
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
|
+
}
|
|
2563
2822
|
async function getProcessStartTimeUnix(pid) {
|
|
2564
2823
|
const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
|
|
2565
2824
|
const d = new Date(raw);
|
|
@@ -2682,6 +2941,7 @@ import {
|
|
|
2682
2941
|
ConversationScanner,
|
|
2683
2942
|
search
|
|
2684
2943
|
} from "@threadbase-sh/scanner";
|
|
2944
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2685
2945
|
import { EventEmitter } from "events";
|
|
2686
2946
|
import {
|
|
2687
2947
|
createReadStream,
|
|
@@ -2953,7 +3213,202 @@ async function handleStartAgentSession(body, deps) {
|
|
|
2953
3213
|
}
|
|
2954
3214
|
|
|
2955
3215
|
// src/api/app.ts
|
|
2956
|
-
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
|
+
};
|
|
2957
3412
|
|
|
2958
3413
|
// src/api/middleware/auth.middleware.ts
|
|
2959
3414
|
function isLocalRequest(remoteAddr) {
|
|
@@ -2984,19 +3439,40 @@ var authMiddleware = (deps) => async (c, next) => {
|
|
|
2984
3439
|
}
|
|
2985
3440
|
}
|
|
2986
3441
|
const authorization = c.req.header("authorization");
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
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 {
|
|
2992
3459
|
}
|
|
3460
|
+
} else if (validateApiKey(presented, deps.apiKey)) {
|
|
3461
|
+
principal = legacyPrincipal();
|
|
3462
|
+
}
|
|
3463
|
+
if (!principal) {
|
|
3464
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
2993
3465
|
}
|
|
2994
|
-
const
|
|
2995
|
-
if (
|
|
3466
|
+
const required = requiredCapability(path, method);
|
|
3467
|
+
if (required === null) {
|
|
2996
3468
|
await next();
|
|
2997
3469
|
return;
|
|
2998
3470
|
}
|
|
2999
|
-
|
|
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();
|
|
3000
3476
|
};
|
|
3001
3477
|
|
|
3002
3478
|
// src/api/middleware/cors.middleware.ts
|
|
@@ -3135,12 +3611,67 @@ var createCacheAlertRoutes = (deps) => {
|
|
|
3135
3611
|
return app;
|
|
3136
3612
|
};
|
|
3137
3613
|
|
|
3138
|
-
// src/api/routes/
|
|
3614
|
+
// src/api/routes/config.routes.ts
|
|
3139
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";
|
|
3140
3671
|
var ALREADY_HANDLED2 = 597;
|
|
3141
3672
|
var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
|
|
3142
3673
|
var createConversationRoutes = (deps) => {
|
|
3143
|
-
const app = new
|
|
3674
|
+
const app = new Hono5();
|
|
3144
3675
|
app.get("/count", async (c) => {
|
|
3145
3676
|
const url = new URL(c.req.url);
|
|
3146
3677
|
await deps.handleConversationsCount(url, c.env.outgoing);
|
|
@@ -3166,8 +3697,34 @@ var createConversationRoutes = (deps) => {
|
|
|
3166
3697
|
return app;
|
|
3167
3698
|
};
|
|
3168
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
|
+
|
|
3169
3726
|
// src/api/routes/health.routes.ts
|
|
3170
|
-
import { Hono as
|
|
3727
|
+
import { Hono as Hono7 } from "hono";
|
|
3171
3728
|
|
|
3172
3729
|
// src/version.ts
|
|
3173
3730
|
import { readFileSync as readFileSync4, realpathSync } from "fs";
|
|
@@ -3204,7 +3761,7 @@ function resolveVersion() {
|
|
|
3204
3761
|
|
|
3205
3762
|
// src/api/routes/health.routes.ts
|
|
3206
3763
|
var createHealthRoutes = (deps) => {
|
|
3207
|
-
const app = new
|
|
3764
|
+
const app = new Hono7();
|
|
3208
3765
|
app.get("/", (c) => {
|
|
3209
3766
|
const cacheAlert = deps.cacheMonitor()?.healthzField();
|
|
3210
3767
|
return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
|
|
@@ -3215,7 +3772,7 @@ var createHealthRoutes = (deps) => {
|
|
|
3215
3772
|
// src/api/routes/logs.routes.ts
|
|
3216
3773
|
import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
|
|
3217
3774
|
import { join as join9 } from "path";
|
|
3218
|
-
import { Hono as
|
|
3775
|
+
import { Hono as Hono8 } from "hono";
|
|
3219
3776
|
|
|
3220
3777
|
// src/lifecycle/constants.ts
|
|
3221
3778
|
import { homedir as homedir4 } from "os";
|
|
@@ -3273,7 +3830,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
3273
3830
|
}
|
|
3274
3831
|
}
|
|
3275
3832
|
function createLogsRoutes() {
|
|
3276
|
-
const app = new
|
|
3833
|
+
const app = new Hono8();
|
|
3277
3834
|
app.get("/", (c) => {
|
|
3278
3835
|
try {
|
|
3279
3836
|
const sourceParam = (c.req.query("source") || "").toLowerCase();
|
|
@@ -3343,8 +3900,8 @@ function createLogsRoutes() {
|
|
|
3343
3900
|
|
|
3344
3901
|
// src/api/routes/misc.routes.ts
|
|
3345
3902
|
import { spawn } from "child_process";
|
|
3346
|
-
import { createHmac, timingSafeEqual as
|
|
3347
|
-
import { Hono as
|
|
3903
|
+
import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
3904
|
+
import { Hono as Hono9 } from "hono";
|
|
3348
3905
|
import { hostname } from "os";
|
|
3349
3906
|
|
|
3350
3907
|
// src/config/update-config.ts
|
|
@@ -3354,15 +3911,15 @@ import { join as join10 } from "path";
|
|
|
3354
3911
|
import { parse as parseYaml } from "yaml";
|
|
3355
3912
|
|
|
3356
3913
|
// src/schemas/updateConfig.schema.ts
|
|
3357
|
-
import { z as
|
|
3358
|
-
var UpdateConfigSchema =
|
|
3359
|
-
auto_update:
|
|
3360
|
-
channel:
|
|
3361
|
-
allow:
|
|
3362
|
-
poll_interval_minutes:
|
|
3363
|
-
defer_if_active_sessions:
|
|
3364
|
-
github_repo:
|
|
3365
|
-
webhook_secret:
|
|
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)
|
|
3366
3923
|
}).strict();
|
|
3367
3924
|
|
|
3368
3925
|
// src/config/update-config.ts
|
|
@@ -3399,7 +3956,7 @@ function readJsonBody(req) {
|
|
|
3399
3956
|
req.on("error", reject);
|
|
3400
3957
|
});
|
|
3401
3958
|
}
|
|
3402
|
-
function
|
|
3959
|
+
function readRawBody4(req) {
|
|
3403
3960
|
return new Promise((resolve2, reject) => {
|
|
3404
3961
|
const chunks = [];
|
|
3405
3962
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -3414,11 +3971,11 @@ function verifyWebhookSignature(body, header, secret) {
|
|
|
3414
3971
|
const a = Buffer.from(provided, "utf-8");
|
|
3415
3972
|
const b = Buffer.from(expected, "utf-8");
|
|
3416
3973
|
if (a.length !== b.length) return false;
|
|
3417
|
-
return
|
|
3974
|
+
return timingSafeEqual3(a, b);
|
|
3418
3975
|
}
|
|
3419
3976
|
var clientLog = getLogger("client");
|
|
3420
3977
|
var createMiscRoutes = (deps) => {
|
|
3421
|
-
const app = new
|
|
3978
|
+
const app = new Hono9();
|
|
3422
3979
|
app.get("/api/info", (c) => {
|
|
3423
3980
|
const ptyIds = deps.ptyAttachedIds();
|
|
3424
3981
|
return c.json({
|
|
@@ -3426,7 +3983,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3426
3983
|
machineName: hostname(),
|
|
3427
3984
|
platform: process.platform,
|
|
3428
3985
|
activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
|
|
3429
|
-
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
|
|
3430
3991
|
});
|
|
3431
3992
|
});
|
|
3432
3993
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -3443,7 +4004,32 @@ var createMiscRoutes = (deps) => {
|
|
|
3443
4004
|
}
|
|
3444
4005
|
});
|
|
3445
4006
|
});
|
|
3446
|
-
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
|
+
});
|
|
3447
4033
|
app.post("/api/__update", async (c) => {
|
|
3448
4034
|
const cfg = loadUpdateConfig();
|
|
3449
4035
|
if (!cfg?.webhook_secret) {
|
|
@@ -3451,7 +4037,7 @@ var createMiscRoutes = (deps) => {
|
|
|
3451
4037
|
}
|
|
3452
4038
|
let body;
|
|
3453
4039
|
try {
|
|
3454
|
-
body = await
|
|
4040
|
+
body = await readRawBody4(c.env.incoming);
|
|
3455
4041
|
} catch {
|
|
3456
4042
|
return c.json({ error: "could not read body" }, 400);
|
|
3457
4043
|
}
|
|
@@ -3494,11 +4080,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3494
4080
|
};
|
|
3495
4081
|
|
|
3496
4082
|
// src/api/routes/pair.routes.ts
|
|
3497
|
-
import { Hono as
|
|
4083
|
+
import { Hono as Hono10 } from "hono";
|
|
3498
4084
|
var ALREADY_HANDLED3 = 597;
|
|
3499
4085
|
var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
|
|
3500
4086
|
var createPairRoutes = (deps) => {
|
|
3501
|
-
const app = new
|
|
4087
|
+
const app = new Hono10();
|
|
3502
4088
|
app.post("/start", (c) => {
|
|
3503
4089
|
deps.handlePairStart(c.env.outgoing);
|
|
3504
4090
|
return alreadyHandled3();
|
|
@@ -3511,11 +4097,11 @@ var createPairRoutes = (deps) => {
|
|
|
3511
4097
|
};
|
|
3512
4098
|
|
|
3513
4099
|
// src/api/routes/projects.routes.ts
|
|
3514
|
-
import { Hono as
|
|
4100
|
+
import { Hono as Hono11 } from "hono";
|
|
3515
4101
|
var ALREADY_HANDLED4 = 597;
|
|
3516
4102
|
var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
|
|
3517
4103
|
var createProjectRoutes = (deps) => {
|
|
3518
|
-
const app = new
|
|
4104
|
+
const app = new Hono11();
|
|
3519
4105
|
app.get("/", (c) => {
|
|
3520
4106
|
const url = new URL(c.req.url);
|
|
3521
4107
|
deps.handleListProjects(url, c.env.outgoing);
|
|
@@ -3529,12 +4115,147 @@ var createProjectRoutes = (deps) => {
|
|
|
3529
4115
|
return app;
|
|
3530
4116
|
};
|
|
3531
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
|
+
|
|
3532
4253
|
// src/api/routes/scanner.routes.ts
|
|
3533
|
-
import { Hono as
|
|
4254
|
+
import { Hono as Hono13 } from "hono";
|
|
3534
4255
|
var ALREADY_HANDLED5 = 597;
|
|
3535
4256
|
var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
|
|
3536
4257
|
var createScannerRoutes = (deps) => {
|
|
3537
|
-
const app = new
|
|
4258
|
+
const app = new Hono13();
|
|
3538
4259
|
app.get("/api/search", async (c) => {
|
|
3539
4260
|
const url = new URL(c.req.url);
|
|
3540
4261
|
await deps.handleSearch(url, c.env.outgoing);
|
|
@@ -3544,11 +4265,11 @@ var createScannerRoutes = (deps) => {
|
|
|
3544
4265
|
};
|
|
3545
4266
|
|
|
3546
4267
|
// src/api/routes/sessions.routes.ts
|
|
3547
|
-
import { Hono as
|
|
4268
|
+
import { Hono as Hono14 } from "hono";
|
|
3548
4269
|
var ALREADY_HANDLED6 = 597;
|
|
3549
4270
|
var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
|
|
3550
4271
|
var createSessionRoutes = (deps) => {
|
|
3551
|
-
const app = new
|
|
4272
|
+
const app = new Hono14();
|
|
3552
4273
|
app.get("/count", (c) => {
|
|
3553
4274
|
deps.handleSessionsCount(c.env.outgoing);
|
|
3554
4275
|
return alreadyHandled6();
|
|
@@ -3615,9 +4336,9 @@ var createSessionRoutes = (deps) => {
|
|
|
3615
4336
|
};
|
|
3616
4337
|
|
|
3617
4338
|
// src/api/routes/ws.routes.ts
|
|
3618
|
-
import { Hono as
|
|
4339
|
+
import { Hono as Hono15 } from "hono";
|
|
3619
4340
|
var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
3620
|
-
const app = new
|
|
4341
|
+
const app = new Hono15();
|
|
3621
4342
|
app.get(
|
|
3622
4343
|
"/ws",
|
|
3623
4344
|
upgradeWebSocket(() => {
|
|
@@ -3643,7 +4364,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
3643
4364
|
|
|
3644
4365
|
// src/api/app.ts
|
|
3645
4366
|
var createHonoApp = (deps, upgradeWebSocket) => {
|
|
3646
|
-
const app = new
|
|
4367
|
+
const app = new Hono16();
|
|
3647
4368
|
const httpLog = getLogger("http");
|
|
3648
4369
|
app.use("*", async (c, next) => {
|
|
3649
4370
|
const start = Date.now();
|
|
@@ -3668,7 +4389,10 @@ var createHonoApp = (deps, upgradeWebSocket) => {
|
|
|
3668
4389
|
app.route("/api/sessions", createSessionRoutes(deps));
|
|
3669
4390
|
app.route("/api/conversations", createConversationRoutes(deps));
|
|
3670
4391
|
app.route("/api/cache/alert", createCacheAlertRoutes(deps));
|
|
4392
|
+
app.route("/api/config", createConfigRoutes(deps));
|
|
3671
4393
|
app.route("/api/projects", createProjectRoutes(deps));
|
|
4394
|
+
app.route("/api/providers", createProviderRoutes());
|
|
4395
|
+
app.route("/api/devices", createDeviceRoutes(deps));
|
|
3672
4396
|
app.route("/api/pair", createPairRoutes(deps));
|
|
3673
4397
|
app.route("/api", createBrowseRoutes(deps));
|
|
3674
4398
|
app.route("/", createScannerRoutes(deps));
|
|
@@ -3878,11 +4602,11 @@ function joinStatCacheByNativePath(metas, canonicalStats) {
|
|
|
3878
4602
|
}
|
|
3879
4603
|
|
|
3880
4604
|
// src/utils/fileIdentity.ts
|
|
3881
|
-
import { createHash } from "crypto";
|
|
4605
|
+
import { createHash as createHash2 } from "crypto";
|
|
3882
4606
|
function fileIdentity(stat3, headBytes) {
|
|
3883
4607
|
if (stat3.ino && stat3.ino > 0) return `inode:${stat3.dev}:${stat3.ino}`;
|
|
3884
4608
|
const head = headBytes ?? Buffer.alloc(0);
|
|
3885
|
-
return `fp:${
|
|
4609
|
+
return `fp:${createHash2("sha1").update(head).digest("hex")}`;
|
|
3886
4610
|
}
|
|
3887
4611
|
function splitCompleteLines(buf, baseOffset) {
|
|
3888
4612
|
const spans = [];
|
|
@@ -5164,8 +5888,122 @@ var ConversationsRepository = class {
|
|
|
5164
5888
|
}
|
|
5165
5889
|
};
|
|
5166
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
|
+
|
|
5167
6005
|
// src/db/repositories/projects.repository.ts
|
|
5168
|
-
import { randomUUID as
|
|
6006
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
5169
6007
|
|
|
5170
6008
|
// src/utils/canonicalizeProjectPath.ts
|
|
5171
6009
|
function canonicalizeProjectPath(projectPath) {
|
|
@@ -5258,7 +6096,7 @@ var ProjectsRepository = class {
|
|
|
5258
6096
|
});
|
|
5259
6097
|
return rowToProject(this.getById.get(existing.id));
|
|
5260
6098
|
}
|
|
5261
|
-
const id =
|
|
6099
|
+
const id = randomUUID4();
|
|
5262
6100
|
this.insert.run({
|
|
5263
6101
|
id,
|
|
5264
6102
|
path,
|
|
@@ -5280,6 +6118,125 @@ function deriveNameFromPath(path) {
|
|
|
5280
6118
|
return parts.length > 0 ? parts[parts.length - 1] : null;
|
|
5281
6119
|
}
|
|
5282
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
|
+
|
|
5283
6240
|
// src/db/repositories/sessions.repository.ts
|
|
5284
6241
|
var SessionsRepository = class {
|
|
5285
6242
|
constructor(store) {
|
|
@@ -5348,8 +6305,20 @@ function handleListProjects(url, res) {
|
|
|
5348
6305
|
res.end(JSON.stringify({ projects: page, total }));
|
|
5349
6306
|
}
|
|
5350
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
|
+
|
|
5351
6320
|
// src/pair-store.ts
|
|
5352
|
-
import { randomBytes as
|
|
6321
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
5353
6322
|
var DEFAULT_TTL_SECONDS = 180;
|
|
5354
6323
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
5355
6324
|
var PairTokenStore = class {
|
|
@@ -5364,7 +6333,7 @@ var PairTokenStore = class {
|
|
|
5364
6333
|
}
|
|
5365
6334
|
}
|
|
5366
6335
|
mint() {
|
|
5367
|
-
const token = `pt_${
|
|
6336
|
+
const token = `pt_${randomBytes3(16).toString("hex")}`;
|
|
5368
6337
|
const expiresAt = Date.now() + this.ttlMs;
|
|
5369
6338
|
this.current = { token, expiresAt, used: false };
|
|
5370
6339
|
return {
|
|
@@ -5432,7 +6401,7 @@ function setCacheMetadata(repo, key, value) {
|
|
|
5432
6401
|
}
|
|
5433
6402
|
|
|
5434
6403
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
5435
|
-
import { createHash as
|
|
6404
|
+
import { createHash as createHash3 } from "crypto";
|
|
5436
6405
|
import { existsSync as existsSync8 } from "fs";
|
|
5437
6406
|
|
|
5438
6407
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -5497,7 +6466,7 @@ function envInt(name, fallback) {
|
|
|
5497
6466
|
}
|
|
5498
6467
|
function fingerprintOf(ids) {
|
|
5499
6468
|
const sorted = [...ids].sort();
|
|
5500
|
-
return `sha256:${
|
|
6469
|
+
return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
5501
6470
|
}
|
|
5502
6471
|
var CacheIntegrityMonitor = class {
|
|
5503
6472
|
constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
|
|
@@ -6311,6 +7280,112 @@ function conversationBusy(input) {
|
|
|
6311
7280
|
};
|
|
6312
7281
|
}
|
|
6313
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
|
+
|
|
6314
7389
|
// src/session-store.ts
|
|
6315
7390
|
var SessionStore = class {
|
|
6316
7391
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -6450,6 +7525,14 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6450
7525
|
conversationId: s.id,
|
|
6451
7526
|
provider: s.provider ?? CLAUDE_CODE_PROVIDER,
|
|
6452
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",
|
|
6453
7536
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
6454
7537
|
// `activity` is attached for managed sessions.
|
|
6455
7538
|
ownership: "managed",
|
|
@@ -6473,6 +7556,13 @@ function managedToResponse(s, ptyAttached) {
|
|
|
6473
7556
|
...s.lastMessageText != null && { lastMessageText: s.lastMessageText },
|
|
6474
7557
|
...s.lastMessageAt != null && { lastMessageAt: s.lastMessageAt.toISOString() },
|
|
6475
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() },
|
|
6476
7566
|
...s.filePath != null && { filePath: s.filePath },
|
|
6477
7567
|
...s.failureReason != null && { failureReason: s.failureReason },
|
|
6478
7568
|
...s.resumedFromConversationId != null && {
|
|
@@ -6494,6 +7584,12 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6494
7584
|
// Discovery just enumerated this PID, so it was alive moments ago. We never
|
|
6495
7585
|
// report "gone" here — a vanished process simply stops being listed.
|
|
6496
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",
|
|
6497
7593
|
projectPath: d.projectPath,
|
|
6498
7594
|
projectName: d.projectName,
|
|
6499
7595
|
branch: d.branch,
|
|
@@ -6508,7 +7604,7 @@ function discoveredToResponse(d, conversationId) {
|
|
|
6508
7604
|
}
|
|
6509
7605
|
|
|
6510
7606
|
// src/uploads.ts
|
|
6511
|
-
import { randomBytes as
|
|
7607
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
6512
7608
|
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
6513
7609
|
import heicConvert from "heic-convert";
|
|
6514
7610
|
import { join as join17 } from "path";
|
|
@@ -6542,7 +7638,7 @@ async function saveUploadFile(input) {
|
|
|
6542
7638
|
mimeType = "image/jpeg";
|
|
6543
7639
|
originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
|
|
6544
7640
|
}
|
|
6545
|
-
const id = `up_${
|
|
7641
|
+
const id = `up_${randomBytes4(8).toString("hex")}`;
|
|
6546
7642
|
const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
|
|
6547
7643
|
const dir = join17(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
|
|
6548
7644
|
await mkdir3(dir, { recursive: true });
|
|
@@ -6576,21 +7672,46 @@ function extractCodexText(content) {
|
|
|
6576
7672
|
return "";
|
|
6577
7673
|
}).filter(Boolean).join("").trim();
|
|
6578
7674
|
}
|
|
6579
|
-
|
|
7675
|
+
var KNOWN_CODEX_TYPES = /* @__PURE__ */ new Set(["response_item", "event_msg", "session_meta", "turn_context"]);
|
|
7676
|
+
function classifyCodexLine(line) {
|
|
6580
7677
|
let entry;
|
|
6581
7678
|
try {
|
|
6582
7679
|
entry = JSON.parse(line);
|
|
6583
7680
|
} catch {
|
|
6584
|
-
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` };
|
|
6585
7692
|
}
|
|
6586
|
-
if (entry.type !== "response_item") return null;
|
|
6587
7693
|
const payload = entry.payload;
|
|
6588
|
-
if (payload?.type !== "message")
|
|
7694
|
+
if (payload?.type !== "message") {
|
|
7695
|
+
return { kind: "ignored", reason: `response_item payload is ${String(payload?.type)}` };
|
|
7696
|
+
}
|
|
6589
7697
|
const role = payload.role;
|
|
6590
|
-
if (role !== "user" && role !== "assistant")
|
|
7698
|
+
if (role !== "user" && role !== "assistant") {
|
|
7699
|
+
return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
|
|
7700
|
+
}
|
|
6591
7701
|
const text = extractCodexText(payload.content);
|
|
6592
|
-
if (!text)
|
|
6593
|
-
|
|
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) {
|
|
6594
7715
|
const timestamp2 = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
6595
7716
|
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp2}-${hashPrefix(text)}`;
|
|
6596
7717
|
return JSON.stringify({
|
|
@@ -6646,13 +7767,13 @@ function hashPrefix(text) {
|
|
|
6646
7767
|
}
|
|
6647
7768
|
|
|
6648
7769
|
// src/utils/conversationEtag.ts
|
|
6649
|
-
import { createHash as
|
|
7770
|
+
import { createHash as createHash4 } from "crypto";
|
|
6650
7771
|
function computeConversationEtag({
|
|
6651
7772
|
filePath,
|
|
6652
7773
|
messageCount,
|
|
6653
7774
|
timestamp: timestamp2
|
|
6654
7775
|
}) {
|
|
6655
|
-
const digest =
|
|
7776
|
+
const digest = createHash4("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
|
|
6656
7777
|
return `"${digest}"`;
|
|
6657
7778
|
}
|
|
6658
7779
|
|
|
@@ -6795,6 +7916,8 @@ var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project b
|
|
|
6795
7916
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
6796
7917
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
6797
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;
|
|
6798
7921
|
var RESUME_DISCOVERY_TIMEOUT_MS = 750;
|
|
6799
7922
|
var DISCOVERY_TTL_MS = 15e3;
|
|
6800
7923
|
var ADOPT_KILL_TIMEOUT_MS = 5e3;
|
|
@@ -6916,6 +8039,16 @@ var StreamerServer = class {
|
|
|
6916
8039
|
defaultPermissionMode;
|
|
6917
8040
|
defaultModel;
|
|
6918
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;
|
|
6919
8052
|
// Map of sessionId → grace timer; fires to kill PTY after WS disconnect
|
|
6920
8053
|
ptyGraceTimers = /* @__PURE__ */ new Map();
|
|
6921
8054
|
// Consecutive grace-timer defers for a still-`running` session (see
|
|
@@ -6923,6 +8056,18 @@ var StreamerServer = class {
|
|
|
6923
8056
|
ptyGraceDeferCounts = /* @__PURE__ */ new Map();
|
|
6924
8057
|
// Map of sessionId → set of subscribed WS clients
|
|
6925
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;
|
|
6926
8071
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
6927
8072
|
clientIdToWs = /* @__PURE__ */ new Map();
|
|
6928
8073
|
// Reverse map for cleanup on close
|
|
@@ -6932,7 +8077,20 @@ var StreamerServer = class {
|
|
|
6932
8077
|
projectsRepo = null;
|
|
6933
8078
|
conversationsRepo = null;
|
|
6934
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();
|
|
6935
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;
|
|
6936
8094
|
discoveryCache = null;
|
|
6937
8095
|
cacheDir;
|
|
6938
8096
|
tailSize;
|
|
@@ -6971,6 +8129,9 @@ var StreamerServer = class {
|
|
|
6971
8129
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
6972
8130
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
6973
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();
|
|
6974
8135
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
|
|
6975
8136
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
6976
8137
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
@@ -7095,6 +8256,7 @@ var StreamerServer = class {
|
|
|
7095
8256
|
this.ptyManager = new LiveSessionManager({
|
|
7096
8257
|
logger: getLogger("pty"),
|
|
7097
8258
|
onOutput: (sessionId, data) => {
|
|
8259
|
+
this.lastAgentChunkAt.set(sessionId, Date.now());
|
|
7098
8260
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
7099
8261
|
},
|
|
7100
8262
|
onUserMessage: (sessionId, text, ts) => {
|
|
@@ -7123,6 +8285,17 @@ var StreamerServer = class {
|
|
|
7123
8285
|
completedAt: session.completedAt,
|
|
7124
8286
|
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
|
|
7125
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
|
+
);
|
|
7126
8299
|
if (session.status === "waiting_input" || session.status === "idle") {
|
|
7127
8300
|
const filePath = this.sessionFileMap.get(session.id);
|
|
7128
8301
|
if (filePath) {
|
|
@@ -7194,6 +8367,8 @@ var StreamerServer = class {
|
|
|
7194
8367
|
localNoAuth: this.localNoAuth,
|
|
7195
8368
|
logMenubarRequests: this.logMenubarRequests,
|
|
7196
8369
|
rotateApiKey: () => this.rotateApiKey(),
|
|
8370
|
+
claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
|
|
8371
|
+
setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
|
|
7197
8372
|
publicUrl: this.publicUrl,
|
|
7198
8373
|
browseRoot: this.browseRoot,
|
|
7199
8374
|
browserCors: this.browserCors,
|
|
@@ -7202,6 +8377,8 @@ var StreamerServer = class {
|
|
|
7202
8377
|
wsHub: this.wsHub,
|
|
7203
8378
|
cache: () => this.cache,
|
|
7204
8379
|
cacheMonitor: () => this.cacheMonitor,
|
|
8380
|
+
pushRepo: () => this.pushRepo,
|
|
8381
|
+
devicesRepo: () => this.devicesRepo,
|
|
7205
8382
|
projectsRepo: () => this.projectsRepo,
|
|
7206
8383
|
conversationsRepo: () => this.conversationsRepo,
|
|
7207
8384
|
sessionsRepo: () => this.sessionsRepo,
|
|
@@ -7235,7 +8412,9 @@ var StreamerServer = class {
|
|
|
7235
8412
|
handleMkdir: (req, res) => this.handleMkdir(req, res),
|
|
7236
8413
|
handleWsOpen: (ws) => {
|
|
7237
8414
|
this.wsHub.addClient(ws);
|
|
7238
|
-
const sessions = this.
|
|
8415
|
+
const sessions = this.withReconciledLifecycle(
|
|
8416
|
+
this.sessionStore.list(this.ptyAttachedIds())
|
|
8417
|
+
);
|
|
7239
8418
|
ws.send(JSON.stringify({ type: "session_list", sessions }));
|
|
7240
8419
|
if (!this.currentWarmupState()) {
|
|
7241
8420
|
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
@@ -7311,11 +8490,8 @@ var StreamerServer = class {
|
|
|
7311
8490
|
this.clientIdToWs.delete(clientId);
|
|
7312
8491
|
this.wsToClientId.delete(ws);
|
|
7313
8492
|
}
|
|
7314
|
-
for (const
|
|
8493
|
+
for (const subscribers of this.sessionSubscribers.values()) {
|
|
7315
8494
|
subscribers.delete(ws);
|
|
7316
|
-
if (subscribers.size === 0 && this.ptyGracePeriodMs > 0) {
|
|
7317
|
-
this.startGraceTimer(sessionId, this.ptyGracePeriodMs);
|
|
7318
|
-
}
|
|
7319
8495
|
}
|
|
7320
8496
|
},
|
|
7321
8497
|
agentClient,
|
|
@@ -7378,7 +8554,7 @@ var StreamerServer = class {
|
|
|
7378
8554
|
const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
|
|
7379
8555
|
const payload = {
|
|
7380
8556
|
type: "session_list",
|
|
7381
|
-
sessions: this.sessionStore.list(this.ptyAttachedIds())
|
|
8557
|
+
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
7382
8558
|
};
|
|
7383
8559
|
if (ws) {
|
|
7384
8560
|
this.wsHub.unicast(ws, payload);
|
|
@@ -7386,6 +8562,29 @@ var StreamerServer = class {
|
|
|
7386
8562
|
this.wsHub.broadcast(payload);
|
|
7387
8563
|
}
|
|
7388
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
|
+
}
|
|
7389
8588
|
addSessionSubscriber(sessionId, ws) {
|
|
7390
8589
|
let subs = this.sessionSubscribers.get(sessionId);
|
|
7391
8590
|
if (!subs) {
|
|
@@ -7400,6 +8599,181 @@ var StreamerServer = class {
|
|
|
7400
8599
|
}
|
|
7401
8600
|
this.ptyGraceDeferCounts.delete(sessionId);
|
|
7402
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
|
+
}
|
|
7403
8777
|
startGraceTimer(sessionId, delayMs) {
|
|
7404
8778
|
const existing = this.ptyGraceTimers.get(sessionId);
|
|
7405
8779
|
if (existing) clearTimeout(existing);
|
|
@@ -7494,6 +8868,8 @@ var StreamerServer = class {
|
|
|
7494
8868
|
this.log.info("Database migrations applied", { event: "db.migrations_applied" });
|
|
7495
8869
|
}
|
|
7496
8870
|
await this.bindWithRetry(port);
|
|
8871
|
+
this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
|
|
8872
|
+
this.idleReaperTimer.unref?.();
|
|
7497
8873
|
const warmUp = new Promise((resolveWarm) => {
|
|
7498
8874
|
{
|
|
7499
8875
|
this.log.info(`Streamer server listening on port ${port}`, {
|
|
@@ -7527,7 +8903,11 @@ var StreamerServer = class {
|
|
|
7527
8903
|
this.projectsRepo = new ProjectsRepository(db);
|
|
7528
8904
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
7529
8905
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
8906
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(db);
|
|
8907
|
+
void this.reconcilePreviousSessions();
|
|
7530
8908
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
8909
|
+
this.pushRepo = new PushRepository(db);
|
|
8910
|
+
this.devicesRepo = new DevicesRepository(db);
|
|
7531
8911
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
7532
8912
|
this.cache,
|
|
7533
8913
|
this.wsHub,
|
|
@@ -7745,6 +9125,12 @@ var StreamerServer = class {
|
|
|
7745
9125
|
async close() {
|
|
7746
9126
|
for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
|
|
7747
9127
|
this.ptyGraceTimers.clear();
|
|
9128
|
+
if (this.idleReaperTimer) {
|
|
9129
|
+
clearInterval(this.idleReaperTimer);
|
|
9130
|
+
this.idleReaperTimer = null;
|
|
9131
|
+
}
|
|
9132
|
+
this.lastAgentChunkAt.clear();
|
|
9133
|
+
this.recordShutdownState();
|
|
7748
9134
|
this.markScannerStaleDebounced.cancel();
|
|
7749
9135
|
await Promise.all([...this.inFlightCacheWrites]);
|
|
7750
9136
|
await Promise.all([...this.allScanners].map((s) => s.close()));
|
|
@@ -7834,12 +9220,28 @@ var StreamerServer = class {
|
|
|
7834
9220
|
ip,
|
|
7835
9221
|
ts
|
|
7836
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
|
+
}
|
|
7837
9234
|
json(res, 200, {
|
|
7838
9235
|
ciphertext: sealed.ciphertext,
|
|
7839
9236
|
nonce: sealed.nonce,
|
|
7840
9237
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
7841
9238
|
publicUrl: this.publicUrl,
|
|
7842
|
-
machineName: hostname2()
|
|
9239
|
+
machineName: hostname2(),
|
|
9240
|
+
...device && {
|
|
9241
|
+
deviceId: device.deviceId,
|
|
9242
|
+
deviceToken: device.deviceToken,
|
|
9243
|
+
capabilities: device.capabilities
|
|
9244
|
+
}
|
|
7843
9245
|
});
|
|
7844
9246
|
}
|
|
7845
9247
|
rotateApiKey() {
|
|
@@ -7856,6 +9258,48 @@ var StreamerServer = class {
|
|
|
7856
9258
|
});
|
|
7857
9259
|
return { newKey, persisted };
|
|
7858
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
|
+
}
|
|
7859
9303
|
checkRateLimit(map, key, limit, windowMs) {
|
|
7860
9304
|
const now = Date.now();
|
|
7861
9305
|
const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
@@ -8873,7 +10317,13 @@ var StreamerServer = class {
|
|
|
8873
10317
|
}
|
|
8874
10318
|
const hasPaginationParams = url.searchParams.has("limit") || url.searchParams.has("cursor") || url.searchParams.has("sortBy") || url.searchParams.has("order") || url.searchParams.has("status");
|
|
8875
10319
|
if (!hasPaginationParams) {
|
|
8876
|
-
json(
|
|
10320
|
+
json(
|
|
10321
|
+
res,
|
|
10322
|
+
200,
|
|
10323
|
+
this.withExternalActivity(
|
|
10324
|
+
this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
10325
|
+
)
|
|
10326
|
+
);
|
|
8877
10327
|
return;
|
|
8878
10328
|
}
|
|
8879
10329
|
const parsed = parseSessionListQuery(url);
|
|
@@ -8883,7 +10333,7 @@ var StreamerServer = class {
|
|
|
8883
10333
|
}
|
|
8884
10334
|
try {
|
|
8885
10335
|
const page = this.sessionStore.paginate(this.ptyAttachedIds(), parsed.query);
|
|
8886
|
-
page.sessions = this.withExternalActivity(page.sessions);
|
|
10336
|
+
page.sessions = this.withExternalActivity(this.withReconciledLifecycle(page.sessions));
|
|
8887
10337
|
json(res, 200, page);
|
|
8888
10338
|
} catch (err) {
|
|
8889
10339
|
if (err instanceof Error && err.message === "INVALID_CURSOR") {
|
|
@@ -8900,6 +10350,9 @@ var StreamerServer = class {
|
|
|
8900
10350
|
if (!existsSync10(session.projectPath)) {
|
|
8901
10351
|
session.failureReason = `Project directory not found: ${session.projectPath}`;
|
|
8902
10352
|
}
|
|
10353
|
+
const reconciled = this.withReconciledLifecycle([session])[0];
|
|
10354
|
+
session.lifecycle = reconciled.lifecycle;
|
|
10355
|
+
session.lifecycleSource = reconciled.lifecycleSource;
|
|
8903
10356
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
8904
10357
|
try {
|
|
8905
10358
|
const lines = await this.ptyManager.getOutputLines(sessionId, 10);
|
|
@@ -8997,10 +10450,13 @@ var StreamerServer = class {
|
|
|
8997
10450
|
projectName: body.projectName,
|
|
8998
10451
|
branch: body.branch,
|
|
8999
10452
|
permissionMode: this.defaultPermissionMode,
|
|
10453
|
+
claudeFlags: this.claudeFlags,
|
|
10454
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9000
10455
|
model: this.defaultModel,
|
|
9001
10456
|
effort: this.defaultEffort
|
|
9002
10457
|
});
|
|
9003
10458
|
this.sessionStore.addManaged(session);
|
|
10459
|
+
this.recordSessionSpawn(session);
|
|
9004
10460
|
void this.watchConversationFile(sessionId);
|
|
9005
10461
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
9006
10462
|
this.broadcastOrUnicastSessionList(req);
|
|
@@ -9073,6 +10529,24 @@ var StreamerServer = class {
|
|
|
9073
10529
|
}
|
|
9074
10530
|
const body = await readBody(req);
|
|
9075
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
|
+
}
|
|
9076
10550
|
if (typeof keys === "string") {
|
|
9077
10551
|
try {
|
|
9078
10552
|
this.ptyManager.sendKeys(sessionId, keys);
|
|
@@ -9080,7 +10554,9 @@ var StreamerServer = class {
|
|
|
9080
10554
|
if (updated) {
|
|
9081
10555
|
this.wsHub.broadcast({ type: "session_update", session: updated });
|
|
9082
10556
|
}
|
|
9083
|
-
|
|
10557
|
+
const result = { status: 200, body: { ok: true } };
|
|
10558
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10559
|
+
json(res, result.status, result.body);
|
|
9084
10560
|
} catch (err) {
|
|
9085
10561
|
const message = err instanceof Error ? err.message : "Failed to send keys";
|
|
9086
10562
|
json(res, 400, { error: message });
|
|
@@ -9118,7 +10594,9 @@ var StreamerServer = class {
|
|
|
9118
10594
|
});
|
|
9119
10595
|
});
|
|
9120
10596
|
}
|
|
9121
|
-
|
|
10597
|
+
const result = { status: 200, body: { ok: true } };
|
|
10598
|
+
if (idempotencyKey) this.idempotency.set(sessionId, idempotencyKey, result);
|
|
10599
|
+
json(res, result.status, result.body);
|
|
9122
10600
|
} catch (err) {
|
|
9123
10601
|
const message = err instanceof Error ? err.message : "Failed to send input";
|
|
9124
10602
|
json(res, 400, { error: message });
|
|
@@ -9423,10 +10901,13 @@ var StreamerServer = class {
|
|
|
9423
10901
|
projectName,
|
|
9424
10902
|
branch,
|
|
9425
10903
|
permissionMode: this.defaultPermissionMode,
|
|
10904
|
+
claudeFlags: this.claudeFlags,
|
|
10905
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9426
10906
|
model: this.defaultModel,
|
|
9427
10907
|
effort: this.defaultEffort
|
|
9428
10908
|
});
|
|
9429
10909
|
this.sessionStore.addManaged(session);
|
|
10910
|
+
this.recordSessionSpawn(session);
|
|
9430
10911
|
void this.watchConversationFile(session.id);
|
|
9431
10912
|
this.wsHub.broadcast({
|
|
9432
10913
|
type: "session_list",
|
|
@@ -9496,10 +10977,13 @@ var StreamerServer = class {
|
|
|
9496
10977
|
projectName: body.projectName,
|
|
9497
10978
|
systemPrompt: systemPromptParts.join("\n"),
|
|
9498
10979
|
permissionMode: this.defaultPermissionMode,
|
|
10980
|
+
claudeFlags: this.claudeFlags,
|
|
10981
|
+
claudeExtraArgs: this.claudeExtraArgs,
|
|
9499
10982
|
model: this.defaultModel,
|
|
9500
10983
|
effort: this.defaultEffort
|
|
9501
10984
|
});
|
|
9502
10985
|
this.sessionStore.addManaged(session);
|
|
10986
|
+
this.recordSessionSpawn(session);
|
|
9503
10987
|
const readyOrFailed = new Promise((resolve2) => {
|
|
9504
10988
|
const handler = (status) => {
|
|
9505
10989
|
if (status === "waiting_input" || status === "idle") {
|
|
@@ -10020,6 +11504,7 @@ export {
|
|
|
10020
11504
|
SessionStore,
|
|
10021
11505
|
StreamerServer,
|
|
10022
11506
|
WSHub,
|
|
11507
|
+
confidenceForSource,
|
|
10023
11508
|
createAgentClient,
|
|
10024
11509
|
createConversationWriter,
|
|
10025
11510
|
createPool,
|