sessix-server 0.4.9 → 0.5.3
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/index.js +1498 -1110
- package/dist/server.js +1498 -1110
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -307,16 +307,18 @@ function t(key, params) {
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
// src/server.ts
|
|
310
|
-
var
|
|
310
|
+
var import_uuid8 = require("uuid");
|
|
311
311
|
var import_promises7 = require("fs/promises");
|
|
312
312
|
var import_node_os9 = require("os");
|
|
313
313
|
var import_node_path9 = require("path");
|
|
314
314
|
var import_node_child_process12 = require("child_process");
|
|
315
315
|
var import_node_util3 = require("util");
|
|
316
|
+
var import_node_v8 = require("v8");
|
|
317
|
+
var import_node_vm = require("vm");
|
|
316
318
|
|
|
317
319
|
// src/providers/ProcessProvider.ts
|
|
318
320
|
var import_child_process = require("child_process");
|
|
319
|
-
var
|
|
321
|
+
var import_readline2 = require("readline");
|
|
320
322
|
var import_events = require("events");
|
|
321
323
|
var import_node_os2 = require("os");
|
|
322
324
|
var import_uuid = require("uuid");
|
|
@@ -367,12 +369,60 @@ function isNormalExit(code, signal) {
|
|
|
367
369
|
}
|
|
368
370
|
|
|
369
371
|
// src/utils/claudePath.ts
|
|
372
|
+
function resolveStable(candidate) {
|
|
373
|
+
if (!candidate) return null;
|
|
374
|
+
try {
|
|
375
|
+
const real = (0, import_node_fs.realpathSync)(candidate.trim());
|
|
376
|
+
(0, import_node_fs.accessSync)(real, import_node_fs.constants.X_OK);
|
|
377
|
+
return real;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function resolveViaLoginShell() {
|
|
383
|
+
if (isWindows) return null;
|
|
384
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
385
|
+
try {
|
|
386
|
+
const out = (0, import_node_child_process2.execSync)(`${shell} -ilc 'command -v claude' 2>/dev/null`, {
|
|
387
|
+
encoding: "utf-8",
|
|
388
|
+
timeout: 8e3
|
|
389
|
+
}).trim().split("\n").filter(Boolean).pop();
|
|
390
|
+
return resolveStable(out);
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function resolveViaFnm() {
|
|
396
|
+
if (isWindows) return null;
|
|
397
|
+
const base = (0, import_node_path.join)((0, import_node_os.homedir)(), ".fnm", "node-versions");
|
|
398
|
+
try {
|
|
399
|
+
const versions = (0, import_node_fs.readdirSync)(base).filter((v) => /^v?\d+\./.test(v)).sort(
|
|
400
|
+
(a, b) => b.localeCompare(a, void 0, { numeric: true, sensitivity: "base" })
|
|
401
|
+
);
|
|
402
|
+
for (const v of versions) {
|
|
403
|
+
const p = resolveStable((0, import_node_path.join)(base, v, "installation", "bin", "claude"));
|
|
404
|
+
if (p) return p;
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
var cached = null;
|
|
370
411
|
function findClaudePath() {
|
|
412
|
+
if (cached) return cached;
|
|
413
|
+
const override = resolveStable(process.env.SESSIX_CLAUDE_PATH);
|
|
414
|
+
if (override) return cached = log(override, "env:SESSIX_CLAUDE_PATH");
|
|
371
415
|
try {
|
|
372
416
|
const cmd = isWindows ? "where claude" : "which claude";
|
|
373
|
-
|
|
417
|
+
const which = (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
|
|
418
|
+
const stable = resolveStable(which);
|
|
419
|
+
if (stable) return cached = log(stable, "which");
|
|
374
420
|
} catch {
|
|
375
421
|
}
|
|
422
|
+
const fnm = resolveViaFnm();
|
|
423
|
+
if (fnm) return cached = log(fnm, "fnm-scan");
|
|
424
|
+
const viaShell = resolveViaLoginShell();
|
|
425
|
+
if (viaShell) return cached = log(viaShell, "login-shell");
|
|
376
426
|
const candidates = isWindows ? [
|
|
377
427
|
(0, import_node_path.join)(process.env.LOCALAPPDATA ?? "", "Programs", "claude", "claude.exe"),
|
|
378
428
|
(0, import_node_path.join)((0, import_node_os.homedir)(), "AppData", "Local", "Programs", "claude", "claude.exe"),
|
|
@@ -383,631 +433,936 @@ function findClaudePath() {
|
|
|
383
433
|
"/opt/homebrew/bin/claude"
|
|
384
434
|
];
|
|
385
435
|
for (const candidate of candidates) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return candidate;
|
|
389
|
-
} catch {
|
|
390
|
-
}
|
|
436
|
+
const stable = resolveStable(candidate);
|
|
437
|
+
if (stable) return cached = log(stable, "candidate");
|
|
391
438
|
}
|
|
392
|
-
|
|
439
|
+
console.warn(
|
|
440
|
+
"[claudePath] \u672A\u80FD\u5B9A\u4F4D claude\uFF0C\u515C\u5E95\u4F7F\u7528\u88F8 'claude'\uFF08PATH \u4E0D\u542B claude \u65F6\u4F1A\u5931\u8D25\uFF09"
|
|
441
|
+
);
|
|
442
|
+
return cached = "claude";
|
|
443
|
+
}
|
|
444
|
+
function log(path2, via) {
|
|
445
|
+
console.log(`[claudePath] \u89E3\u6790\u5230 claude: ${path2} (via ${via})`);
|
|
446
|
+
return path2;
|
|
393
447
|
}
|
|
394
448
|
|
|
395
|
-
// src/
|
|
396
|
-
var
|
|
397
|
-
var
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const sessionId = existingSessionId ?? (0, import_uuid.v4)();
|
|
413
|
-
if (this.activeSessions.has(sessionId)) {
|
|
414
|
-
await this.killSession(sessionId);
|
|
415
|
-
}
|
|
416
|
-
const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
|
|
417
|
-
const session = {
|
|
418
|
-
id: sessionId,
|
|
419
|
-
projectId,
|
|
420
|
-
projectPath,
|
|
421
|
-
status: "running",
|
|
422
|
-
createdAt: Date.now(),
|
|
423
|
-
lastActiveAt: Date.now(),
|
|
424
|
-
summary: message.slice(0, 80)
|
|
425
|
-
};
|
|
426
|
-
const resume = opts.resume ?? !!existingSessionId;
|
|
427
|
-
const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, model, permissionMode, effort, fallbackModel, maxBudgetUsd);
|
|
428
|
-
this.writeUserMessage(proc, message, sessionId, images);
|
|
429
|
-
session.pid = proc.pid;
|
|
430
|
-
this.activeSessions.set(sessionId, { session, process: proc, model, permissionMode, effort, fallbackModel, maxBudgetUsd });
|
|
431
|
-
proc.on("error", (err) => {
|
|
432
|
-
console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
|
|
433
|
-
this.activeSessions.delete(sessionId);
|
|
434
|
-
const syntheticResult = {
|
|
435
|
-
type: "result",
|
|
436
|
-
subtype: "error",
|
|
437
|
-
result: `Process spawn failed: ${err.message}`,
|
|
438
|
-
session_id: sessionId,
|
|
439
|
-
duration_ms: 0,
|
|
440
|
-
is_error: true,
|
|
441
|
-
num_turns: 0
|
|
442
|
-
};
|
|
443
|
-
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
449
|
+
// src/session/ProjectReader.ts
|
|
450
|
+
var import_promises = require("fs/promises");
|
|
451
|
+
var import_readline = require("readline");
|
|
452
|
+
var import_path = require("path");
|
|
453
|
+
var import_os = require("os");
|
|
454
|
+
var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
|
|
455
|
+
function getSessionFilePath(projectPath, sessionId) {
|
|
456
|
+
return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
|
|
457
|
+
}
|
|
458
|
+
async function getSessionModel(projectPath, sessionId) {
|
|
459
|
+
const filePath = getSessionFilePath(projectPath, sessionId);
|
|
460
|
+
let fileHandle;
|
|
461
|
+
try {
|
|
462
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
463
|
+
const rl = (0, import_readline.createInterface)({
|
|
464
|
+
input: fileHandle.createReadStream({ encoding: "utf-8" }),
|
|
465
|
+
crlfDelay: Infinity
|
|
444
466
|
});
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
return session;
|
|
449
|
-
}
|
|
450
|
-
/**
|
|
451
|
-
* 终止指定会话
|
|
452
|
-
*
|
|
453
|
-
* kill 进程并从活跃映射中移除。
|
|
454
|
-
*/
|
|
455
|
-
async killSession(sessionId) {
|
|
456
|
-
const entry = this.activeSessions.get(sessionId);
|
|
457
|
-
if (!entry) {
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
467
|
+
let lastModel;
|
|
468
|
+
for await (const line of rl) {
|
|
469
|
+
if (!line.trim()) continue;
|
|
461
470
|
try {
|
|
462
|
-
|
|
471
|
+
const obj = JSON.parse(line);
|
|
472
|
+
if (obj.type !== "assistant" || !obj.message) continue;
|
|
473
|
+
const model = obj.message.model;
|
|
474
|
+
if (typeof model === "string" && model && model !== "unknown") {
|
|
475
|
+
lastModel = model;
|
|
476
|
+
}
|
|
463
477
|
} catch {
|
|
464
478
|
}
|
|
465
|
-
await killProcessCrossPlatform(entry.process);
|
|
466
479
|
}
|
|
467
|
-
|
|
468
|
-
|
|
480
|
+
return lastModel;
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if (err.code === "ENOENT") return void 0;
|
|
483
|
+
throw err;
|
|
484
|
+
} finally {
|
|
485
|
+
await fileHandle?.close();
|
|
469
486
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
async sendMessage(sessionId, message, permissionMode, images) {
|
|
477
|
-
const entry = this.activeSessions.get(sessionId);
|
|
478
|
-
if (!entry) {
|
|
479
|
-
throw new Error(`Session ${sessionId} not found or already ended`);
|
|
487
|
+
}
|
|
488
|
+
async function getProjects() {
|
|
489
|
+
try {
|
|
490
|
+
const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
|
|
491
|
+
if (!dirExists) {
|
|
492
|
+
return { ok: true, value: [] };
|
|
480
493
|
}
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
entry.
|
|
485
|
-
|
|
486
|
-
|
|
494
|
+
const entries = await (0, import_promises.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
|
|
495
|
+
const projects = [];
|
|
496
|
+
for (const entry of entries) {
|
|
497
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const encodedPath = entry.name;
|
|
501
|
+
const decodedPath = decodeDirName(encodedPath);
|
|
502
|
+
const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
|
|
503
|
+
const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
504
|
+
const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
|
|
505
|
+
projects.push({
|
|
506
|
+
id: encodedPath,
|
|
507
|
+
path: decodedPath,
|
|
508
|
+
name,
|
|
509
|
+
sessionCount,
|
|
510
|
+
lastActiveAt: latestMtime
|
|
511
|
+
});
|
|
487
512
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
513
|
+
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
514
|
+
return { ok: true, value: projects };
|
|
515
|
+
} catch (err) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function getHistoricalSessions(projectPath) {
|
|
523
|
+
try {
|
|
524
|
+
const encodedPath = encodeDirName(projectPath);
|
|
525
|
+
const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
526
|
+
const dirExists = await directoryExists(projectDir);
|
|
527
|
+
if (!dirExists) {
|
|
528
|
+
return { ok: true, value: [] };
|
|
529
|
+
}
|
|
530
|
+
const entries = await (0, import_promises.readdir)(projectDir, { withFileTypes: true });
|
|
531
|
+
const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
532
|
+
const mtimeMap = /* @__PURE__ */ new Map();
|
|
533
|
+
await Promise.all(
|
|
534
|
+
jsonlFiles.map(async (entry) => {
|
|
535
|
+
const sessionId = entry.name.slice(0, -6);
|
|
536
|
+
const filePath = (0, import_path.join)(projectDir, entry.name);
|
|
491
537
|
try {
|
|
492
|
-
|
|
538
|
+
const contentTs = await extractLastTimestamp(filePath);
|
|
539
|
+
if (contentTs) {
|
|
540
|
+
mtimeMap.set(sessionId, contentTs);
|
|
541
|
+
} else {
|
|
542
|
+
const fileStat = await (0, import_promises.stat)(filePath);
|
|
543
|
+
mtimeMap.set(sessionId, fileStat.mtimeMs);
|
|
544
|
+
}
|
|
493
545
|
} catch {
|
|
546
|
+
mtimeMap.set(sessionId, 0);
|
|
494
547
|
}
|
|
495
|
-
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
const uuidDirs = entries.filter(
|
|
551
|
+
(e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
|
|
552
|
+
);
|
|
553
|
+
for (const entry of uuidDirs) {
|
|
554
|
+
try {
|
|
555
|
+
const fileStat = await (0, import_promises.stat)((0, import_path.join)(projectDir, entry.name));
|
|
556
|
+
mtimeMap.set(entry.name, fileStat.mtimeMs);
|
|
557
|
+
} catch {
|
|
558
|
+
mtimeMap.set(entry.name, 0);
|
|
496
559
|
}
|
|
497
|
-
} else {
|
|
498
|
-
console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
|
|
499
560
|
}
|
|
500
|
-
const
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
561
|
+
const indexPath = (0, import_path.join)(projectDir, "sessions-index.json");
|
|
562
|
+
const sessionMap = /* @__PURE__ */ new Map();
|
|
563
|
+
try {
|
|
564
|
+
const indexContent = await (0, import_promises.readFile)(indexPath, "utf-8");
|
|
565
|
+
const indexData = JSON.parse(indexContent);
|
|
566
|
+
if (indexData.version === 1 && Array.isArray(indexData.entries)) {
|
|
567
|
+
for (const entry of indexData.entries) {
|
|
568
|
+
const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
|
|
569
|
+
sessionMap.set(entry.sessionId, {
|
|
570
|
+
sessionId: entry.sessionId,
|
|
571
|
+
lastModified: mtime,
|
|
572
|
+
summary: entry.summary,
|
|
573
|
+
firstPrompt: entry.firstPrompt,
|
|
574
|
+
messageCount: entry.messageCount
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
await Promise.all(
|
|
578
|
+
Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
|
|
579
|
+
const filePath = (0, import_path.join)(projectDir, `${s.sessionId}.jsonl`);
|
|
580
|
+
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
581
|
+
if (firstPrompt) s.firstPrompt = firstPrompt;
|
|
582
|
+
})
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
} catch {
|
|
586
|
+
}
|
|
587
|
+
const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
|
|
588
|
+
for (const [sessionId, mtime] of mtimeMap) {
|
|
589
|
+
if (!sessionMap.has(sessionId)) {
|
|
590
|
+
if (uuidDirSet.has(sessionId)) {
|
|
591
|
+
sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
|
|
592
|
+
} else {
|
|
593
|
+
const filePath = (0, import_path.join)(projectDir, `${sessionId}.jsonl`);
|
|
594
|
+
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
595
|
+
sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const sessions = Array.from(sessionMap.values()).filter((s) => {
|
|
600
|
+
if (s.messageCount === 0) return false;
|
|
601
|
+
if (s.messageCount === -1) return true;
|
|
602
|
+
if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
|
|
603
|
+
return true;
|
|
523
604
|
});
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
*
|
|
531
|
-
* @returns 取消订阅函数
|
|
532
|
-
*/
|
|
533
|
-
onEvent(sessionId, callback) {
|
|
534
|
-
const eventName = this.getEventName(sessionId);
|
|
535
|
-
this.emitter.on(eventName, callback);
|
|
536
|
-
return () => {
|
|
537
|
-
this.emitter.off(eventName, callback);
|
|
605
|
+
sessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
606
|
+
return { ok: true, value: sessions };
|
|
607
|
+
} catch (err) {
|
|
608
|
+
return {
|
|
609
|
+
ok: false,
|
|
610
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
538
611
|
};
|
|
539
612
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
|
|
560
|
-
if (entry.session.status !== "idle") continue;
|
|
561
|
-
if (now - entry.session.lastActiveAt < maxIdleMs) continue;
|
|
562
|
-
const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
|
|
563
|
-
console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
|
|
613
|
+
}
|
|
614
|
+
async function getSessionHistory(projectPath, sessionId) {
|
|
615
|
+
let fileHandle;
|
|
616
|
+
try {
|
|
617
|
+
const encodedPath = encodeDirName(projectPath);
|
|
618
|
+
const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
|
|
619
|
+
try {
|
|
620
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
621
|
+
} catch (err) {
|
|
622
|
+
if (err.code === "ENOENT") return { ok: true, value: [] };
|
|
623
|
+
throw err;
|
|
624
|
+
}
|
|
625
|
+
const rl = (0, import_readline.createInterface)({
|
|
626
|
+
input: fileHandle.createReadStream({ encoding: "utf-8" }),
|
|
627
|
+
crlfDelay: Infinity
|
|
628
|
+
});
|
|
629
|
+
const events = [];
|
|
630
|
+
for await (const line of rl) {
|
|
631
|
+
if (!line.trim()) continue;
|
|
564
632
|
try {
|
|
565
|
-
|
|
633
|
+
const obj = JSON.parse(line);
|
|
634
|
+
const type = obj.type;
|
|
635
|
+
if (type === "user" && obj.message) {
|
|
636
|
+
const msgContent = obj.message.content;
|
|
637
|
+
if (typeof msgContent === "string") {
|
|
638
|
+
if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
|
|
639
|
+
} else if (Array.isArray(msgContent)) {
|
|
640
|
+
const hasText = msgContent.some(
|
|
641
|
+
(b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
|
|
642
|
+
);
|
|
643
|
+
if (!hasText) continue;
|
|
644
|
+
}
|
|
645
|
+
const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
|
|
646
|
+
if (normalizedContent.length === 0) continue;
|
|
647
|
+
events.push({
|
|
648
|
+
type: "user",
|
|
649
|
+
message: {
|
|
650
|
+
...obj.message,
|
|
651
|
+
content: normalizedContent
|
|
652
|
+
},
|
|
653
|
+
session_id: sessionId
|
|
654
|
+
});
|
|
655
|
+
} else if (type === "assistant" && obj.message) {
|
|
656
|
+
const content = (obj.message.content ?? []).filter(
|
|
657
|
+
(b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
|
|
658
|
+
);
|
|
659
|
+
if (content.length === 0) continue;
|
|
660
|
+
events.push({
|
|
661
|
+
type: "assistant",
|
|
662
|
+
message: {
|
|
663
|
+
id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
|
|
664
|
+
model: obj.message.model ?? "unknown",
|
|
665
|
+
role: "assistant",
|
|
666
|
+
content,
|
|
667
|
+
stop_reason: obj.message.stop_reason,
|
|
668
|
+
usage: obj.message.usage
|
|
669
|
+
},
|
|
670
|
+
session_id: sessionId
|
|
671
|
+
});
|
|
672
|
+
}
|
|
566
673
|
} catch {
|
|
567
674
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
675
|
+
}
|
|
676
|
+
if (events.length > 0) {
|
|
677
|
+
let totalInputTokens = 0;
|
|
678
|
+
let totalOutputTokens = 0;
|
|
679
|
+
for (const ev of events) {
|
|
680
|
+
if (ev.type === "assistant" && ev.message.usage) {
|
|
681
|
+
totalInputTokens += ev.message.usage.input_tokens ?? 0;
|
|
682
|
+
totalOutputTokens += ev.message.usage.output_tokens ?? 0;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (totalInputTokens > 0 || totalOutputTokens > 0) {
|
|
686
|
+
events.push({
|
|
687
|
+
type: "result",
|
|
688
|
+
subtype: "success",
|
|
689
|
+
is_error: false,
|
|
690
|
+
duration_ms: 0,
|
|
691
|
+
num_turns: events.filter((e) => e.type === "user").length,
|
|
692
|
+
result: "",
|
|
693
|
+
session_id: sessionId,
|
|
694
|
+
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
|
|
695
|
+
});
|
|
573
696
|
}
|
|
574
|
-
swept.push(sessionId);
|
|
575
697
|
}
|
|
576
|
-
return
|
|
698
|
+
return { ok: true, value: events };
|
|
699
|
+
} catch (err) {
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
703
|
+
};
|
|
704
|
+
} finally {
|
|
705
|
+
await fileHandle?.close();
|
|
577
706
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
const aliveEntries = Array.from(this.activeSessions.entries()).filter(
|
|
591
|
-
([, e]) => e.process.exitCode === null && e.process.signalCode === null
|
|
592
|
-
);
|
|
593
|
-
if (aliveEntries.length <= maxAlive) return swept;
|
|
594
|
-
const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
|
|
595
|
-
let aliveCount = aliveEntries.length;
|
|
596
|
-
for (const [sessionId, entry] of idleSorted) {
|
|
597
|
-
if (aliveCount <= maxAlive) break;
|
|
598
|
-
const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
|
|
599
|
-
console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
|
|
707
|
+
}
|
|
708
|
+
async function extractLastTimestamp(filePath) {
|
|
709
|
+
let fileHandle;
|
|
710
|
+
try {
|
|
711
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
712
|
+
const fileStat = await fileHandle.stat();
|
|
713
|
+
const readSize = Math.min(fileStat.size, 8192);
|
|
714
|
+
const buffer = Buffer.alloc(readSize);
|
|
715
|
+
await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
|
|
716
|
+
const tail = buffer.toString("utf-8");
|
|
717
|
+
const lines = tail.split("\n").filter((l) => l.trim());
|
|
718
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
600
719
|
try {
|
|
601
|
-
|
|
720
|
+
const obj = JSON.parse(lines[i]);
|
|
721
|
+
if (obj.timestamp) {
|
|
722
|
+
const ts = new Date(obj.timestamp).getTime();
|
|
723
|
+
if (!isNaN(ts)) return ts;
|
|
724
|
+
}
|
|
602
725
|
} catch {
|
|
603
726
|
}
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
} finally {
|
|
730
|
+
await fileHandle?.close();
|
|
731
|
+
}
|
|
732
|
+
return void 0;
|
|
733
|
+
}
|
|
734
|
+
async function extractFirstPrompt(filePath) {
|
|
735
|
+
let fileHandle;
|
|
736
|
+
try {
|
|
737
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
738
|
+
const rl = (0, import_readline.createInterface)({
|
|
739
|
+
input: fileHandle.createReadStream({ encoding: "utf-8" }),
|
|
740
|
+
crlfDelay: Infinity
|
|
741
|
+
});
|
|
742
|
+
let lineCount = 0;
|
|
743
|
+
for await (const line of rl) {
|
|
744
|
+
if (++lineCount > 20) break;
|
|
745
|
+
if (!line.trim()) continue;
|
|
604
746
|
try {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
747
|
+
const obj = JSON.parse(line);
|
|
748
|
+
if (obj.type === "user" && obj.message) {
|
|
749
|
+
const msgContent = obj.message.content;
|
|
750
|
+
let text = "";
|
|
751
|
+
if (typeof msgContent === "string") {
|
|
752
|
+
text = msgContent;
|
|
753
|
+
} else if (Array.isArray(msgContent)) {
|
|
754
|
+
const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
|
|
755
|
+
text = textBlock?.text ?? "";
|
|
756
|
+
}
|
|
757
|
+
if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
|
|
758
|
+
text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
|
|
759
|
+
text = text.replace(/<[^>]+>/g, "").trim();
|
|
760
|
+
rl.close();
|
|
761
|
+
return text.length > 80 ? text.slice(0, 80) + "..." : text;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
} catch {
|
|
610
765
|
}
|
|
611
766
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
return swept;
|
|
767
|
+
} catch {
|
|
768
|
+
} finally {
|
|
769
|
+
await fileHandle?.close();
|
|
616
770
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
771
|
+
return void 0;
|
|
772
|
+
}
|
|
773
|
+
function decodeDirName(dirName) {
|
|
774
|
+
const placeholder = "\0";
|
|
775
|
+
const escaped = dirName.replace(/--/g, placeholder);
|
|
776
|
+
const decoded = escaped.replace(/-/g, "/");
|
|
777
|
+
return decoded.replace(new RegExp(placeholder, "g"), "-");
|
|
778
|
+
}
|
|
779
|
+
function encodeDirName(path2) {
|
|
780
|
+
const escaped = path2.replace(/-/g, "--");
|
|
781
|
+
return escaped.replace(/\//g, "-");
|
|
782
|
+
}
|
|
783
|
+
async function directoryExists(dirPath) {
|
|
784
|
+
try {
|
|
785
|
+
const s = await (0, import_promises.stat)(dirPath);
|
|
786
|
+
return s.isDirectory();
|
|
787
|
+
} catch {
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
792
|
+
async function countJsonlFilesWithMtime(dirPath) {
|
|
793
|
+
try {
|
|
794
|
+
const entries = await (0, import_promises.readdir)(dirPath, { withFileTypes: true });
|
|
795
|
+
const jsonlNames = new Set(
|
|
796
|
+
entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
|
|
797
|
+
);
|
|
798
|
+
const uuidDirs = entries.filter(
|
|
799
|
+
(e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
|
|
800
|
+
);
|
|
801
|
+
let latestMtime = 0;
|
|
802
|
+
const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
803
|
+
await Promise.all([
|
|
804
|
+
...jsonlEntries.map(async (entry) => {
|
|
805
|
+
try {
|
|
806
|
+
const contentTs = await extractLastTimestamp((0, import_path.join)(dirPath, entry.name));
|
|
807
|
+
const ts = contentTs ?? (await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name))).mtimeMs;
|
|
808
|
+
if (ts > latestMtime) latestMtime = ts;
|
|
809
|
+
} catch {
|
|
810
|
+
}
|
|
811
|
+
}),
|
|
812
|
+
...uuidDirs.map(async (entry) => {
|
|
813
|
+
try {
|
|
814
|
+
const fileStat = await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name));
|
|
815
|
+
if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
|
|
816
|
+
} catch {
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
]);
|
|
820
|
+
return { count: jsonlNames.size + uuidDirs.length, latestMtime };
|
|
821
|
+
} catch {
|
|
822
|
+
return { count: 0, latestMtime: 0 };
|
|
661
823
|
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/providers/ProcessProvider.ts
|
|
827
|
+
var CLAUDE_PATH = findClaudePath();
|
|
828
|
+
var ProcessProvider = class {
|
|
829
|
+
/** 活跃会话映射表:sessionId -> { session, process } */
|
|
830
|
+
activeSessions = /* @__PURE__ */ new Map();
|
|
831
|
+
/** 事件发射器,用于分发 Claude 事件流 */
|
|
832
|
+
emitter = new import_events.EventEmitter();
|
|
662
833
|
/**
|
|
663
|
-
*
|
|
834
|
+
* 启动新会话或恢复已有会话
|
|
664
835
|
*
|
|
665
|
-
*
|
|
836
|
+
* 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
|
|
837
|
+
* 并开始监听 stdout 的 NDJSON 输出。
|
|
666
838
|
*/
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
for (let i = 0; i < images.length; i++) {
|
|
673
|
-
const img = images[i];
|
|
674
|
-
if (!ALLOWED_TYPES.has(img.media_type)) {
|
|
675
|
-
if (sessionId) {
|
|
676
|
-
this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
|
|
677
|
-
}
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const sizeBytes = Math.floor(img.data.length * 0.75);
|
|
681
|
-
if (sizeBytes > MAX_IMAGE_BYTES) {
|
|
682
|
-
if (sessionId) {
|
|
683
|
-
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
|
|
684
|
-
this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
|
|
685
|
-
}
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
content.push({
|
|
689
|
-
type: "image",
|
|
690
|
-
source: { type: "base64", media_type: img.media_type, data: img.data }
|
|
691
|
-
});
|
|
692
|
-
}
|
|
839
|
+
async startSession(opts) {
|
|
840
|
+
const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
|
|
841
|
+
const sessionId = existingSessionId ?? (0, import_uuid.v4)();
|
|
842
|
+
if (this.activeSessions.has(sessionId)) {
|
|
843
|
+
await this.killSession(sessionId);
|
|
693
844
|
}
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
845
|
+
const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
|
|
846
|
+
const session = {
|
|
847
|
+
id: sessionId,
|
|
848
|
+
projectId,
|
|
849
|
+
projectPath,
|
|
850
|
+
status: "running",
|
|
851
|
+
createdAt: Date.now(),
|
|
852
|
+
lastActiveAt: Date.now(),
|
|
853
|
+
summary: message.slice(0, 80)
|
|
854
|
+
};
|
|
855
|
+
const resume = opts.resume ?? !!existingSessionId;
|
|
856
|
+
let effectiveModel = model;
|
|
857
|
+
if (resume && !effectiveModel) {
|
|
858
|
+
effectiveModel = await getSessionModel(projectPath, sessionId).catch(() => void 0);
|
|
859
|
+
if (effectiveModel) {
|
|
860
|
+
console.log(`[ProcessProvider] Session ${sessionId}: resume restored original model "${effectiveModel}"`);
|
|
705
861
|
}
|
|
706
|
-
return;
|
|
707
862
|
}
|
|
708
|
-
proc.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const
|
|
716
|
-
type: "
|
|
863
|
+
const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd);
|
|
864
|
+
this.writeUserMessage(proc, message, sessionId, images);
|
|
865
|
+
session.pid = proc.pid;
|
|
866
|
+
this.activeSessions.set(sessionId, { session, process: proc, model: effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd });
|
|
867
|
+
proc.on("error", (err) => {
|
|
868
|
+
console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
|
|
869
|
+
this.activeSessions.delete(sessionId);
|
|
870
|
+
const syntheticResult = {
|
|
871
|
+
type: "result",
|
|
872
|
+
subtype: "error",
|
|
873
|
+
result: `Process spawn failed: ${err.message}`,
|
|
717
874
|
session_id: sessionId,
|
|
718
|
-
|
|
875
|
+
duration_ms: 0,
|
|
876
|
+
is_error: true,
|
|
877
|
+
num_turns: 0
|
|
719
878
|
};
|
|
720
|
-
this.emitter.emit(this.getEventName(sessionId),
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
emitWriteError(sessionId, message) {
|
|
727
|
-
const syntheticResult = {
|
|
728
|
-
type: "result",
|
|
729
|
-
subtype: "error",
|
|
730
|
-
result: message,
|
|
731
|
-
session_id: sessionId,
|
|
732
|
-
duration_ms: 0,
|
|
733
|
-
is_error: true,
|
|
734
|
-
num_turns: 0
|
|
735
|
-
};
|
|
736
|
-
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
879
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
880
|
+
});
|
|
881
|
+
this.attachStdoutListener(sessionId, proc);
|
|
882
|
+
this.attachStderrListener(sessionId, proc);
|
|
883
|
+
this.attachExitListener(sessionId, proc);
|
|
884
|
+
return session;
|
|
737
885
|
}
|
|
738
886
|
/**
|
|
739
|
-
*
|
|
887
|
+
* 终止指定会话
|
|
888
|
+
*
|
|
889
|
+
* kill 进程并从活跃映射中移除。
|
|
740
890
|
*/
|
|
741
|
-
|
|
742
|
-
if (!proc.stdout) {
|
|
743
|
-
console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
const rl = (0, import_readline.createInterface)({
|
|
747
|
-
input: proc.stdout,
|
|
748
|
-
crlfDelay: Infinity
|
|
749
|
-
});
|
|
891
|
+
async killSession(sessionId) {
|
|
750
892
|
const entry = this.activeSessions.get(sessionId);
|
|
751
|
-
if (entry) {
|
|
752
|
-
|
|
893
|
+
if (!entry) {
|
|
894
|
+
return;
|
|
753
895
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
if (result.ok) {
|
|
759
|
-
const event = result.value;
|
|
760
|
-
if (event.type === "assistant") {
|
|
761
|
-
for (const block of event.message.content) {
|
|
762
|
-
if (block.type === "tool_use") {
|
|
763
|
-
const isQuestion = block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion";
|
|
764
|
-
if (!isQuestion) continue;
|
|
765
|
-
const input = block.input;
|
|
766
|
-
let question = "";
|
|
767
|
-
let options;
|
|
768
|
-
let questions;
|
|
769
|
-
if (typeof input.question === "string") {
|
|
770
|
-
question = input.question;
|
|
771
|
-
options = Array.isArray(input.options) ? input.options : void 0;
|
|
772
|
-
} else if (Array.isArray(input.questions) && input.questions.length > 0) {
|
|
773
|
-
questions = input.questions.map((q) => {
|
|
774
|
-
const item = {
|
|
775
|
-
question: typeof q.question === "string" ? q.question : "",
|
|
776
|
-
header: typeof q.header === "string" ? q.header : void 0,
|
|
777
|
-
multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
|
|
778
|
-
};
|
|
779
|
-
if (Array.isArray(q.options)) {
|
|
780
|
-
item.options = q.options.map((o) => ({
|
|
781
|
-
label: typeof o.label === "string" ? o.label : String(o),
|
|
782
|
-
description: typeof o.description === "string" ? o.description : void 0
|
|
783
|
-
}));
|
|
784
|
-
}
|
|
785
|
-
return item;
|
|
786
|
-
});
|
|
787
|
-
const first = questions[0];
|
|
788
|
-
question = first.question;
|
|
789
|
-
options = first.options?.map((o) => o.label);
|
|
790
|
-
}
|
|
791
|
-
if (!question) continue;
|
|
792
|
-
const prevKey = `${block.id}:${question}:${JSON.stringify(options ?? [])}`;
|
|
793
|
-
let sessionSet = this.emittedQuestionToolUseIds.get(sessionId);
|
|
794
|
-
if (!sessionSet) {
|
|
795
|
-
sessionSet = /* @__PURE__ */ new Set();
|
|
796
|
-
this.emittedQuestionToolUseIds.set(sessionId, sessionSet);
|
|
797
|
-
}
|
|
798
|
-
if (sessionSet.has(prevKey)) continue;
|
|
799
|
-
sessionSet.add(prevKey);
|
|
800
|
-
console.log(`[ProcessProvider] Session ${sessionId}: detected ${block.name} (toolUseId=${block.id})`);
|
|
801
|
-
this.emitter.emit(this.getQuestionEventName(sessionId), {
|
|
802
|
-
toolUseId: block.id,
|
|
803
|
-
question,
|
|
804
|
-
options,
|
|
805
|
-
questions
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
this.updateSessionStatus(sessionId, event);
|
|
811
|
-
this.emitter.emit(this.getEventName(sessionId), event);
|
|
812
|
-
} else {
|
|
813
|
-
console.warn(
|
|
814
|
-
`[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
/**
|
|
820
|
-
* 挂载 stderr 监听器,记录日志
|
|
821
|
-
*/
|
|
822
|
-
attachStderrListener(sessionId, proc) {
|
|
823
|
-
if (!proc.stderr) return;
|
|
824
|
-
proc.stderr.on("data", (data) => {
|
|
825
|
-
const text = data.toString().trim();
|
|
826
|
-
if (text) {
|
|
827
|
-
console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
|
|
896
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
897
|
+
try {
|
|
898
|
+
entry.process.stdin?.end();
|
|
899
|
+
} catch {
|
|
828
900
|
}
|
|
829
|
-
|
|
901
|
+
await killProcessCrossPlatform(entry.process);
|
|
902
|
+
}
|
|
903
|
+
this.activeSessions.delete(sessionId);
|
|
830
904
|
}
|
|
831
905
|
/**
|
|
832
|
-
*
|
|
906
|
+
* 向已有会话发送新消息
|
|
833
907
|
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
* updateSessionStatus 会将 session.status 更新为 idle/error。
|
|
837
|
-
* 此时合成事件会重复触发,导致手机端出现两张总结卡。
|
|
838
|
-
* 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
|
|
839
|
-
* 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
|
|
908
|
+
* 快速路径:进程存活时直接写 stdin(毫秒级响应)。
|
|
909
|
+
* 慢速路径:进程已退出时 respawn 并 --resume。
|
|
840
910
|
*/
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
entry.session.pid = void 0;
|
|
911
|
+
async sendMessage(sessionId, message, permissionMode, images) {
|
|
912
|
+
const entry = this.activeSessions.get(sessionId);
|
|
913
|
+
if (!entry) {
|
|
914
|
+
throw new Error(`Session ${sessionId} not found or already ended`);
|
|
915
|
+
}
|
|
916
|
+
const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
|
|
917
|
+
if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
|
|
918
|
+
entry.session.status = "running";
|
|
851
919
|
entry.session.lastActiveAt = Date.now();
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
920
|
+
this.writeUserMessage(entry.process, message, sessionId, images);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (modeChanged) {
|
|
924
|
+
console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
|
|
925
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
926
|
+
try {
|
|
927
|
+
entry.process.stdin?.end();
|
|
928
|
+
} catch {
|
|
929
|
+
}
|
|
930
|
+
killProcessCrossPlatform(entry.process);
|
|
860
931
|
}
|
|
932
|
+
} else {
|
|
933
|
+
console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
|
|
934
|
+
}
|
|
935
|
+
const newMode = permissionMode ?? entry.permissionMode;
|
|
936
|
+
const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
|
|
937
|
+
this.writeUserMessage(proc, message, sessionId, images);
|
|
938
|
+
entry.session.status = "running";
|
|
939
|
+
entry.session.lastActiveAt = Date.now();
|
|
940
|
+
entry.session.pid = proc.pid;
|
|
941
|
+
entry.process = proc;
|
|
942
|
+
entry.permissionMode = newMode;
|
|
943
|
+
proc.on("error", (err) => {
|
|
944
|
+
console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
|
|
945
|
+
this.activeSessions.delete(sessionId);
|
|
861
946
|
const syntheticResult = {
|
|
862
947
|
type: "result",
|
|
863
|
-
subtype:
|
|
948
|
+
subtype: "error",
|
|
949
|
+
result: `Failed to send message: ${err.message}`,
|
|
864
950
|
session_id: sessionId,
|
|
865
|
-
is_error: !isNormal,
|
|
866
|
-
result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
|
|
867
951
|
duration_ms: 0,
|
|
952
|
+
is_error: true,
|
|
868
953
|
num_turns: 0
|
|
869
954
|
};
|
|
870
955
|
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
871
956
|
});
|
|
957
|
+
this.attachStdoutListener(sessionId, proc);
|
|
958
|
+
this.attachStderrListener(sessionId, proc);
|
|
959
|
+
this.attachExitListener(sessionId, proc);
|
|
872
960
|
}
|
|
873
961
|
/**
|
|
874
|
-
*
|
|
962
|
+
* 订阅指定会话的 Claude 事件流
|
|
963
|
+
*
|
|
964
|
+
* @returns 取消订阅函数
|
|
875
965
|
*/
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
ok: false,
|
|
883
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
884
|
-
};
|
|
885
|
-
}
|
|
966
|
+
onEvent(sessionId, callback) {
|
|
967
|
+
const eventName = this.getEventName(sessionId);
|
|
968
|
+
this.emitter.on(eventName, callback);
|
|
969
|
+
return () => {
|
|
970
|
+
this.emitter.off(eventName, callback);
|
|
971
|
+
};
|
|
886
972
|
}
|
|
887
973
|
/**
|
|
888
|
-
*
|
|
974
|
+
* 获取当前所有活跃会话列表
|
|
889
975
|
*/
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
if (!entry) return;
|
|
893
|
-
entry.session.lastActiveAt = Date.now();
|
|
894
|
-
switch (event.type) {
|
|
895
|
-
case "system":
|
|
896
|
-
if (event.subtype === "init") {
|
|
897
|
-
entry.session.status = "running";
|
|
898
|
-
}
|
|
899
|
-
break;
|
|
900
|
-
case "assistant":
|
|
901
|
-
entry.session.status = "running";
|
|
902
|
-
break;
|
|
903
|
-
case "result":
|
|
904
|
-
entry.session.status = event.is_error ? "error" : "idle";
|
|
905
|
-
break;
|
|
906
|
-
}
|
|
976
|
+
getActiveSessions() {
|
|
977
|
+
return Array.from(this.activeSessions.values()).map((entry) => entry.session);
|
|
907
978
|
}
|
|
908
979
|
/**
|
|
909
|
-
*
|
|
980
|
+
* 清理空闲进程
|
|
910
981
|
*
|
|
911
|
-
*
|
|
982
|
+
* 找出所有 status='idle' 且 lastActiveAt 距今超过 maxIdleMs 的活跃进程,
|
|
983
|
+
* kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
|
|
984
|
+
* 走 slow path 自动 --resume 重启进程。
|
|
985
|
+
*
|
|
986
|
+
* @returns 被 sweep 的 sessionId 列表
|
|
912
987
|
*/
|
|
913
|
-
async
|
|
914
|
-
const
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
}
|
|
936
|
-
});
|
|
937
|
-
proc.once("error", reject);
|
|
938
|
-
});
|
|
988
|
+
async sweepIdleProcesses(maxIdleMs) {
|
|
989
|
+
const now = Date.now();
|
|
990
|
+
const swept = [];
|
|
991
|
+
for (const [sessionId, entry] of this.activeSessions) {
|
|
992
|
+
if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
|
|
993
|
+
if (entry.session.status !== "idle") continue;
|
|
994
|
+
if (now - entry.session.lastActiveAt < maxIdleMs) continue;
|
|
995
|
+
const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
|
|
996
|
+
console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
|
|
997
|
+
try {
|
|
998
|
+
entry.process.stdin?.end();
|
|
999
|
+
} catch {
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
await killProcessCrossPlatform(entry.process);
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
swept.push(sessionId);
|
|
1008
|
+
}
|
|
1009
|
+
return swept;
|
|
939
1010
|
}
|
|
940
1011
|
/**
|
|
941
|
-
*
|
|
1012
|
+
* LRU 上限清理
|
|
1013
|
+
*
|
|
1014
|
+
* 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
|
|
1015
|
+
* 状态为 idle 的进程,直到活跃数回到上限以内。
|
|
1016
|
+
* running / waiting_question 状态的进程永远不会被 kill。
|
|
942
1017
|
*
|
|
943
|
-
*
|
|
944
|
-
* Claude 收到后继续执行。
|
|
1018
|
+
* @returns 被 sweep 的 sessionId 列表
|
|
945
1019
|
*/
|
|
946
|
-
async
|
|
947
|
-
const
|
|
948
|
-
if (
|
|
949
|
-
|
|
1020
|
+
async sweepLruProcesses(maxAlive) {
|
|
1021
|
+
const swept = [];
|
|
1022
|
+
if (maxAlive <= 0) return swept;
|
|
1023
|
+
const aliveEntries = Array.from(this.activeSessions.entries()).filter(
|
|
1024
|
+
([, e]) => e.process.exitCode === null && e.process.signalCode === null
|
|
1025
|
+
);
|
|
1026
|
+
if (aliveEntries.length <= maxAlive) return swept;
|
|
1027
|
+
const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
|
|
1028
|
+
let aliveCount = aliveEntries.length;
|
|
1029
|
+
for (const [sessionId, entry] of idleSorted) {
|
|
1030
|
+
if (aliveCount <= maxAlive) break;
|
|
1031
|
+
const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
|
|
1032
|
+
console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
|
|
1033
|
+
try {
|
|
1034
|
+
entry.process.stdin?.end();
|
|
1035
|
+
} catch {
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
await killProcessCrossPlatform(entry.process);
|
|
1039
|
+
swept.push(sessionId);
|
|
1040
|
+
aliveCount--;
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
|
|
1043
|
+
}
|
|
950
1044
|
}
|
|
951
|
-
if (
|
|
952
|
-
|
|
1045
|
+
if (aliveCount > maxAlive) {
|
|
1046
|
+
console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
|
|
953
1047
|
}
|
|
954
|
-
|
|
955
|
-
type: "user",
|
|
956
|
-
session_id: "",
|
|
957
|
-
message: {
|
|
958
|
-
role: "user",
|
|
959
|
-
content: [{ type: "tool_result", tool_use_id: toolUseId, content: answer }]
|
|
960
|
-
},
|
|
961
|
-
parent_tool_use_id: toolUseId
|
|
962
|
-
});
|
|
963
|
-
await new Promise((resolve, reject) => {
|
|
964
|
-
entry.process.stdin.write(toolResult + "\n", (err) => {
|
|
965
|
-
if (err) reject(err);
|
|
966
|
-
else resolve();
|
|
967
|
-
});
|
|
968
|
-
});
|
|
969
|
-
console.log(`[ProcessProvider] Session ${sessionId}: AskUserQuestion answered (toolUseId=${toolUseId})`);
|
|
1048
|
+
return swept;
|
|
970
1049
|
}
|
|
971
1050
|
/**
|
|
972
|
-
*
|
|
1051
|
+
* 枚举可淘汰的老会话
|
|
973
1052
|
*
|
|
974
|
-
*
|
|
1053
|
+
* 进程已退出(已被空闲 GC kill)且空闲超过 maxIdleMs 的会话——其 entry 与各 Map
|
|
1054
|
+
* 仍长期占内存。调用方对返回 id 执行 killSession 彻底清除;淘汰后手机端发消息
|
|
1055
|
+
* 会自动走 resume 路径(--resume + JSONL),不影响继续对话。
|
|
1056
|
+
*
|
|
1057
|
+
* @returns 可淘汰的 sessionId 列表(仅枚举,不删除)
|
|
975
1058
|
*/
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1059
|
+
listEvictableSessions(maxIdleMs) {
|
|
1060
|
+
if (maxIdleMs <= 0) return [];
|
|
1061
|
+
const now = Date.now();
|
|
1062
|
+
const evictable = [];
|
|
1063
|
+
for (const [sessionId, entry] of this.activeSessions) {
|
|
1064
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) continue;
|
|
1065
|
+
if (entry.session.status === "running" || entry.session.status === "waiting_question" || entry.session.status === "waiting_approval") continue;
|
|
1066
|
+
if (now - entry.session.lastActiveAt < maxIdleMs) continue;
|
|
1067
|
+
evictable.push(sessionId);
|
|
1068
|
+
}
|
|
1069
|
+
return evictable;
|
|
982
1070
|
}
|
|
1071
|
+
// ============================================
|
|
1072
|
+
// 私有方法
|
|
1073
|
+
// ============================================
|
|
983
1074
|
/**
|
|
984
|
-
*
|
|
1075
|
+
* 启动 claude CLI 进程(持久模式,stdin 保持开放接收多条消息)
|
|
985
1076
|
*/
|
|
986
|
-
|
|
987
|
-
|
|
1077
|
+
spawnClaudeProcess(sessionId, projectPath, resume = false, model, permissionMode, effort, fallbackModel, maxBudgetUsd) {
|
|
1078
|
+
const args = [
|
|
1079
|
+
"--input-format",
|
|
1080
|
+
"stream-json",
|
|
1081
|
+
"--output-format",
|
|
1082
|
+
"stream-json",
|
|
1083
|
+
"--verbose",
|
|
1084
|
+
"--include-partial-messages",
|
|
1085
|
+
"--include-hook-events"
|
|
1086
|
+
];
|
|
1087
|
+
if (resume) {
|
|
1088
|
+
args.push("--resume", sessionId);
|
|
1089
|
+
} else {
|
|
1090
|
+
args.push("--session-id", sessionId);
|
|
1091
|
+
}
|
|
1092
|
+
if (model) {
|
|
1093
|
+
args.push("--model", model);
|
|
1094
|
+
}
|
|
1095
|
+
if (permissionMode && permissionMode !== "default") {
|
|
1096
|
+
args.push("--permission-mode", permissionMode);
|
|
1097
|
+
}
|
|
1098
|
+
if (effort) {
|
|
1099
|
+
args.push("--effort", effort);
|
|
1100
|
+
}
|
|
1101
|
+
if (fallbackModel) {
|
|
1102
|
+
args.push("--fallback-model", fallbackModel);
|
|
1103
|
+
}
|
|
1104
|
+
if (maxBudgetUsd != null) {
|
|
1105
|
+
args.push("--max-budget-usd", String(maxBudgetUsd));
|
|
1106
|
+
}
|
|
1107
|
+
const env = { ...process.env, SESSIX_SESSION_ID: sessionId };
|
|
1108
|
+
delete env.CLAUDECODE;
|
|
1109
|
+
const proc = (0, import_child_process.spawn)(CLAUDE_PATH, args, {
|
|
1110
|
+
cwd: projectPath,
|
|
1111
|
+
env,
|
|
1112
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1113
|
+
});
|
|
1114
|
+
return proc;
|
|
988
1115
|
}
|
|
989
1116
|
/**
|
|
990
|
-
*
|
|
1117
|
+
* 向持久进程的 stdin 写入一条用户消息(NDJSON 格式)
|
|
1118
|
+
*
|
|
1119
|
+
* 写入失败时合成 error result 事件,确保 SessionManager 能感知到失败。
|
|
991
1120
|
*/
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1121
|
+
writeUserMessage(proc, message, sessionId, images) {
|
|
1122
|
+
const content = [];
|
|
1123
|
+
if (images?.length) {
|
|
1124
|
+
const ALLOWED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
1125
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
1126
|
+
for (let i = 0; i < images.length; i++) {
|
|
1127
|
+
const img = images[i];
|
|
1128
|
+
if (!ALLOWED_TYPES.has(img.media_type)) {
|
|
1129
|
+
if (sessionId) {
|
|
1130
|
+
this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
|
|
1131
|
+
}
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const sizeBytes = Math.floor(img.data.length * 0.75);
|
|
1135
|
+
if (sizeBytes > MAX_IMAGE_BYTES) {
|
|
1136
|
+
if (sessionId) {
|
|
1137
|
+
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
|
|
1138
|
+
this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
|
|
1139
|
+
}
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
content.push({
|
|
1143
|
+
type: "image",
|
|
1144
|
+
source: { type: "base64", media_type: img.media_type, data: img.data }
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
content.push({ type: "text", text: message });
|
|
1149
|
+
const payload = JSON.stringify({
|
|
1150
|
+
type: "user",
|
|
1151
|
+
session_id: "",
|
|
1152
|
+
message: { role: "user", content },
|
|
1153
|
+
parent_tool_use_id: null
|
|
1154
|
+
});
|
|
1155
|
+
if (!proc.stdin || proc.stdin.destroyed) {
|
|
1156
|
+
console.error(`[ProcessProvider] stdin unavailable, message lost`);
|
|
1157
|
+
if (sessionId) {
|
|
1158
|
+
this.emitWriteError(sessionId, "Process stdin closed, message not delivered");
|
|
1159
|
+
}
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
proc.stdin.write(payload + "\n", (err) => {
|
|
1163
|
+
if (err && sessionId) {
|
|
1164
|
+
console.error(`[ProcessProvider] Session ${sessionId} stdin write failed:`, err.message);
|
|
1165
|
+
this.emitWriteError(sessionId, `Failed to send message: ${err.message}`);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
if (sessionId) {
|
|
1169
|
+
const syntheticUser = {
|
|
1170
|
+
type: "user",
|
|
1171
|
+
session_id: sessionId,
|
|
1172
|
+
message: { role: "user", content }
|
|
1173
|
+
};
|
|
1174
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticUser);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* 发出写入失败的合成错误事件
|
|
1179
|
+
*/
|
|
1180
|
+
emitWriteError(sessionId, message) {
|
|
1181
|
+
const syntheticResult = {
|
|
1182
|
+
type: "result",
|
|
1183
|
+
subtype: "error",
|
|
1184
|
+
result: message,
|
|
1185
|
+
session_id: sessionId,
|
|
1186
|
+
duration_ms: 0,
|
|
1187
|
+
is_error: true,
|
|
1188
|
+
num_turns: 0
|
|
1189
|
+
};
|
|
1190
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* 挂载 stdout 监听器,逐行解析 NDJSON
|
|
1194
|
+
*/
|
|
1195
|
+
attachStdoutListener(sessionId, proc) {
|
|
1196
|
+
if (!proc.stdout) {
|
|
1197
|
+
console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const rl = (0, import_readline2.createInterface)({
|
|
1201
|
+
input: proc.stdout,
|
|
1202
|
+
crlfDelay: Infinity
|
|
1203
|
+
});
|
|
1204
|
+
const entry = this.activeSessions.get(sessionId);
|
|
1205
|
+
if (entry) {
|
|
1206
|
+
entry.rl = rl;
|
|
1207
|
+
}
|
|
1208
|
+
rl.on("line", (line) => {
|
|
1209
|
+
const trimmed = line.trim();
|
|
1210
|
+
if (!trimmed) return;
|
|
1211
|
+
const result = this.parseLine(trimmed);
|
|
1212
|
+
if (result.ok) {
|
|
1213
|
+
const event = result.value;
|
|
1214
|
+
this.updateSessionStatus(sessionId, event);
|
|
1215
|
+
this.emitter.emit(this.getEventName(sessionId), event);
|
|
1216
|
+
} else {
|
|
1217
|
+
console.warn(
|
|
1218
|
+
`[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* 挂载 stderr 监听器,记录日志
|
|
1225
|
+
*/
|
|
1226
|
+
attachStderrListener(sessionId, proc) {
|
|
1227
|
+
if (!proc.stderr) return;
|
|
1228
|
+
proc.stderr.on("data", (data) => {
|
|
1229
|
+
const text = data.toString().trim();
|
|
1230
|
+
if (text) {
|
|
1231
|
+
console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* 挂载进程退出监听器
|
|
1237
|
+
*
|
|
1238
|
+
* 当进程退出时发出合成的 result 事件,确保 SessionManager 能感知到退出。
|
|
1239
|
+
* 正常退出时 Claude 会先通过 stdout 发送真实 result 事件,
|
|
1240
|
+
* updateSessionStatus 会将 session.status 更新为 idle/error。
|
|
1241
|
+
* 此时合成事件会重复触发,导致手机端出现两张总结卡。
|
|
1242
|
+
* 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
|
|
1243
|
+
* 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
|
|
1244
|
+
*/
|
|
1245
|
+
attachExitListener(sessionId, proc) {
|
|
1246
|
+
proc.once("exit", (code, signal) => {
|
|
1247
|
+
const entry = this.activeSessions.get(sessionId);
|
|
1248
|
+
if (!entry) return;
|
|
1249
|
+
if (entry.process !== proc) return;
|
|
1250
|
+
if (entry.rl) {
|
|
1251
|
+
entry.rl.close();
|
|
1252
|
+
entry.rl = void 0;
|
|
1253
|
+
}
|
|
1254
|
+
entry.session.pid = void 0;
|
|
1255
|
+
entry.session.lastActiveAt = Date.now();
|
|
1256
|
+
const alreadyHasResult = entry.session.status === "idle" || entry.session.status === "error";
|
|
1257
|
+
if (alreadyHasResult) return;
|
|
1258
|
+
const isNormal = isNormalExit(code, signal);
|
|
1259
|
+
entry.session.status = isNormal ? "idle" : "error";
|
|
1260
|
+
if (!isNormal) {
|
|
1261
|
+
console.error(
|
|
1262
|
+
`[ProcessProvider] Session ${sessionId}: process exited abnormally code=${code} signal=${signal}`
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const syntheticResult = {
|
|
1266
|
+
type: "result",
|
|
1267
|
+
subtype: isNormal ? "success" : "error",
|
|
1268
|
+
session_id: sessionId,
|
|
1269
|
+
is_error: !isNormal,
|
|
1270
|
+
result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
|
|
1271
|
+
duration_ms: 0,
|
|
1272
|
+
num_turns: 0
|
|
1273
|
+
};
|
|
1274
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* 解析一行 NDJSON 文本为 ClaudeStreamEvent
|
|
1279
|
+
*/
|
|
1280
|
+
parseLine(line) {
|
|
1281
|
+
try {
|
|
1282
|
+
const parsed = JSON.parse(line);
|
|
1283
|
+
return { ok: true, value: parsed };
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
return {
|
|
1286
|
+
ok: false,
|
|
1287
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* 根据 Claude 事件更新会话状态
|
|
1293
|
+
*/
|
|
1294
|
+
updateSessionStatus(sessionId, event) {
|
|
1295
|
+
const entry = this.activeSessions.get(sessionId);
|
|
1296
|
+
if (!entry) return;
|
|
1297
|
+
entry.session.lastActiveAt = Date.now();
|
|
1298
|
+
switch (event.type) {
|
|
1299
|
+
case "system":
|
|
1300
|
+
if (event.subtype === "init") {
|
|
1301
|
+
entry.session.status = "running";
|
|
1302
|
+
}
|
|
1303
|
+
break;
|
|
1304
|
+
case "assistant":
|
|
1305
|
+
entry.session.status = "running";
|
|
1306
|
+
break;
|
|
1307
|
+
case "result":
|
|
1308
|
+
entry.session.status = event.is_error ? "error" : "idle";
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* 根据对话上下文生成下一步建议指令
|
|
1314
|
+
*
|
|
1315
|
+
* 使用 --output-format text 做一次性调用,返回纯文本结果。
|
|
1316
|
+
*/
|
|
1317
|
+
async generateSuggestion(context) {
|
|
1318
|
+
const prompt = `You are an AI coding assistant. Based on the following Claude Code conversation context, suggest the most valuable next instruction for the user (give the instruction directly, no explanation, no quotes):
|
|
1319
|
+
|
|
1320
|
+
${context}`;
|
|
1321
|
+
return new Promise((resolve, reject) => {
|
|
1322
|
+
const env = { ...process.env };
|
|
1323
|
+
delete env.CLAUDECODE;
|
|
1324
|
+
const proc = (0, import_child_process.spawn)(CLAUDE_PATH, ["-p", prompt, "--output-format", "text"], {
|
|
1325
|
+
cwd: (0, import_node_os2.homedir)(),
|
|
1326
|
+
env,
|
|
1327
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1328
|
+
});
|
|
1329
|
+
proc.stdin.end();
|
|
1330
|
+
let output = "";
|
|
1331
|
+
proc.stdout?.on("data", (data) => {
|
|
1332
|
+
output += data.toString();
|
|
1333
|
+
});
|
|
1334
|
+
proc.once("exit", (code) => {
|
|
1335
|
+
if (code === 0) {
|
|
1336
|
+
resolve(output.trim());
|
|
1337
|
+
} else {
|
|
1338
|
+
reject(new Error(`generateSuggestion process exit code: ${code}`));
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
proc.once("error", reject);
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* 生成事件名称
|
|
1346
|
+
*/
|
|
1347
|
+
getEventName(sessionId) {
|
|
1348
|
+
return `claude:${sessionId}`;
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
// src/providers/CodexProvider.ts
|
|
1353
|
+
var import_child_process2 = require("child_process");
|
|
1354
|
+
var import_readline3 = require("readline");
|
|
1355
|
+
var import_events2 = require("events");
|
|
1356
|
+
var import_fs = require("fs");
|
|
1357
|
+
var import_path2 = require("path");
|
|
1358
|
+
var import_os2 = require("os");
|
|
1359
|
+
var import_uuid2 = require("uuid");
|
|
1360
|
+
|
|
1361
|
+
// src/utils/codexPath.ts
|
|
1362
|
+
var import_node_child_process3 = require("child_process");
|
|
1363
|
+
var import_node_fs2 = require("fs");
|
|
1364
|
+
var import_node_path2 = require("path");
|
|
1365
|
+
var import_node_os3 = require("os");
|
|
1011
1366
|
function findCodexPath() {
|
|
1012
1367
|
try {
|
|
1013
1368
|
const cmd = isWindows ? "where codex" : "which codex";
|
|
@@ -1079,9 +1434,9 @@ function isCodexAvailable() {
|
|
|
1079
1434
|
}
|
|
1080
1435
|
|
|
1081
1436
|
// src/providers/CodexProvider.ts
|
|
1082
|
-
var SESSIX_DIR = (0,
|
|
1083
|
-
var CODEX_SESSIONS_FILE = (0,
|
|
1084
|
-
var CODEX_EVENTS_DIR = (0,
|
|
1437
|
+
var SESSIX_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".sessix");
|
|
1438
|
+
var CODEX_SESSIONS_FILE = (0, import_path2.join)(SESSIX_DIR, "codex-sessions.json");
|
|
1439
|
+
var CODEX_EVENTS_DIR = (0, import_path2.join)(SESSIX_DIR, "codex-events");
|
|
1085
1440
|
var SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1086
1441
|
var CodexProvider = class {
|
|
1087
1442
|
activeSessions = /* @__PURE__ */ new Map();
|
|
@@ -1228,12 +1583,6 @@ var CodexProvider = class {
|
|
|
1228
1583
|
async generateSuggestion(_context) {
|
|
1229
1584
|
return "";
|
|
1230
1585
|
}
|
|
1231
|
-
async answerQuestion(_sessionId, _toolUseId, _answer) {
|
|
1232
|
-
}
|
|
1233
|
-
onQuestion(_sessionId, _callback) {
|
|
1234
|
-
return () => {
|
|
1235
|
-
};
|
|
1236
|
-
}
|
|
1237
1586
|
// ============================================
|
|
1238
1587
|
// 私有方法
|
|
1239
1588
|
// ============================================
|
|
@@ -1285,7 +1634,7 @@ var CodexProvider = class {
|
|
|
1285
1634
|
*/
|
|
1286
1635
|
attachStdoutListener(sessionId, proc) {
|
|
1287
1636
|
if (!proc.stdout) return;
|
|
1288
|
-
const rl = (0,
|
|
1637
|
+
const rl = (0, import_readline3.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
|
|
1289
1638
|
const entry = this.activeSessions.get(sessionId);
|
|
1290
1639
|
if (entry) entry.rl = rl;
|
|
1291
1640
|
rl.on("line", (line) => {
|
|
@@ -1567,9 +1916,9 @@ var CodexProvider = class {
|
|
|
1567
1916
|
* 优先从内存读,miss 时从磁盘加载
|
|
1568
1917
|
*/
|
|
1569
1918
|
getSessionHistory(sessionId) {
|
|
1570
|
-
const
|
|
1571
|
-
if (
|
|
1572
|
-
const filePath = (0,
|
|
1919
|
+
const cached2 = this.sessionEvents.get(sessionId);
|
|
1920
|
+
if (cached2 && cached2.length > 0) return cached2;
|
|
1921
|
+
const filePath = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
|
|
1573
1922
|
try {
|
|
1574
1923
|
if (!(0, import_fs.existsSync)(filePath)) return [];
|
|
1575
1924
|
const data = JSON.parse((0, import_fs.readFileSync)(filePath, "utf-8"));
|
|
@@ -1590,7 +1939,7 @@ var CodexProvider = class {
|
|
|
1590
1939
|
(0, import_fs.mkdirSync)(CODEX_EVENTS_DIR, { recursive: true });
|
|
1591
1940
|
}
|
|
1592
1941
|
(0, import_fs.writeFileSync)(
|
|
1593
|
-
(0,
|
|
1942
|
+
(0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
|
|
1594
1943
|
JSON.stringify(events),
|
|
1595
1944
|
"utf-8"
|
|
1596
1945
|
);
|
|
@@ -1615,7 +1964,7 @@ var CodexProvider = class {
|
|
|
1615
1964
|
if (now - m.lastActiveAt > SESSION_EXPIRY_MS) {
|
|
1616
1965
|
expiredCount++;
|
|
1617
1966
|
try {
|
|
1618
|
-
const eventsFile = (0,
|
|
1967
|
+
const eventsFile = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
|
|
1619
1968
|
if ((0, import_fs.existsSync)(eventsFile)) (0, import_fs.unlinkSync)(eventsFile);
|
|
1620
1969
|
} catch {
|
|
1621
1970
|
}
|
|
@@ -1728,7 +2077,6 @@ var ProviderFactory = class {
|
|
|
1728
2077
|
};
|
|
1729
2078
|
|
|
1730
2079
|
// src/session/SessionManager.ts
|
|
1731
|
-
var import_uuid3 = require("uuid");
|
|
1732
2080
|
var BUFFER_MAX = 5e3;
|
|
1733
2081
|
var SessionManager = class {
|
|
1734
2082
|
provider;
|
|
@@ -1736,10 +2084,18 @@ var SessionManager = class {
|
|
|
1736
2084
|
sessionAgentType = /* @__PURE__ */ new Map();
|
|
1737
2085
|
/** 事件回调列表(事件会被转发到 WsBridge) */
|
|
1738
2086
|
eventCallbacks = [];
|
|
2087
|
+
/** 会话被移除(kill / 淘汰)时的回调列表(用于释放外部模块的会话级状态,如 NotificationService) */
|
|
2088
|
+
sessionRemovedCallbacks = [];
|
|
1739
2089
|
/** 每个会话的事件流取消订阅函数 */
|
|
1740
2090
|
unsubscribeMap = /* @__PURE__ */ new Map();
|
|
1741
2091
|
/** 每个会话的事件缓冲区(用于新订阅者重放)*/
|
|
1742
2092
|
sessionEventBuffers = /* @__PURE__ */ new Map();
|
|
2093
|
+
/**
|
|
2094
|
+
* 每个会话最近一次 AskUserQuestion tool_use 的真实 id(从 claude_event 流捕获)。
|
|
2095
|
+
* PreToolUse hook payload 不含 tool_use_id,但内联卡片需要它来匹配状态,
|
|
2096
|
+
* 故在转发流事件时记录,askQuestion 时兜底回填。
|
|
2097
|
+
*/
|
|
2098
|
+
lastAskQuestionToolUseId = /* @__PURE__ */ new Map();
|
|
1743
2099
|
/** AskUserQuestion 问题映射:requestId → resolve 回调 + 原始问题内容 */
|
|
1744
2100
|
pendingQuestions = /* @__PURE__ */ new Map();
|
|
1745
2101
|
/**
|
|
@@ -1848,6 +2204,7 @@ var SessionManager = class {
|
|
|
1848
2204
|
this.bufferTruncated.delete(sessionId);
|
|
1849
2205
|
this.sessionProjectPaths.delete(sessionId);
|
|
1850
2206
|
this.sessionStats.delete(sessionId);
|
|
2207
|
+
this.lastAskQuestionToolUseId.delete(sessionId);
|
|
1851
2208
|
const pending = this.pendingAssistantEvents.get(sessionId);
|
|
1852
2209
|
if (pending) {
|
|
1853
2210
|
clearTimeout(pending.timer);
|
|
@@ -1856,6 +2213,13 @@ var SessionManager = class {
|
|
|
1856
2213
|
const provider = this.getProviderForSession(sessionId);
|
|
1857
2214
|
await provider.killSession(sessionId);
|
|
1858
2215
|
this.sessionAgentType.delete(sessionId);
|
|
2216
|
+
for (const cb of this.sessionRemovedCallbacks) {
|
|
2217
|
+
try {
|
|
2218
|
+
cb(sessionId);
|
|
2219
|
+
} catch (err) {
|
|
2220
|
+
console.error("[SessionManager] sessionRemoved callback failed:", err);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
1859
2223
|
console.log(`[SessionManager] Session killed: ${sessionId}`);
|
|
1860
2224
|
}
|
|
1861
2225
|
/**
|
|
@@ -1908,7 +2272,22 @@ var SessionManager = class {
|
|
|
1908
2272
|
}
|
|
1909
2273
|
}
|
|
1910
2274
|
/**
|
|
1911
|
-
*
|
|
2275
|
+
* 幂等清理单个待回答问题(由 ApprovalProxy onQuestionResolved 触发:
|
|
2276
|
+
* 答案到达 / 325s 超时 / 会话 kill / 服务关闭)。
|
|
2277
|
+
* 已不存在则静默返回(不打 warn,与 handleQuestionResponse 区分)。
|
|
2278
|
+
*/
|
|
2279
|
+
clearPendingQuestion(requestId) {
|
|
2280
|
+
const pending = this.pendingQuestions.get(requestId);
|
|
2281
|
+
if (!pending) return;
|
|
2282
|
+
const { sessionId } = pending;
|
|
2283
|
+
this.pendingQuestions.delete(requestId);
|
|
2284
|
+
pending.resolve("");
|
|
2285
|
+
if (!this.hasPendingQuestionsForSession(sessionId)) {
|
|
2286
|
+
this.updateSessionStatus(sessionId, "running");
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* 获取指定会话的所有待回答问题(用于重连时恢复)
|
|
1912
2291
|
*/
|
|
1913
2292
|
getPendingQuestionsForSession(sessionId) {
|
|
1914
2293
|
const result = [];
|
|
@@ -2031,6 +2410,21 @@ var SessionManager = class {
|
|
|
2031
2410
|
}
|
|
2032
2411
|
};
|
|
2033
2412
|
}
|
|
2413
|
+
/**
|
|
2414
|
+
* 注册"会话被移除"回调(会话 kill 或淘汰时触发,传入 sessionId)。
|
|
2415
|
+
* 用于让外部模块释放会话级状态,如 NotificationService.releaseSession。
|
|
2416
|
+
*
|
|
2417
|
+
* @returns 取消注册的函数
|
|
2418
|
+
*/
|
|
2419
|
+
onSessionRemoved(callback) {
|
|
2420
|
+
this.sessionRemovedCallbacks.push(callback);
|
|
2421
|
+
return () => {
|
|
2422
|
+
const index = this.sessionRemovedCallbacks.indexOf(callback);
|
|
2423
|
+
if (index !== -1) {
|
|
2424
|
+
this.sessionRemovedCallbacks.splice(index, 1);
|
|
2425
|
+
}
|
|
2426
|
+
};
|
|
2427
|
+
}
|
|
2034
2428
|
/**
|
|
2035
2429
|
* 清理所有资源
|
|
2036
2430
|
*/
|
|
@@ -2048,31 +2442,28 @@ var SessionManager = class {
|
|
|
2048
2442
|
clearTimeout(pending.timer);
|
|
2049
2443
|
}
|
|
2050
2444
|
this.pendingAssistantEvents.clear();
|
|
2445
|
+
for (const pending of this.pendingQuestions.values()) {
|
|
2446
|
+
pending.resolve("");
|
|
2447
|
+
}
|
|
2051
2448
|
this.pendingQuestions.clear();
|
|
2052
2449
|
this.lastBroadcastStatus.clear();
|
|
2053
2450
|
this.eventCallbacks.length = 0;
|
|
2451
|
+
this.sessionRemovedCallbacks.length = 0;
|
|
2054
2452
|
console.log("[SessionManager] Destroyed");
|
|
2055
2453
|
}
|
|
2056
2454
|
// ============================================
|
|
2057
2455
|
// 内部方法
|
|
2058
2456
|
// ============================================
|
|
2059
2457
|
/**
|
|
2060
|
-
*
|
|
2458
|
+
* 订阅指定会话的事件流(AskUserQuestion 已改由 ApprovalProxy hook 驱动)
|
|
2061
2459
|
*/
|
|
2062
2460
|
subscribeToSession(sessionId) {
|
|
2063
2461
|
const provider = this.getProviderForSession(sessionId);
|
|
2064
2462
|
const unsubscribeEvent = provider.onEvent(sessionId, (event) => {
|
|
2065
2463
|
this.handleClaudeEvent(sessionId, event);
|
|
2066
2464
|
});
|
|
2067
|
-
const unsubscribeQuestion = provider.onQuestion(
|
|
2068
|
-
sessionId,
|
|
2069
|
-
({ toolUseId, question, options, questions }) => {
|
|
2070
|
-
this.handleAskUserQuestion(sessionId, toolUseId, question, options, questions);
|
|
2071
|
-
}
|
|
2072
|
-
);
|
|
2073
2465
|
this.unsubscribeMap.set(sessionId, () => {
|
|
2074
2466
|
unsubscribeEvent();
|
|
2075
|
-
unsubscribeQuestion();
|
|
2076
2467
|
});
|
|
2077
2468
|
}
|
|
2078
2469
|
/**
|
|
@@ -2106,6 +2497,13 @@ var SessionManager = class {
|
|
|
2106
2497
|
console.log(`[SessionManager] \u{1F9E0} thinking block detected in ${sessionId}: msgId=${event.message.id}, blocks=${thinkingBlocks.length}, len=${thinkingBlocks.map((b) => (b.thinking || "").length).join(",")}`);
|
|
2107
2498
|
}
|
|
2108
2499
|
}
|
|
2500
|
+
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
2501
|
+
for (const block of event.message.content) {
|
|
2502
|
+
if (block.type === "tool_use" && (block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion") && typeof block.id === "string") {
|
|
2503
|
+
this.lastAskQuestionToolUseId.set(sessionId, block.id);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2109
2507
|
switch (event.type) {
|
|
2110
2508
|
case "assistant":
|
|
2111
2509
|
this.bufferAssistantEvent(sessionId, event);
|
|
@@ -2221,55 +2619,35 @@ var SessionManager = class {
|
|
|
2221
2619
|
return runningStartedAt ? { ...base, runningStartedAt } : base;
|
|
2222
2620
|
}
|
|
2223
2621
|
/**
|
|
2224
|
-
*
|
|
2622
|
+
* 由 ApprovalProxy 在 PreToolUse hook 拦截到 AskUserQuestion 时调用。
|
|
2623
|
+
* 登记 pendingQuestion、广播 question_request、置 waiting_question,
|
|
2624
|
+
* 返回的 Promise 在 handleQuestionResponse 时 resolve。
|
|
2225
2625
|
*/
|
|
2226
|
-
|
|
2227
|
-
const
|
|
2228
|
-
([, v]) => v.toolUseId === toolUseId
|
|
2229
|
-
);
|
|
2230
|
-
if (existingEntry) {
|
|
2231
|
-
const [existingRequestId, existingPending] = existingEntry;
|
|
2232
|
-
existingPending.question = question;
|
|
2233
|
-
existingPending.options = options;
|
|
2234
|
-
existingPending.questions = questions;
|
|
2235
|
-
existingPending.createdAt = Date.now();
|
|
2236
|
-
const updatedRequest = {
|
|
2237
|
-
id: existingRequestId,
|
|
2238
|
-
sessionId,
|
|
2239
|
-
toolUseId,
|
|
2240
|
-
question,
|
|
2241
|
-
options,
|
|
2242
|
-
questions,
|
|
2243
|
-
createdAt: existingPending.createdAt
|
|
2244
|
-
};
|
|
2245
|
-
this.emit({ type: "question_request", request: updatedRequest });
|
|
2246
|
-
console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion updated (requestId=${existingRequestId})`);
|
|
2247
|
-
return;
|
|
2248
|
-
}
|
|
2249
|
-
const requestId = (0, import_uuid3.v4)();
|
|
2626
|
+
askQuestion(sessionId, toolUseId, questions, requestId) {
|
|
2627
|
+
const resolvedToolUseId = toolUseId || this.lastAskQuestionToolUseId.get(sessionId) || "";
|
|
2250
2628
|
const request = {
|
|
2251
2629
|
id: requestId,
|
|
2252
2630
|
sessionId,
|
|
2253
|
-
toolUseId,
|
|
2254
|
-
question,
|
|
2255
|
-
options,
|
|
2631
|
+
toolUseId: resolvedToolUseId,
|
|
2632
|
+
question: questions[0]?.question ?? "",
|
|
2633
|
+
options: questions[0]?.options?.map((o) => o.label),
|
|
2256
2634
|
questions,
|
|
2257
2635
|
createdAt: Date.now()
|
|
2258
2636
|
};
|
|
2259
2637
|
this.updateSessionStatus(sessionId, "waiting_question");
|
|
2260
2638
|
this.emit({ type: "question_request", request });
|
|
2261
|
-
const answerPromise = new Promise((resolve) => {
|
|
2262
|
-
this.pendingQuestions.set(requestId, { sessionId, toolUseId, question, options, questions, createdAt: request.createdAt, resolve });
|
|
2263
|
-
});
|
|
2264
|
-
answerPromise.then(async (answer) => {
|
|
2265
|
-
try {
|
|
2266
|
-
const provider = this.getProviderForSession(sessionId);
|
|
2267
|
-
await provider.answerQuestion(sessionId, toolUseId, answer);
|
|
2268
|
-
} catch (err) {
|
|
2269
|
-
console.error(`[SessionManager] answerQuestion failed (${sessionId}):`, err);
|
|
2270
|
-
}
|
|
2271
|
-
}).catch((err) => console.error("[SessionManager] answerPromise rejected:", err));
|
|
2272
2639
|
console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion pushed (requestId=${requestId})`);
|
|
2640
|
+
return new Promise((resolve) => {
|
|
2641
|
+
this.pendingQuestions.set(requestId, {
|
|
2642
|
+
sessionId,
|
|
2643
|
+
toolUseId: resolvedToolUseId,
|
|
2644
|
+
question: request.question,
|
|
2645
|
+
options: request.options,
|
|
2646
|
+
questions,
|
|
2647
|
+
createdAt: request.createdAt,
|
|
2648
|
+
resolve
|
|
2649
|
+
});
|
|
2650
|
+
});
|
|
2273
2651
|
}
|
|
2274
2652
|
/**
|
|
2275
2653
|
* 清除指定会话的所有待回答问题
|
|
@@ -2282,6 +2660,7 @@ var SessionManager = class {
|
|
|
2282
2660
|
}
|
|
2283
2661
|
}
|
|
2284
2662
|
for (const requestId of toRemove) {
|
|
2663
|
+
this.pendingQuestions.get(requestId)?.resolve("");
|
|
2285
2664
|
this.pendingQuestions.delete(requestId);
|
|
2286
2665
|
}
|
|
2287
2666
|
}
|
|
@@ -2301,7 +2680,7 @@ var SessionManager = class {
|
|
|
2301
2680
|
|
|
2302
2681
|
// src/session/SessionFileWatcher.ts
|
|
2303
2682
|
var import_chokidar = __toESM(require("chokidar"));
|
|
2304
|
-
var
|
|
2683
|
+
var import_promises2 = require("fs/promises");
|
|
2305
2684
|
var import_node_readline = require("readline");
|
|
2306
2685
|
var SessionFileWatcher = class {
|
|
2307
2686
|
watchers = /* @__PURE__ */ new Map();
|
|
@@ -2380,7 +2759,7 @@ var SessionFileWatcher = class {
|
|
|
2380
2759
|
let fileHandle;
|
|
2381
2760
|
let rl;
|
|
2382
2761
|
try {
|
|
2383
|
-
fileHandle = await (0,
|
|
2762
|
+
fileHandle = await (0, import_promises2.open)(entry.filePath, "r");
|
|
2384
2763
|
const fileStat = await fileHandle.stat();
|
|
2385
2764
|
const newSize = fileStat.size;
|
|
2386
2765
|
if (newSize <= entry.byteOffset) return;
|
|
@@ -2690,7 +3069,7 @@ var import_node_http = __toESM(require("http"));
|
|
|
2690
3069
|
var import_node_fs3 = __toESM(require("fs"));
|
|
2691
3070
|
var import_node_path3 = __toESM(require("path"));
|
|
2692
3071
|
var import_node_os4 = __toESM(require("os"));
|
|
2693
|
-
var
|
|
3072
|
+
var import_uuid3 = require("uuid");
|
|
2694
3073
|
var ApprovalProxy = class _ApprovalProxy {
|
|
2695
3074
|
server;
|
|
2696
3075
|
token;
|
|
@@ -2698,10 +3077,16 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2698
3077
|
settingsPath = import_node_path3.default.join(import_node_os4.default.homedir(), ".claude", "settings.json");
|
|
2699
3078
|
/** 待处理的审批请求:requestId -> { resolve, timer, request } */
|
|
2700
3079
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
3080
|
+
/** 待回答的 AskUserQuestion:requestId -> { resolve, timer, request } */
|
|
3081
|
+
pendingQuestions = /* @__PURE__ */ new Map();
|
|
3082
|
+
/** 由外部注入:把问题推给手机并等待答案(返回用户答案文本) */
|
|
3083
|
+
questionHandler = null;
|
|
2701
3084
|
/** 审批请求回调(通知外部推送到手机) */
|
|
2702
3085
|
approvalRequestCallbacks = [];
|
|
2703
3086
|
/** 审批 resolve 回调(任何来源的 resolve 都会触发,用于 WS 广播清理 */
|
|
2704
3087
|
approvalResolvedCallbacks = [];
|
|
3088
|
+
/** 问题 resolve 回调(任何来源的 resolve 都会触发,用于 SessionManager 清理) */
|
|
3089
|
+
questionResolvedCallbacks = [];
|
|
2705
3090
|
/** Hook 通知回调(PreCompact / PermissionDenied / 等非阻塞 hook) */
|
|
2706
3091
|
notifyCallbacks = [];
|
|
2707
3092
|
/** YOLO 模式状态:sessionId -> enabled */
|
|
@@ -2765,6 +3150,30 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2765
3150
|
}
|
|
2766
3151
|
}
|
|
2767
3152
|
}
|
|
3153
|
+
/**
|
|
3154
|
+
* 注册问题 resolve 回调
|
|
3155
|
+
*
|
|
3156
|
+
* 任何来源的 resolve 都会触发:
|
|
3157
|
+
* - resolveQuestion(手机端答案到达)
|
|
3158
|
+
* - 325s 超时自动空答案
|
|
3159
|
+
* - clearPendingQuestionsForSession(会话被 kill)
|
|
3160
|
+
* - close()(服务关闭)
|
|
3161
|
+
*
|
|
3162
|
+
* 用于通知 SessionManager 清理 pendingQuestions,避免会话卡在 waiting_question。
|
|
3163
|
+
*/
|
|
3164
|
+
onQuestionResolved(callback) {
|
|
3165
|
+
this.questionResolvedCallbacks.push(callback);
|
|
3166
|
+
}
|
|
3167
|
+
/** 通知所有问题 resolve 回调(内部调用) */
|
|
3168
|
+
notifyQuestionResolved(requestId) {
|
|
3169
|
+
for (const cb of this.questionResolvedCallbacks) {
|
|
3170
|
+
try {
|
|
3171
|
+
cb(requestId);
|
|
3172
|
+
} catch (err) {
|
|
3173
|
+
console.error("[ApprovalProxy] question resolved callback error:", err);
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
2768
3177
|
/**
|
|
2769
3178
|
* 注册非阻塞 hook 通知回调(如 PreCompact、PermissionDenied)
|
|
2770
3179
|
*
|
|
@@ -2819,6 +3228,42 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2819
3228
|
this.notifyApprovalResolved(requestId, decision);
|
|
2820
3229
|
return true;
|
|
2821
3230
|
}
|
|
3231
|
+
/** 注入问题处理器(server.ts 接到 SessionManager.askQuestion) */
|
|
3232
|
+
setQuestionHandler(handler) {
|
|
3233
|
+
this.questionHandler = handler;
|
|
3234
|
+
}
|
|
3235
|
+
/** 解析一个待回答问题(手机端答案到达时由 server.ts 调用) */
|
|
3236
|
+
resolveQuestion(requestId, answer) {
|
|
3237
|
+
const pending = this.pendingQuestions.get(requestId);
|
|
3238
|
+
if (!pending) {
|
|
3239
|
+
console.warn(`[ApprovalProxy] Question request not found: ${requestId}`);
|
|
3240
|
+
return false;
|
|
3241
|
+
}
|
|
3242
|
+
clearTimeout(pending.timer);
|
|
3243
|
+
pending.resolve(answer);
|
|
3244
|
+
this.pendingQuestions.delete(requestId);
|
|
3245
|
+
console.log(`[ApprovalProxy] Question answered: ${requestId}`);
|
|
3246
|
+
this.notifyQuestionResolved(requestId);
|
|
3247
|
+
return true;
|
|
3248
|
+
}
|
|
3249
|
+
/** 清理会话的待回答问题(会话被 kill 时,给空答案让 hook 不再阻塞) */
|
|
3250
|
+
clearPendingQuestionsForSession(sessionId) {
|
|
3251
|
+
const toRemove = [];
|
|
3252
|
+
for (const [requestId, pending] of this.pendingQuestions) {
|
|
3253
|
+
if (pending.request.sessionId === sessionId) {
|
|
3254
|
+
toRemove.push(requestId);
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
for (const requestId of toRemove) {
|
|
3258
|
+
const pending = this.pendingQuestions.get(requestId);
|
|
3259
|
+
if (!pending) continue;
|
|
3260
|
+
clearTimeout(pending.timer);
|
|
3261
|
+
pending.resolve("");
|
|
3262
|
+
this.pendingQuestions.delete(requestId);
|
|
3263
|
+
console.log(`[ApprovalProxy] Session ${sessionId} killed, cleared pending question ${requestId}`);
|
|
3264
|
+
this.notifyQuestionResolved(requestId);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
2822
3267
|
/** 获取当前待处理的审批数量 */
|
|
2823
3268
|
getPendingCount() {
|
|
2824
3269
|
return this.pendingApprovals.size;
|
|
@@ -2940,6 +3385,13 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2940
3385
|
pending.resolve({ decision: "deny", reason: t("approval.serverClosed") });
|
|
2941
3386
|
}
|
|
2942
3387
|
this.pendingApprovals.clear();
|
|
3388
|
+
const pendingQuestionEntries = Array.from(this.pendingQuestions.entries());
|
|
3389
|
+
for (const [requestId, pending] of pendingQuestionEntries) {
|
|
3390
|
+
clearTimeout(pending.timer);
|
|
3391
|
+
pending.resolve("");
|
|
3392
|
+
this.notifyQuestionResolved(requestId);
|
|
3393
|
+
}
|
|
3394
|
+
this.pendingQuestions.clear();
|
|
2943
3395
|
this.server.close((err) => {
|
|
2944
3396
|
if (err) {
|
|
2945
3397
|
reject(err);
|
|
@@ -2995,7 +3447,7 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2995
3447
|
try {
|
|
2996
3448
|
const body = await this.parseJsonBody(req);
|
|
2997
3449
|
const payload = body.payload ?? body;
|
|
2998
|
-
const requestId = (0,
|
|
3450
|
+
const requestId = (0, import_uuid3.v4)();
|
|
2999
3451
|
const projectPath = String(body.projectPath ?? "unknown");
|
|
3000
3452
|
const toolName = String(payload.tool_name ?? body.tool_name ?? "unknown");
|
|
3001
3453
|
const toolInput = payload.tool_input ?? body.tool_input ?? {};
|
|
@@ -3009,6 +3461,51 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
3009
3461
|
createdAt: Date.now()
|
|
3010
3462
|
};
|
|
3011
3463
|
console.log(`[ApprovalProxy] ${t("approval.received")}: ${requestId} (${approvalRequest.toolName})`);
|
|
3464
|
+
if ((approvalRequest.toolName === "AskUserQuestion" || approvalRequest.toolName === "AskFollowupQuestion") && this.questionHandler) {
|
|
3465
|
+
const questions = parseQuestionsFromInput(toolInput);
|
|
3466
|
+
if (questions.length === 0) {
|
|
3467
|
+
this.sendJson(res, 200, { decision: "allow" });
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
const toolUseId = String(
|
|
3471
|
+
payload.tool_use_id ?? body.tool_use_id ?? ""
|
|
3472
|
+
);
|
|
3473
|
+
const qRequest = {
|
|
3474
|
+
id: requestId,
|
|
3475
|
+
sessionId: approvalRequest.sessionId,
|
|
3476
|
+
toolUseId,
|
|
3477
|
+
question: questions[0].question,
|
|
3478
|
+
options: questions[0].options?.map((o) => o.label),
|
|
3479
|
+
questions,
|
|
3480
|
+
createdAt: Date.now()
|
|
3481
|
+
};
|
|
3482
|
+
const answer = await new Promise((resolve) => {
|
|
3483
|
+
const timer = setTimeout(() => {
|
|
3484
|
+
this.pendingQuestions.delete(requestId);
|
|
3485
|
+
console.log(`[ApprovalProxy] Question timeout: ${requestId}`);
|
|
3486
|
+
resolve("");
|
|
3487
|
+
this.notifyQuestionResolved(requestId);
|
|
3488
|
+
}, 325e3);
|
|
3489
|
+
this.pendingQuestions.set(requestId, { resolve, timer, request: qRequest });
|
|
3490
|
+
this.questionHandler(qRequest.sessionId, toolUseId, questions, requestId).then((ans) => {
|
|
3491
|
+
if (ans && this.pendingQuestions.has(requestId)) this.resolveQuestion(requestId, ans);
|
|
3492
|
+
}).catch((err) => console.error("[ApprovalProxy] questionHandler error:", err));
|
|
3493
|
+
});
|
|
3494
|
+
if (!answer) {
|
|
3495
|
+
this.sendJson(res, 200, {
|
|
3496
|
+
decision: "deny",
|
|
3497
|
+
reason: "\u7528\u6237\u672A\u5728\u9650\u5B9A\u65F6\u95F4\u5185\u56DE\u7B54\u8BE5\u95EE\u9898\u3002\u8BF7\u57FA\u4E8E\u73B0\u6709\u4FE1\u606F\u81EA\u884C\u51B3\u7B56\u540E\u7EE7\u7EED\uFF0C\u4E0D\u8981\u91CD\u590D\u8C03\u7528 AskUserQuestion\u3002",
|
|
3498
|
+
systemMessage: "\u7528\u6237\u672A\u56DE\u7B54 AskUserQuestion\uFF1B\u8BF7\u52FF\u91CD\u8BD5\u8BE5\u5DE5\u5177\uFF0C\u81EA\u884C\u51B3\u7B56\u7EE7\u7EED\u3002"
|
|
3499
|
+
});
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
this.sendJson(res, 200, {
|
|
3503
|
+
decision: "deny",
|
|
3504
|
+
reason: formatQuestionAnswer(questions, answer),
|
|
3505
|
+
systemMessage: "\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u79FB\u52A8\u7AEF\u56DE\u7B54\u4E86 AskUserQuestion\uFF0C\u56DE\u7B54\u5728\u5DE5\u5177\u53CD\u9988\u4E2D\u7ED9\u51FA\u3002\u8BF7\u5C06\u5176\u89C6\u4E3A\u8BE5\u5DE5\u5177\u7684\u6709\u6548\u8FD4\u56DE\u76F4\u63A5\u7EE7\u7EED\uFF0C\u4E0D\u8981\u518D\u6B21\u8C03\u7528 AskUserQuestion/AskFollowupQuestion\u3002"
|
|
3506
|
+
});
|
|
3507
|
+
return;
|
|
3508
|
+
}
|
|
3012
3509
|
if (this.isToolAlwaysAllowed(approvalRequest.toolName, projectPath !== "unknown" ? projectPath : void 0)) {
|
|
3013
3510
|
console.log(`[ApprovalProxy] ${t("approval.alwaysAllowPassThrough", { tool: approvalRequest.toolName })}`);
|
|
3014
3511
|
this.sendJson(res, 200, { decision: "allow" });
|
|
@@ -3198,6 +3695,49 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
3198
3695
|
res.end(body);
|
|
3199
3696
|
}
|
|
3200
3697
|
};
|
|
3698
|
+
function parseQuestionsFromInput(input) {
|
|
3699
|
+
if (Array.isArray(input.questions) && input.questions.length > 0) {
|
|
3700
|
+
return input.questions.map((q) => {
|
|
3701
|
+
const item = {
|
|
3702
|
+
question: typeof q.question === "string" ? q.question : "",
|
|
3703
|
+
header: typeof q.header === "string" ? q.header : void 0,
|
|
3704
|
+
multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
|
|
3705
|
+
};
|
|
3706
|
+
if (Array.isArray(q.options)) {
|
|
3707
|
+
item.options = q.options.map((o) => ({
|
|
3708
|
+
label: typeof o.label === "string" ? o.label : "",
|
|
3709
|
+
description: typeof o.description === "string" ? o.description : void 0
|
|
3710
|
+
}));
|
|
3711
|
+
}
|
|
3712
|
+
return item;
|
|
3713
|
+
});
|
|
3714
|
+
}
|
|
3715
|
+
if (typeof input.question === "string") {
|
|
3716
|
+
const opts = Array.isArray(input.options) ? input.options.map((o) => ({ label: String(o) })) : void 0;
|
|
3717
|
+
return [{ question: input.question, options: opts }];
|
|
3718
|
+
}
|
|
3719
|
+
return [];
|
|
3720
|
+
}
|
|
3721
|
+
function formatQuestionAnswer(questions, raw) {
|
|
3722
|
+
let pairs = [];
|
|
3723
|
+
try {
|
|
3724
|
+
const parsed = JSON.parse(raw);
|
|
3725
|
+
if (parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object") {
|
|
3726
|
+
const answers = parsed.answers;
|
|
3727
|
+
pairs = questions.length > 0 ? questions.map((item) => ({ q: item.question, a: String(answers[item.question] ?? "") })) : Object.entries(answers).map(([q, a]) => ({ q, a: String(a) }));
|
|
3728
|
+
}
|
|
3729
|
+
} catch {
|
|
3730
|
+
}
|
|
3731
|
+
if (pairs.length === 0) {
|
|
3732
|
+
pairs = [{ q: questions[0]?.question ?? "\uFF08\u672A\u77E5\u95EE\u9898\uFF09", a: raw }];
|
|
3733
|
+
}
|
|
3734
|
+
const body = pairs.map((p, i) => `${i + 1}. \u95EE\u9898\uFF1A${p.q}
|
|
3735
|
+
\u7528\u6237\u56DE\u7B54\uFF1A${p.a}`).join("\n");
|
|
3736
|
+
return `\u3010\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u56DE\u7B54\u3011
|
|
3737
|
+
${body}
|
|
3738
|
+
|
|
3739
|
+
\u8BF7\u5C06\u4EE5\u4E0A\u56DE\u7B54\u89C6\u4E3A AskUserQuestion \u7684\u6709\u6548\u8FD4\u56DE\uFF0C\u76F4\u63A5\u636E\u6B64\u7EE7\u7EED\u5B8C\u6210\u4EFB\u52A1\uFF0C\u4E0D\u8981\u518D\u6B21\u8C03\u7528 AskUserQuestion/AskFollowupQuestion\u3002`;
|
|
3740
|
+
}
|
|
3201
3741
|
|
|
3202
3742
|
// src/mdns/MdnsService.ts
|
|
3203
3743
|
var import_node_child_process5 = require("child_process");
|
|
@@ -3363,7 +3903,7 @@ function getLanAddresses(networkInterfacesFn) {
|
|
|
3363
3903
|
}
|
|
3364
3904
|
|
|
3365
3905
|
// src/hooks/HookInstaller.ts
|
|
3366
|
-
var
|
|
3906
|
+
var import_promises3 = require("fs/promises");
|
|
3367
3907
|
var import_node_path4 = require("path");
|
|
3368
3908
|
var import_node_os6 = require("os");
|
|
3369
3909
|
var SESSIX_HOOKS_DIR = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".sessix", "hooks");
|
|
@@ -3383,7 +3923,7 @@ var LEGACY_HOOK_COMMANDS = [
|
|
|
3383
3923
|
"~/.sessix/hooks/permission-accept.sh"
|
|
3384
3924
|
];
|
|
3385
3925
|
var HOOK_SCRIPT_TEMPLATE = `#!/usr/bin/env node
|
|
3386
|
-
// Sessix Approval Hook
|
|
3926
|
+
// Sessix Approval Hook v2 (systemMessage passthrough)
|
|
3387
3927
|
// \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
|
|
3388
3928
|
|
|
3389
3929
|
const sessionId = process.env.SESSIX_SESSION_ID
|
|
@@ -3414,6 +3954,9 @@ process.stdin.on('end', async () => {
|
|
|
3414
3954
|
if (decision === 'deny' && data.reason) {
|
|
3415
3955
|
output.hookSpecificOutput.permissionDecisionReason = String(data.reason)
|
|
3416
3956
|
}
|
|
3957
|
+
if (data.systemMessage) {
|
|
3958
|
+
output.systemMessage = String(data.systemMessage)
|
|
3959
|
+
}
|
|
3417
3960
|
process.stdout.write(JSON.stringify(output))
|
|
3418
3961
|
process.exit(0)
|
|
3419
3962
|
} catch {
|
|
@@ -3553,17 +4096,17 @@ var HookInstaller = class {
|
|
|
3553
4096
|
* 4. 更新 Claude Code settings.json 添加 hook 配置
|
|
3554
4097
|
*/
|
|
3555
4098
|
async install() {
|
|
3556
|
-
await (0,
|
|
3557
|
-
await (0,
|
|
3558
|
-
await (0,
|
|
3559
|
-
await (0,
|
|
3560
|
-
await (0,
|
|
3561
|
-
await (0,
|
|
3562
|
-
await (0,
|
|
3563
|
-
await (0,
|
|
3564
|
-
await (0,
|
|
3565
|
-
await (0,
|
|
3566
|
-
await (0,
|
|
4099
|
+
await (0, import_promises3.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
|
|
4100
|
+
await (0, import_promises3.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
|
|
4101
|
+
await (0, import_promises3.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
|
|
4102
|
+
await (0, import_promises3.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
|
|
4103
|
+
await (0, import_promises3.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
|
|
4104
|
+
await (0, import_promises3.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
|
|
4105
|
+
await (0, import_promises3.chmod)(HOOK_SCRIPT_PATH, 493);
|
|
4106
|
+
await (0, import_promises3.chmod)(PERMISSION_ACCEPT_PATH, 493);
|
|
4107
|
+
await (0, import_promises3.chmod)(COMPACT_HOOK_PATH, 493);
|
|
4108
|
+
await (0, import_promises3.chmod)(POST_COMPACT_HOOK_PATH, 493);
|
|
4109
|
+
await (0, import_promises3.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
|
|
3567
4110
|
await this.addHookToSettings();
|
|
3568
4111
|
console.log("[HookInstaller] Hook installation complete");
|
|
3569
4112
|
}
|
|
@@ -3593,33 +4136,34 @@ var HookInstaller = class {
|
|
|
3593
4136
|
let postCompactScriptExists = false;
|
|
3594
4137
|
let permissionDeniedScriptExists = false;
|
|
3595
4138
|
try {
|
|
3596
|
-
approvalScriptContent = await (0,
|
|
4139
|
+
approvalScriptContent = await (0, import_promises3.readFile)(HOOK_SCRIPT_PATH, "utf-8");
|
|
3597
4140
|
} catch {
|
|
3598
4141
|
}
|
|
3599
4142
|
try {
|
|
3600
|
-
await (0,
|
|
4143
|
+
await (0, import_promises3.access)(PERMISSION_ACCEPT_PATH);
|
|
3601
4144
|
permissionScriptExists = true;
|
|
3602
4145
|
} catch {
|
|
3603
4146
|
}
|
|
3604
4147
|
try {
|
|
3605
|
-
await (0,
|
|
4148
|
+
await (0, import_promises3.access)(COMPACT_HOOK_PATH);
|
|
3606
4149
|
compactScriptExists = true;
|
|
3607
4150
|
} catch {
|
|
3608
4151
|
}
|
|
3609
4152
|
try {
|
|
3610
|
-
await (0,
|
|
4153
|
+
await (0, import_promises3.access)(POST_COMPACT_HOOK_PATH);
|
|
3611
4154
|
postCompactScriptExists = true;
|
|
3612
4155
|
} catch {
|
|
3613
4156
|
}
|
|
3614
4157
|
try {
|
|
3615
|
-
await (0,
|
|
4158
|
+
await (0, import_promises3.access)(PERMISSION_DENIED_HOOK_PATH);
|
|
3616
4159
|
permissionDeniedScriptExists = true;
|
|
3617
4160
|
} catch {
|
|
3618
4161
|
}
|
|
3619
|
-
const isLatestVersion = approvalScriptContent.includes("permissionDecision");
|
|
4162
|
+
const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
|
|
3620
4163
|
const settings = await this.readClaudeSettings();
|
|
3621
4164
|
const configExists = this.hasHookConfig(settings);
|
|
3622
|
-
|
|
4165
|
+
const hasLegacyHook = this.hasHookEntry(settings?.hooks?.PreToolUse, LEGACY_HOOK_COMMANDS[0]) || this.hasHookEntry(settings?.hooks?.PermissionRequest, LEGACY_HOOK_COMMANDS[1]);
|
|
4166
|
+
return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists && !hasLegacyHook;
|
|
3623
4167
|
}
|
|
3624
4168
|
// ============================================
|
|
3625
4169
|
// 内部方法
|
|
@@ -3631,8 +4175,14 @@ var HookInstaller = class {
|
|
|
3631
4175
|
let settings = await this.readClaudeSettings();
|
|
3632
4176
|
let changed = false;
|
|
3633
4177
|
for (const cmd of LEGACY_HOOK_COMMANDS) {
|
|
3634
|
-
this.
|
|
3635
|
-
|
|
4178
|
+
if (this.hasHookEntry(settings?.hooks?.PreToolUse, cmd)) {
|
|
4179
|
+
this.removeHookCommand(settings, "PreToolUse", cmd);
|
|
4180
|
+
changed = true;
|
|
4181
|
+
}
|
|
4182
|
+
if (this.hasHookEntry(settings?.hooks?.PermissionRequest, cmd)) {
|
|
4183
|
+
this.removeHookCommand(settings, "PermissionRequest", cmd);
|
|
4184
|
+
changed = true;
|
|
4185
|
+
}
|
|
3636
4186
|
}
|
|
3637
4187
|
if (!settings.hooks) {
|
|
3638
4188
|
settings.hooks = {};
|
|
@@ -3724,7 +4274,7 @@ var HookInstaller = class {
|
|
|
3724
4274
|
*/
|
|
3725
4275
|
async readClaudeSettings() {
|
|
3726
4276
|
try {
|
|
3727
|
-
const content = await (0,
|
|
4277
|
+
const content = await (0, import_promises3.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
|
|
3728
4278
|
return JSON.parse(content);
|
|
3729
4279
|
} catch {
|
|
3730
4280
|
return {};
|
|
@@ -3734,8 +4284,8 @@ var HookInstaller = class {
|
|
|
3734
4284
|
* 写入 Claude Code settings.json
|
|
3735
4285
|
*/
|
|
3736
4286
|
async writeClaudeSettings(settings) {
|
|
3737
|
-
await (0,
|
|
3738
|
-
await (0,
|
|
4287
|
+
await (0, import_promises3.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
|
|
4288
|
+
await (0, import_promises3.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
3739
4289
|
}
|
|
3740
4290
|
/**
|
|
3741
4291
|
* 检查 settings 中是否已包含所有 Sessix hook 配置
|
|
@@ -3776,7 +4326,7 @@ var HookInstaller = class {
|
|
|
3776
4326
|
var import_node_path5 = require("path");
|
|
3777
4327
|
var RECENT_ACTIVITY_MAX = 6;
|
|
3778
4328
|
var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
|
|
3779
|
-
var NotificationService = class {
|
|
4329
|
+
var NotificationService = class _NotificationService {
|
|
3780
4330
|
constructor(sessionManager, expoChannel = null) {
|
|
3781
4331
|
this.sessionManager = sessionManager;
|
|
3782
4332
|
this.expoChannel = expoChannel;
|
|
@@ -3818,6 +4368,14 @@ var NotificationService = class {
|
|
|
3818
4368
|
* token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
|
|
3819
4369
|
*/
|
|
3820
4370
|
laHeartbeatTimers = /* @__PURE__ */ new Map();
|
|
4371
|
+
/**
|
|
4372
|
+
* 上次推送的内容指纹(status + recentActivity + approvalId)。
|
|
4373
|
+
* 只在内容实际变化时发 priority-10 推送;未变化时低频刷新(30s),
|
|
4374
|
+
* 节省 APNs push budget,避免 iOS 节流导致 LA 停滞。
|
|
4375
|
+
*/
|
|
4376
|
+
lastPushedFingerprint = /* @__PURE__ */ new Map();
|
|
4377
|
+
/** 内容未变化时低频刷新间隔(仅刷新 stats/timer,不含内容变化) */
|
|
4378
|
+
static STATS_REFRESH_INTERVAL_MS = 3e4;
|
|
3821
4379
|
/** 添加通知渠道(id 唯一,可用于后续动态开关) */
|
|
3822
4380
|
addChannel(id, channel, enabled = true) {
|
|
3823
4381
|
this.channelMap.set(id, { channel, enabled });
|
|
@@ -3874,6 +4432,7 @@ var NotificationService = class {
|
|
|
3874
4432
|
this.recentActivityState.delete(sessionId);
|
|
3875
4433
|
this.lastActivityPushAt.delete(sessionId);
|
|
3876
4434
|
this.activityCounters.delete(sessionId);
|
|
4435
|
+
this.lastPushedFingerprint.delete(sessionId);
|
|
3877
4436
|
}
|
|
3878
4437
|
/** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
|
|
3879
4438
|
setPendingApprovalsProvider(fn) {
|
|
@@ -4020,10 +4579,39 @@ var NotificationService = class {
|
|
|
4020
4579
|
this.latestAssistantText.clear();
|
|
4021
4580
|
for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
|
|
4022
4581
|
this.activityPushTimers.clear();
|
|
4582
|
+
for (const timer of this.idleEndTimers.values()) clearTimeout(timer);
|
|
4583
|
+
this.idleEndTimers.clear();
|
|
4584
|
+
for (const timer of this.laHeartbeatTimers.values()) clearInterval(timer);
|
|
4585
|
+
this.laHeartbeatTimers.clear();
|
|
4023
4586
|
this.recentActivityState.clear();
|
|
4024
4587
|
this.lastActivityPushAt.clear();
|
|
4025
4588
|
this.pendingPriority.clear();
|
|
4026
4589
|
this.activityCounters.clear();
|
|
4590
|
+
this.lastPushedFingerprint.clear();
|
|
4591
|
+
}
|
|
4592
|
+
/**
|
|
4593
|
+
* 释放单个会话的全部内存状态(会话被 kill 或淘汰时调用)。
|
|
4594
|
+
* 由 SessionManager.onSessionRemoved 钩子触发,覆盖用户主动 kill 和自动淘汰两条路径。
|
|
4595
|
+
* 幂等:重复调用或对未知会话调用都安全。
|
|
4596
|
+
*/
|
|
4597
|
+
releaseSession(sessionId) {
|
|
4598
|
+
this.clearActivityPushTimer(sessionId);
|
|
4599
|
+
this.cancelIdleEndTimer(sessionId);
|
|
4600
|
+
this.stopLaHeartbeat(sessionId);
|
|
4601
|
+
this.clearSessionActivityState(sessionId);
|
|
4602
|
+
this.yoloModeState.delete(sessionId);
|
|
4603
|
+
this.lastActivityPushAt.delete(sessionId);
|
|
4604
|
+
this.lastPushedFingerprint.delete(sessionId);
|
|
4605
|
+
this.pendingPriority.delete(sessionId);
|
|
4606
|
+
}
|
|
4607
|
+
/**
|
|
4608
|
+
* 清空单会话可重建的重状态(recentActivity / 计数器 / 最新文本)。
|
|
4609
|
+
* 会话走到 idle 时调用即可释放内存——resume 后这些状态会随新事件自动重建。
|
|
4610
|
+
*/
|
|
4611
|
+
clearSessionActivityState(sessionId) {
|
|
4612
|
+
this.recentActivityState.delete(sessionId);
|
|
4613
|
+
this.activityCounters.delete(sessionId);
|
|
4614
|
+
this.latestAssistantText.delete(sessionId);
|
|
4027
4615
|
}
|
|
4028
4616
|
// ============================================
|
|
4029
4617
|
// 内部方法
|
|
@@ -4062,6 +4650,7 @@ var NotificationService = class {
|
|
|
4062
4650
|
badge: this.getGlobalPendingCount(),
|
|
4063
4651
|
data: { type: "task_complete", sessionId: event.sessionId }
|
|
4064
4652
|
});
|
|
4653
|
+
this.clearSessionActivityState(event.sessionId);
|
|
4065
4654
|
}
|
|
4066
4655
|
} else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
|
|
4067
4656
|
this.cancelIdleEndTimer(event.sessionId);
|
|
@@ -4124,6 +4713,8 @@ var NotificationService = class {
|
|
|
4124
4713
|
while (state2.history.length > RECENT_ACTIVITY_MAX) state2.history.shift();
|
|
4125
4714
|
state2.currentEntries = [];
|
|
4126
4715
|
state2.currentMessageId = null;
|
|
4716
|
+
state2.accumulatedText = "";
|
|
4717
|
+
state2.countedToolIds = /* @__PURE__ */ new Set();
|
|
4127
4718
|
}
|
|
4128
4719
|
return;
|
|
4129
4720
|
}
|
|
@@ -4132,7 +4723,7 @@ var NotificationService = class {
|
|
|
4132
4723
|
if (!Array.isArray(msg.content)) return;
|
|
4133
4724
|
let state = this.recentActivityState.get(sessionId);
|
|
4134
4725
|
if (!state) {
|
|
4135
|
-
state = { history: [], currentMessageId: null, currentEntries: [] };
|
|
4726
|
+
state = { history: [], currentMessageId: null, currentEntries: [], accumulatedText: "", countedToolIds: /* @__PURE__ */ new Set() };
|
|
4136
4727
|
this.recentActivityState.set(sessionId, state);
|
|
4137
4728
|
}
|
|
4138
4729
|
if (state.currentMessageId !== msg.id) {
|
|
@@ -4142,16 +4733,25 @@ var NotificationService = class {
|
|
|
4142
4733
|
}
|
|
4143
4734
|
state.currentEntries = [];
|
|
4144
4735
|
state.currentMessageId = msg.id;
|
|
4736
|
+
state.accumulatedText = "";
|
|
4737
|
+
state.countedToolIds = /* @__PURE__ */ new Set();
|
|
4738
|
+
}
|
|
4739
|
+
for (const block of msg.content) {
|
|
4740
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4741
|
+
state.accumulatedText += block.text;
|
|
4742
|
+
}
|
|
4145
4743
|
}
|
|
4146
4744
|
const next = [];
|
|
4745
|
+
const accText = this.summarizeText(state.accumulatedText);
|
|
4746
|
+
if (accText.length >= 4) next.push(accText);
|
|
4147
4747
|
for (const block of msg.content) {
|
|
4148
|
-
if (block.type === "
|
|
4149
|
-
const line = this.summarizeText(block.text);
|
|
4150
|
-
if (line.length >= 4) next.push(line);
|
|
4151
|
-
} else if (block.type === "tool_use") {
|
|
4748
|
+
if (block.type === "tool_use") {
|
|
4152
4749
|
const line = this.summarizeToolCall(block.name, block.input ?? {});
|
|
4153
4750
|
if (line) next.push(line);
|
|
4154
|
-
|
|
4751
|
+
if (!state.countedToolIds.has(block.id)) {
|
|
4752
|
+
state.countedToolIds.add(block.id);
|
|
4753
|
+
this.incrementCounter(sessionId, block.name);
|
|
4754
|
+
}
|
|
4155
4755
|
}
|
|
4156
4756
|
}
|
|
4157
4757
|
state.currentEntries = next;
|
|
@@ -4267,7 +4867,7 @@ var NotificationService = class {
|
|
|
4267
4867
|
return;
|
|
4268
4868
|
}
|
|
4269
4869
|
if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
|
|
4270
|
-
this.scheduleActivityPush(sessionId);
|
|
4870
|
+
this.scheduleActivityPush(sessionId, true);
|
|
4271
4871
|
}
|
|
4272
4872
|
}, ACTIVITY_PUSH_THROTTLE_MS);
|
|
4273
4873
|
this.laHeartbeatTimers.set(sessionId, timer);
|
|
@@ -4339,17 +4939,38 @@ var NotificationService = class {
|
|
|
4339
4939
|
});
|
|
4340
4940
|
}
|
|
4341
4941
|
this.stopLaHeartbeat(sessionId);
|
|
4342
|
-
this.
|
|
4942
|
+
this.clearSessionActivityState(sessionId);
|
|
4343
4943
|
this.lastActivityPushAt.delete(sessionId);
|
|
4344
|
-
this.
|
|
4944
|
+
this.lastPushedFingerprint.delete(sessionId);
|
|
4345
4945
|
console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
|
|
4346
4946
|
}
|
|
4947
|
+
/**
|
|
4948
|
+
* 计算内容指纹:status + recentActivity + latestApproval。
|
|
4949
|
+
* 用于判断 LA 内容是否实际变化,避免重复推送消耗 APNs budget。
|
|
4950
|
+
*/
|
|
4951
|
+
computeContentFingerprint(sessionId) {
|
|
4952
|
+
const activity = this.getRecentActivity(sessionId);
|
|
4953
|
+
const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
|
|
4954
|
+
const approvals = this.pendingApprovalsProvider?.(sessionId) ?? [];
|
|
4955
|
+
const latestApproval = approvals[approvals.length - 1];
|
|
4956
|
+
return `${session?.status ?? ""}|${activity.join(" ")}|${latestApproval?.id ?? ""}`;
|
|
4957
|
+
}
|
|
4347
4958
|
/** 真正发送一次 LA content push(无 alert) */
|
|
4348
4959
|
flushActivityPush(sessionId) {
|
|
4349
4960
|
const channel = this.activityPushChannel;
|
|
4350
4961
|
if (!channel?.hasToken(sessionId)) return;
|
|
4351
4962
|
const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
|
|
4352
4963
|
if (!session) return;
|
|
4964
|
+
const fingerprint = this.computeContentFingerprint(sessionId);
|
|
4965
|
+
const lastFingerprint = this.lastPushedFingerprint.get(sessionId);
|
|
4966
|
+
const contentChanged = fingerprint !== lastFingerprint;
|
|
4967
|
+
if (!contentChanged) {
|
|
4968
|
+
const lastPush = this.lastActivityPushAt.get(sessionId) ?? 0;
|
|
4969
|
+
if (Date.now() - lastPush < _NotificationService.STATS_REFRESH_INTERVAL_MS) {
|
|
4970
|
+
return;
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
this.lastPushedFingerprint.set(sessionId, fingerprint);
|
|
4353
4974
|
const recentActivity = this.getRecentActivity(sessionId);
|
|
4354
4975
|
const latestMessage = recentActivity[recentActivity.length - 1] ?? this.latestAssistantText.get(sessionId) ?? "";
|
|
4355
4976
|
const sessionTitle = this.getSessionTitle(sessionId);
|
|
@@ -4378,13 +4999,14 @@ var NotificationService = class {
|
|
|
4378
4999
|
};
|
|
4379
5000
|
}
|
|
4380
5001
|
contentState.stats = this.buildStatsPayload(session);
|
|
4381
|
-
const
|
|
5002
|
+
const explicitPriority = this.pendingPriority.get(sessionId);
|
|
5003
|
+
const priority = explicitPriority ?? (contentChanged ? "10" : "5");
|
|
4382
5004
|
this.pendingPriority.delete(sessionId);
|
|
4383
5005
|
this.lastActivityPushAt.set(sessionId, Date.now());
|
|
4384
5006
|
const lineCount = recentActivity.length;
|
|
4385
5007
|
channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
|
|
4386
5008
|
if (ok) {
|
|
4387
|
-
console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
|
|
5009
|
+
console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} changed=${contentChanged} lines=${lineCount}`);
|
|
4388
5010
|
}
|
|
4389
5011
|
}).catch((err) => {
|
|
4390
5012
|
console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
|
|
@@ -4513,6 +5135,8 @@ var DesktopNotificationChannel = class {
|
|
|
4513
5135
|
|
|
4514
5136
|
// src/notification/ExpoNotificationChannel.ts
|
|
4515
5137
|
var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
|
|
5138
|
+
var EXPO_RECEIPT_API = "https://exp.host/--/api/v2/push/getReceipts";
|
|
5139
|
+
var RECEIPT_CHECK_DELAY_MS = 1e4;
|
|
4516
5140
|
var ExpoNotificationChannel = class {
|
|
4517
5141
|
tokens = /* @__PURE__ */ new Set();
|
|
4518
5142
|
/** push token → WebSocket 连接映射,用于前台抑制 */
|
|
@@ -4560,6 +5184,7 @@ var ExpoNotificationChannel = class {
|
|
|
4560
5184
|
if (prefs) {
|
|
4561
5185
|
const notifType = payload.data?.type ?? "";
|
|
4562
5186
|
if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
|
|
5187
|
+
else if (notifType === "question_request" && prefs.approval) sound = prefs.approval;
|
|
4563
5188
|
else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
|
|
4564
5189
|
else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
|
|
4565
5190
|
}
|
|
@@ -4590,6 +5215,7 @@ var ExpoNotificationChannel = class {
|
|
|
4590
5215
|
console.warn(`[ExpoNotificationChannel] ${t("notification.pushApiFormatError")}`, JSON.stringify(body));
|
|
4591
5216
|
return;
|
|
4592
5217
|
}
|
|
5218
|
+
const receiptIdToToken = /* @__PURE__ */ new Map();
|
|
4593
5219
|
for (let i = 0; i < body.data.length; i++) {
|
|
4594
5220
|
const ticket = body.data[i];
|
|
4595
5221
|
if (ticket.status === "error") {
|
|
@@ -4602,13 +5228,63 @@ var ExpoNotificationChannel = class {
|
|
|
4602
5228
|
this.soundPreferences.delete(staleToken);
|
|
4603
5229
|
console.warn(`[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08DeviceNotRegistered\uFF09\u3002\u82E5\u901A\u77E5\u672A\u6062\u590D\uFF0C\u8BF7\u91CD\u542F App \u91CD\u65B0\u6CE8\u518C push token\u3002`);
|
|
4604
5230
|
}
|
|
5231
|
+
} else if (ticket.status === "ok" && typeof ticket.id === "string" && targetTokens[i]) {
|
|
5232
|
+
receiptIdToToken.set(ticket.id, targetTokens[i]);
|
|
4605
5233
|
}
|
|
4606
5234
|
}
|
|
5235
|
+
this.scheduleReceiptCheck(receiptIdToToken);
|
|
4607
5236
|
}
|
|
4608
5237
|
} catch (err) {
|
|
4609
5238
|
console.warn(`[ExpoNotificationChannel] ${t("notification.sendFailed")}`, err);
|
|
4610
5239
|
}
|
|
4611
5240
|
}
|
|
5241
|
+
/**
|
|
5242
|
+
* Expo push 二阶段:延迟查 receipt,暴露 ticket 阶段看不到的 APNs 投递失败。
|
|
5243
|
+
*
|
|
5244
|
+
* 关键诊断点:InvalidCredentials / MismatchSenderId 表示 Expo 项目的 APNs
|
|
5245
|
+
* 凭证配置问题(不是用户机器问题)——这正是"只有开发者能收到推送"的根因,
|
|
5246
|
+
* 且 ticket 全为 ok、不查 receipt 永远静默。
|
|
5247
|
+
*/
|
|
5248
|
+
scheduleReceiptCheck(receiptIdToToken) {
|
|
5249
|
+
if (receiptIdToToken.size === 0) return;
|
|
5250
|
+
const timer = setTimeout(async () => {
|
|
5251
|
+
try {
|
|
5252
|
+
const ids = Array.from(receiptIdToToken.keys());
|
|
5253
|
+
const res = await fetch(EXPO_RECEIPT_API, {
|
|
5254
|
+
method: "POST",
|
|
5255
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
5256
|
+
body: JSON.stringify({ ids })
|
|
5257
|
+
});
|
|
5258
|
+
const body = await res.json();
|
|
5259
|
+
if (!res.ok || !body?.data || typeof body.data !== "object") {
|
|
5260
|
+
console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5931\u8D25", res.status, JSON.stringify(body));
|
|
5261
|
+
return;
|
|
5262
|
+
}
|
|
5263
|
+
const receipts = body.data;
|
|
5264
|
+
for (const [receiptId, receipt] of Object.entries(receipts)) {
|
|
5265
|
+
if (receipt?.status !== "error") continue;
|
|
5266
|
+
const errorCode = receipt.details?.error ?? "unknown";
|
|
5267
|
+
const token = receiptIdToToken.get(receiptId);
|
|
5268
|
+
console.error(
|
|
5269
|
+
`[ExpoNotificationChannel] \u274C APNs \u6295\u9012\u5931\u8D25 receipt=${receiptId} error=${errorCode}` + (receipt.message ? ` \u2014 ${receipt.message}` : "")
|
|
5270
|
+
);
|
|
5271
|
+
if (errorCode === "DeviceNotRegistered" && token) {
|
|
5272
|
+
this.tokens.delete(token);
|
|
5273
|
+
this.tokenWsMap.delete(token);
|
|
5274
|
+
this.soundPreferences.delete(token);
|
|
5275
|
+
console.warn("[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08receipt DeviceNotRegistered\uFF09\u3002\u91CD\u542F App \u53EF\u91CD\u65B0\u6CE8\u518C\u3002");
|
|
5276
|
+
} else if (errorCode === "InvalidCredentials" || errorCode === "MismatchSenderId") {
|
|
5277
|
+
console.error(
|
|
5278
|
+
"[ExpoNotificationChannel] \u26A0\uFE0F \u8FD9\u662F Expo \u9879\u76EE\u7684 APNs \u51ED\u8BC1\u914D\u7F6E\u95EE\u9898\uFF08\u4E0D\u662F\u7528\u6237\u673A\u5668\u95EE\u9898\uFF09\uFF1A\n Expo \u63A5\u53D7\u4E86\u63A8\u9001\u4F46 APNs \u62D2\u6295\uFF0C\u901A\u5E38\u56E0\u4E3A\u8BE5 Expo \u9879\u76EE\u672A\u914D\u7F6E\u53EF\u6295\u9012\u5230\u3010\u751F\u4EA7\u73AF\u5883\u3011\u7684 APNs Auth Key\u3002\n \u6392\u67E5\uFF1Acd packages/mobile && npx eas credentials -p ios \u2014\u2014 \u786E\u8BA4\u5B58\u5728\u8986\u76D6\u751F\u4EA7\u7684 APNs Push Key(.p8)\u3002"
|
|
5279
|
+
);
|
|
5280
|
+
}
|
|
5281
|
+
}
|
|
5282
|
+
} catch (err) {
|
|
5283
|
+
console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5F02\u5E38:", err);
|
|
5284
|
+
}
|
|
5285
|
+
}, RECEIPT_CHECK_DELAY_MS);
|
|
5286
|
+
timer.unref?.();
|
|
5287
|
+
}
|
|
4612
5288
|
};
|
|
4613
5289
|
|
|
4614
5290
|
// src/notification/ActivityPushChannel.ts
|
|
@@ -4903,470 +5579,134 @@ var ActivityPushChannel = class {
|
|
|
4903
5579
|
const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
|
|
4904
5580
|
console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
|
|
4905
5581
|
if (isProviderTokenError(err)) {
|
|
4906
|
-
console.error(
|
|
4907
|
-
`[ActivityPushChannel] \u274C APNs Auth Key \u5DF2\u5931\u6548\uFF08${reason}\uFF09\u3002\u8BF7\u5230 Apple Developer \u4E0B\u8F7D\u65B0\u7684 .p8 \u6587\u4EF6\uFF0C\u5E76\u66F4\u65B0 ~/.sessix/apns.json \u4E2D\u7684 keyId \u548C authKeyPath\uFF0C\u7136\u540E\u91CD\u542F\u670D\u52A1\u7AEF\u3002`
|
|
4908
|
-
);
|
|
4909
|
-
throw err;
|
|
4910
|
-
}
|
|
4911
|
-
if (!isBadDeviceTokenError(err)) {
|
|
4912
|
-
throw err;
|
|
4913
|
-
}
|
|
4914
|
-
}
|
|
4915
|
-
}
|
|
4916
|
-
this.deadTokens.add(deviceToken);
|
|
4917
|
-
for (const [sid, tok] of this.tokens) {
|
|
4918
|
-
if (tok === deviceToken) {
|
|
4919
|
-
this.tokens.delete(sid);
|
|
4920
|
-
console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
|
|
4921
|
-
break;
|
|
4922
|
-
}
|
|
4923
|
-
}
|
|
4924
|
-
throw lastErr ?? new Error("APNs send failed: all environments rejected token");
|
|
4925
|
-
}
|
|
4926
|
-
/** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
|
|
4927
|
-
async sendToAPNsOnce(deviceToken, payload, opts, env) {
|
|
4928
|
-
const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
|
|
4929
|
-
const pushType = opts.pushType ?? "liveactivity";
|
|
4930
|
-
const jwt = this.getJWT();
|
|
4931
|
-
const payloadStr = JSON.stringify(payload);
|
|
4932
|
-
const priority = opts.priority ?? "10";
|
|
4933
|
-
return new Promise((resolve, reject) => {
|
|
4934
|
-
let client;
|
|
4935
|
-
try {
|
|
4936
|
-
client = this.getHttp2Client(env);
|
|
4937
|
-
} catch (err) {
|
|
4938
|
-
return reject(err);
|
|
4939
|
-
}
|
|
4940
|
-
const headers = {
|
|
4941
|
-
":method": "POST",
|
|
4942
|
-
":path": `/3/device/${deviceToken}`,
|
|
4943
|
-
"authorization": `bearer ${jwt}`,
|
|
4944
|
-
"apns-topic": topic,
|
|
4945
|
-
"apns-push-type": pushType,
|
|
4946
|
-
"apns-priority": priority,
|
|
4947
|
-
"apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
|
|
4948
|
-
"content-type": "application/json",
|
|
4949
|
-
"content-length": Buffer.byteLength(payloadStr)
|
|
4950
|
-
};
|
|
4951
|
-
if (opts.collapseId) {
|
|
4952
|
-
headers["apns-collapse-id"] = opts.collapseId;
|
|
4953
|
-
}
|
|
4954
|
-
const req = client.request(headers);
|
|
4955
|
-
let statusCode = 0;
|
|
4956
|
-
let responseData = "";
|
|
4957
|
-
req.on("response", (headers2) => {
|
|
4958
|
-
statusCode = Number(headers2[":status"] ?? 0);
|
|
4959
|
-
});
|
|
4960
|
-
req.on("data", (chunk) => {
|
|
4961
|
-
responseData += chunk;
|
|
4962
|
-
});
|
|
4963
|
-
req.on("end", () => {
|
|
4964
|
-
if (statusCode === 200) {
|
|
4965
|
-
resolve();
|
|
4966
|
-
} else {
|
|
4967
|
-
if (statusCode === 0) {
|
|
4968
|
-
const c = this.http2Clients[env];
|
|
4969
|
-
c?.destroy();
|
|
4970
|
-
delete this.http2Clients[env];
|
|
4971
|
-
}
|
|
4972
|
-
reject(new ApnsError(statusCode, responseData));
|
|
4973
|
-
}
|
|
4974
|
-
});
|
|
4975
|
-
req.on("error", (err) => {
|
|
4976
|
-
reject(err);
|
|
4977
|
-
});
|
|
4978
|
-
req.write(payloadStr);
|
|
4979
|
-
req.end();
|
|
4980
|
-
});
|
|
4981
|
-
}
|
|
4982
|
-
/** 生成或获取缓存的 APNs JWT token */
|
|
4983
|
-
getJWT() {
|
|
4984
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
4985
|
-
if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
|
|
4986
|
-
return this.cachedJwt.token;
|
|
4987
|
-
}
|
|
4988
|
-
const header = Buffer.from(JSON.stringify({
|
|
4989
|
-
alg: "ES256",
|
|
4990
|
-
kid: this.keyId
|
|
4991
|
-
})).toString("base64url");
|
|
4992
|
-
const claims = Buffer.from(JSON.stringify({
|
|
4993
|
-
iss: this.teamId,
|
|
4994
|
-
iat: now
|
|
4995
|
-
})).toString("base64url");
|
|
4996
|
-
const signingInput = `${header}.${claims}`;
|
|
4997
|
-
const sign = crypto.createSign("SHA256");
|
|
4998
|
-
sign.update(signingInput);
|
|
4999
|
-
const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
|
|
5000
|
-
const token = `${signingInput}.${signature}`;
|
|
5001
|
-
this.cachedJwt = { token, expiresAt: now + 3e3 };
|
|
5002
|
-
return token;
|
|
5003
|
-
}
|
|
5004
|
-
};
|
|
5005
|
-
var ApnsError = class extends Error {
|
|
5006
|
-
constructor(statusCode, responseBody) {
|
|
5007
|
-
super(`APNs returned ${statusCode}: ${responseBody}`);
|
|
5008
|
-
this.statusCode = statusCode;
|
|
5009
|
-
this.responseBody = responseBody;
|
|
5010
|
-
this.name = "ApnsError";
|
|
5011
|
-
}
|
|
5012
|
-
};
|
|
5013
|
-
function isProviderTokenError(err) {
|
|
5014
|
-
if (!(err instanceof ApnsError)) return false;
|
|
5015
|
-
if (err.statusCode !== 403) return false;
|
|
5016
|
-
try {
|
|
5017
|
-
const parsed = JSON.parse(err.responseBody);
|
|
5018
|
-
return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
|
|
5019
|
-
} catch {
|
|
5020
|
-
return false;
|
|
5021
|
-
}
|
|
5022
|
-
}
|
|
5023
|
-
function isBadDeviceTokenError(err) {
|
|
5024
|
-
if (!(err instanceof ApnsError)) return false;
|
|
5025
|
-
if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
|
|
5026
|
-
try {
|
|
5027
|
-
const parsed = JSON.parse(err.responseBody);
|
|
5028
|
-
return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
|
|
5029
|
-
} catch {
|
|
5030
|
-
return false;
|
|
5031
|
-
}
|
|
5032
|
-
}
|
|
5033
|
-
|
|
5034
|
-
// src/session/ProjectReader.ts
|
|
5035
|
-
var import_promises3 = require("fs/promises");
|
|
5036
|
-
var import_readline3 = require("readline");
|
|
5037
|
-
var import_path2 = require("path");
|
|
5038
|
-
var import_os2 = require("os");
|
|
5039
|
-
var CLAUDE_PROJECTS_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "projects");
|
|
5040
|
-
function getSessionFilePath(projectPath, sessionId) {
|
|
5041
|
-
return (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
|
|
5042
|
-
}
|
|
5043
|
-
async function getProjects() {
|
|
5044
|
-
try {
|
|
5045
|
-
const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
|
|
5046
|
-
if (!dirExists) {
|
|
5047
|
-
return { ok: true, value: [] };
|
|
5048
|
-
}
|
|
5049
|
-
const entries = await (0, import_promises3.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
|
|
5050
|
-
const projects = [];
|
|
5051
|
-
for (const entry of entries) {
|
|
5052
|
-
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
|
5053
|
-
continue;
|
|
5054
|
-
}
|
|
5055
|
-
const encodedPath = entry.name;
|
|
5056
|
-
const decodedPath = decodeDirName(encodedPath);
|
|
5057
|
-
const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
|
|
5058
|
-
const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
5059
|
-
const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
|
|
5060
|
-
projects.push({
|
|
5061
|
-
id: encodedPath,
|
|
5062
|
-
path: decodedPath,
|
|
5063
|
-
name,
|
|
5064
|
-
sessionCount,
|
|
5065
|
-
lastActiveAt: latestMtime
|
|
5066
|
-
});
|
|
5067
|
-
}
|
|
5068
|
-
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
5069
|
-
return { ok: true, value: projects };
|
|
5070
|
-
} catch (err) {
|
|
5071
|
-
return {
|
|
5072
|
-
ok: false,
|
|
5073
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
5074
|
-
};
|
|
5075
|
-
}
|
|
5076
|
-
}
|
|
5077
|
-
async function getHistoricalSessions(projectPath) {
|
|
5078
|
-
try {
|
|
5079
|
-
const encodedPath = encodeDirName(projectPath);
|
|
5080
|
-
const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
5081
|
-
const dirExists = await directoryExists(projectDir);
|
|
5082
|
-
if (!dirExists) {
|
|
5083
|
-
return { ok: true, value: [] };
|
|
5084
|
-
}
|
|
5085
|
-
const entries = await (0, import_promises3.readdir)(projectDir, { withFileTypes: true });
|
|
5086
|
-
const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
5087
|
-
const mtimeMap = /* @__PURE__ */ new Map();
|
|
5088
|
-
await Promise.all(
|
|
5089
|
-
jsonlFiles.map(async (entry) => {
|
|
5090
|
-
const sessionId = entry.name.slice(0, -6);
|
|
5091
|
-
const filePath = (0, import_path2.join)(projectDir, entry.name);
|
|
5092
|
-
try {
|
|
5093
|
-
const contentTs = await extractLastTimestamp(filePath);
|
|
5094
|
-
if (contentTs) {
|
|
5095
|
-
mtimeMap.set(sessionId, contentTs);
|
|
5096
|
-
} else {
|
|
5097
|
-
const fileStat = await (0, import_promises3.stat)(filePath);
|
|
5098
|
-
mtimeMap.set(sessionId, fileStat.mtimeMs);
|
|
5099
|
-
}
|
|
5100
|
-
} catch {
|
|
5101
|
-
mtimeMap.set(sessionId, 0);
|
|
5102
|
-
}
|
|
5103
|
-
})
|
|
5104
|
-
);
|
|
5105
|
-
const uuidDirs = entries.filter(
|
|
5106
|
-
(e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
|
|
5107
|
-
);
|
|
5108
|
-
for (const entry of uuidDirs) {
|
|
5109
|
-
try {
|
|
5110
|
-
const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(projectDir, entry.name));
|
|
5111
|
-
mtimeMap.set(entry.name, fileStat.mtimeMs);
|
|
5112
|
-
} catch {
|
|
5113
|
-
mtimeMap.set(entry.name, 0);
|
|
5114
|
-
}
|
|
5115
|
-
}
|
|
5116
|
-
const indexPath = (0, import_path2.join)(projectDir, "sessions-index.json");
|
|
5117
|
-
const sessionMap = /* @__PURE__ */ new Map();
|
|
5118
|
-
try {
|
|
5119
|
-
const indexContent = await (0, import_promises3.readFile)(indexPath, "utf-8");
|
|
5120
|
-
const indexData = JSON.parse(indexContent);
|
|
5121
|
-
if (indexData.version === 1 && Array.isArray(indexData.entries)) {
|
|
5122
|
-
for (const entry of indexData.entries) {
|
|
5123
|
-
const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
|
|
5124
|
-
sessionMap.set(entry.sessionId, {
|
|
5125
|
-
sessionId: entry.sessionId,
|
|
5126
|
-
lastModified: mtime,
|
|
5127
|
-
summary: entry.summary,
|
|
5128
|
-
firstPrompt: entry.firstPrompt,
|
|
5129
|
-
messageCount: entry.messageCount
|
|
5130
|
-
});
|
|
5131
|
-
}
|
|
5132
|
-
await Promise.all(
|
|
5133
|
-
Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
|
|
5134
|
-
const filePath = (0, import_path2.join)(projectDir, `${s.sessionId}.jsonl`);
|
|
5135
|
-
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
5136
|
-
if (firstPrompt) s.firstPrompt = firstPrompt;
|
|
5137
|
-
})
|
|
5138
|
-
);
|
|
5139
|
-
}
|
|
5140
|
-
} catch {
|
|
5141
|
-
}
|
|
5142
|
-
const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
|
|
5143
|
-
for (const [sessionId, mtime] of mtimeMap) {
|
|
5144
|
-
if (!sessionMap.has(sessionId)) {
|
|
5145
|
-
if (uuidDirSet.has(sessionId)) {
|
|
5146
|
-
sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
|
|
5147
|
-
} else {
|
|
5148
|
-
const filePath = (0, import_path2.join)(projectDir, `${sessionId}.jsonl`);
|
|
5149
|
-
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
5150
|
-
sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
|
|
5151
|
-
}
|
|
5152
|
-
}
|
|
5153
|
-
}
|
|
5154
|
-
const sessions = Array.from(sessionMap.values()).filter((s) => {
|
|
5155
|
-
if (s.messageCount === 0) return false;
|
|
5156
|
-
if (s.messageCount === -1) return true;
|
|
5157
|
-
if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
|
|
5158
|
-
return true;
|
|
5159
|
-
});
|
|
5160
|
-
sessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
5161
|
-
return { ok: true, value: sessions };
|
|
5162
|
-
} catch (err) {
|
|
5163
|
-
return {
|
|
5164
|
-
ok: false,
|
|
5165
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
5166
|
-
};
|
|
5167
|
-
}
|
|
5168
|
-
}
|
|
5169
|
-
async function getSessionHistory(projectPath, sessionId) {
|
|
5170
|
-
try {
|
|
5171
|
-
const encodedPath = encodeDirName(projectPath);
|
|
5172
|
-
const filePath = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
|
|
5173
|
-
const raw = await (0, import_promises3.readFile)(filePath, "utf-8").catch((err) => {
|
|
5174
|
-
if (err.code === "ENOENT") return null;
|
|
5175
|
-
throw err;
|
|
5176
|
-
});
|
|
5177
|
-
if (raw === null) return { ok: true, value: [] };
|
|
5178
|
-
const lines = raw.split("\n").filter((l) => l.trim());
|
|
5179
|
-
const events = [];
|
|
5180
|
-
for (const line of lines) {
|
|
5181
|
-
try {
|
|
5182
|
-
const obj = JSON.parse(line);
|
|
5183
|
-
const type = obj.type;
|
|
5184
|
-
if (type === "user" && obj.message) {
|
|
5185
|
-
const msgContent = obj.message.content;
|
|
5186
|
-
if (typeof msgContent === "string") {
|
|
5187
|
-
if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
|
|
5188
|
-
} else if (Array.isArray(msgContent)) {
|
|
5189
|
-
const hasText = msgContent.some(
|
|
5190
|
-
(b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
|
|
5191
|
-
);
|
|
5192
|
-
if (!hasText) continue;
|
|
5193
|
-
}
|
|
5194
|
-
const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
|
|
5195
|
-
if (normalizedContent.length === 0) continue;
|
|
5196
|
-
events.push({
|
|
5197
|
-
type: "user",
|
|
5198
|
-
message: {
|
|
5199
|
-
...obj.message,
|
|
5200
|
-
content: normalizedContent
|
|
5201
|
-
},
|
|
5202
|
-
session_id: sessionId
|
|
5203
|
-
});
|
|
5204
|
-
} else if (type === "assistant" && obj.message) {
|
|
5205
|
-
const content = (obj.message.content ?? []).filter(
|
|
5206
|
-
(b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
|
|
5207
|
-
);
|
|
5208
|
-
if (content.length === 0) continue;
|
|
5209
|
-
events.push({
|
|
5210
|
-
type: "assistant",
|
|
5211
|
-
message: {
|
|
5212
|
-
id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
|
|
5213
|
-
model: obj.message.model ?? "unknown",
|
|
5214
|
-
role: "assistant",
|
|
5215
|
-
content,
|
|
5216
|
-
stop_reason: obj.message.stop_reason,
|
|
5217
|
-
usage: obj.message.usage
|
|
5218
|
-
},
|
|
5219
|
-
session_id: sessionId
|
|
5220
|
-
});
|
|
5582
|
+
console.error(
|
|
5583
|
+
`[ActivityPushChannel] \u274C APNs Auth Key \u5DF2\u5931\u6548\uFF08${reason}\uFF09\u3002\u8BF7\u5230 Apple Developer \u4E0B\u8F7D\u65B0\u7684 .p8 \u6587\u4EF6\uFF0C\u5E76\u66F4\u65B0 ~/.sessix/apns.json \u4E2D\u7684 keyId \u548C authKeyPath\uFF0C\u7136\u540E\u91CD\u542F\u670D\u52A1\u7AEF\u3002`
|
|
5584
|
+
);
|
|
5585
|
+
throw err;
|
|
5221
5586
|
}
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
}
|
|
5225
|
-
if (events.length > 0) {
|
|
5226
|
-
let totalInputTokens = 0;
|
|
5227
|
-
let totalOutputTokens = 0;
|
|
5228
|
-
for (const ev of events) {
|
|
5229
|
-
if (ev.type === "assistant" && ev.message.usage) {
|
|
5230
|
-
totalInputTokens += ev.message.usage.input_tokens ?? 0;
|
|
5231
|
-
totalOutputTokens += ev.message.usage.output_tokens ?? 0;
|
|
5587
|
+
if (!isBadDeviceTokenError(err)) {
|
|
5588
|
+
throw err;
|
|
5232
5589
|
}
|
|
5233
5590
|
}
|
|
5234
|
-
if (totalInputTokens > 0 || totalOutputTokens > 0) {
|
|
5235
|
-
events.push({
|
|
5236
|
-
type: "result",
|
|
5237
|
-
subtype: "success",
|
|
5238
|
-
is_error: false,
|
|
5239
|
-
duration_ms: 0,
|
|
5240
|
-
num_turns: events.filter((e) => e.type === "user").length,
|
|
5241
|
-
result: "",
|
|
5242
|
-
session_id: sessionId,
|
|
5243
|
-
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
|
|
5244
|
-
});
|
|
5245
|
-
}
|
|
5246
5591
|
}
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
}
|
|
5254
|
-
}
|
|
5255
|
-
async function extractLastTimestamp(filePath) {
|
|
5256
|
-
let fileHandle;
|
|
5257
|
-
try {
|
|
5258
|
-
fileHandle = await (0, import_promises3.open)(filePath, "r");
|
|
5259
|
-
const fileStat = await fileHandle.stat();
|
|
5260
|
-
const readSize = Math.min(fileStat.size, 8192);
|
|
5261
|
-
const buffer = Buffer.alloc(readSize);
|
|
5262
|
-
await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
|
|
5263
|
-
const tail = buffer.toString("utf-8");
|
|
5264
|
-
const lines = tail.split("\n").filter((l) => l.trim());
|
|
5265
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
5266
|
-
try {
|
|
5267
|
-
const obj = JSON.parse(lines[i]);
|
|
5268
|
-
if (obj.timestamp) {
|
|
5269
|
-
const ts = new Date(obj.timestamp).getTime();
|
|
5270
|
-
if (!isNaN(ts)) return ts;
|
|
5271
|
-
}
|
|
5272
|
-
} catch {
|
|
5592
|
+
this.deadTokens.add(deviceToken);
|
|
5593
|
+
for (const [sid, tok] of this.tokens) {
|
|
5594
|
+
if (tok === deviceToken) {
|
|
5595
|
+
this.tokens.delete(sid);
|
|
5596
|
+
console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
|
|
5597
|
+
break;
|
|
5273
5598
|
}
|
|
5274
5599
|
}
|
|
5275
|
-
|
|
5276
|
-
} finally {
|
|
5277
|
-
await fileHandle?.close();
|
|
5600
|
+
throw lastErr ?? new Error("APNs send failed: all environments rejected token");
|
|
5278
5601
|
}
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
const
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
});
|
|
5289
|
-
let lineCount = 0;
|
|
5290
|
-
for await (const line of rl) {
|
|
5291
|
-
if (++lineCount > 20) break;
|
|
5292
|
-
if (!line.trim()) continue;
|
|
5602
|
+
/** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
|
|
5603
|
+
async sendToAPNsOnce(deviceToken, payload, opts, env) {
|
|
5604
|
+
const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
|
|
5605
|
+
const pushType = opts.pushType ?? "liveactivity";
|
|
5606
|
+
const jwt = this.getJWT();
|
|
5607
|
+
const payloadStr = JSON.stringify(payload);
|
|
5608
|
+
const priority = opts.priority ?? "10";
|
|
5609
|
+
return new Promise((resolve, reject) => {
|
|
5610
|
+
let client;
|
|
5293
5611
|
try {
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5612
|
+
client = this.getHttp2Client(env);
|
|
5613
|
+
} catch (err) {
|
|
5614
|
+
return reject(err);
|
|
5615
|
+
}
|
|
5616
|
+
const headers = {
|
|
5617
|
+
":method": "POST",
|
|
5618
|
+
":path": `/3/device/${deviceToken}`,
|
|
5619
|
+
"authorization": `bearer ${jwt}`,
|
|
5620
|
+
"apns-topic": topic,
|
|
5621
|
+
"apns-push-type": pushType,
|
|
5622
|
+
"apns-priority": priority,
|
|
5623
|
+
"apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
|
|
5624
|
+
"content-type": "application/json",
|
|
5625
|
+
"content-length": Buffer.byteLength(payloadStr)
|
|
5626
|
+
};
|
|
5627
|
+
if (opts.collapseId) {
|
|
5628
|
+
headers["apns-collapse-id"] = opts.collapseId;
|
|
5629
|
+
}
|
|
5630
|
+
const req = client.request(headers);
|
|
5631
|
+
req.setTimeout(1e4, () => {
|
|
5632
|
+
req.close(http2.constants.NGHTTP2_CANCEL);
|
|
5633
|
+
});
|
|
5634
|
+
let statusCode = 0;
|
|
5635
|
+
let responseData = "";
|
|
5636
|
+
req.on("response", (headers2) => {
|
|
5637
|
+
statusCode = Number(headers2[":status"] ?? 0);
|
|
5638
|
+
});
|
|
5639
|
+
req.on("data", (chunk) => {
|
|
5640
|
+
responseData += chunk;
|
|
5641
|
+
});
|
|
5642
|
+
req.on("end", () => {
|
|
5643
|
+
if (statusCode === 200) {
|
|
5644
|
+
resolve();
|
|
5645
|
+
} else {
|
|
5646
|
+
if (statusCode === 0) {
|
|
5647
|
+
const c = this.http2Clients[env];
|
|
5648
|
+
c?.destroy();
|
|
5649
|
+
delete this.http2Clients[env];
|
|
5309
5650
|
}
|
|
5651
|
+
reject(new ApnsError(statusCode, responseData));
|
|
5310
5652
|
}
|
|
5311
|
-
}
|
|
5312
|
-
|
|
5653
|
+
});
|
|
5654
|
+
req.on("error", (err) => {
|
|
5655
|
+
reject(err);
|
|
5656
|
+
});
|
|
5657
|
+
req.write(payloadStr);
|
|
5658
|
+
req.end();
|
|
5659
|
+
});
|
|
5660
|
+
}
|
|
5661
|
+
/** 生成或获取缓存的 APNs JWT token */
|
|
5662
|
+
getJWT() {
|
|
5663
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
5664
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
|
|
5665
|
+
return this.cachedJwt.token;
|
|
5313
5666
|
}
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5667
|
+
const header = Buffer.from(JSON.stringify({
|
|
5668
|
+
alg: "ES256",
|
|
5669
|
+
kid: this.keyId
|
|
5670
|
+
})).toString("base64url");
|
|
5671
|
+
const claims = Buffer.from(JSON.stringify({
|
|
5672
|
+
iss: this.teamId,
|
|
5673
|
+
iat: now
|
|
5674
|
+
})).toString("base64url");
|
|
5675
|
+
const signingInput = `${header}.${claims}`;
|
|
5676
|
+
const sign = crypto.createSign("SHA256");
|
|
5677
|
+
sign.update(signingInput);
|
|
5678
|
+
const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
|
|
5679
|
+
const token = `${signingInput}.${signature}`;
|
|
5680
|
+
this.cachedJwt = { token, expiresAt: now + 3e3 };
|
|
5681
|
+
return token;
|
|
5317
5682
|
}
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
}
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
async function directoryExists(dirPath) {
|
|
5683
|
+
};
|
|
5684
|
+
var ApnsError = class extends Error {
|
|
5685
|
+
constructor(statusCode, responseBody) {
|
|
5686
|
+
super(`APNs returned ${statusCode}: ${responseBody}`);
|
|
5687
|
+
this.statusCode = statusCode;
|
|
5688
|
+
this.responseBody = responseBody;
|
|
5689
|
+
this.name = "ApnsError";
|
|
5690
|
+
}
|
|
5691
|
+
};
|
|
5692
|
+
function isProviderTokenError(err) {
|
|
5693
|
+
if (!(err instanceof ApnsError)) return false;
|
|
5694
|
+
if (err.statusCode !== 403) return false;
|
|
5331
5695
|
try {
|
|
5332
|
-
const
|
|
5333
|
-
return
|
|
5696
|
+
const parsed = JSON.parse(err.responseBody);
|
|
5697
|
+
return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
|
|
5334
5698
|
} catch {
|
|
5335
5699
|
return false;
|
|
5336
5700
|
}
|
|
5337
5701
|
}
|
|
5338
|
-
|
|
5339
|
-
|
|
5702
|
+
function isBadDeviceTokenError(err) {
|
|
5703
|
+
if (!(err instanceof ApnsError)) return false;
|
|
5704
|
+
if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
|
|
5340
5705
|
try {
|
|
5341
|
-
const
|
|
5342
|
-
|
|
5343
|
-
entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
|
|
5344
|
-
);
|
|
5345
|
-
const uuidDirs = entries.filter(
|
|
5346
|
-
(e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
|
|
5347
|
-
);
|
|
5348
|
-
let latestMtime = 0;
|
|
5349
|
-
const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
5350
|
-
await Promise.all([
|
|
5351
|
-
...jsonlEntries.map(async (entry) => {
|
|
5352
|
-
try {
|
|
5353
|
-
const contentTs = await extractLastTimestamp((0, import_path2.join)(dirPath, entry.name));
|
|
5354
|
-
const ts = contentTs ?? (await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name))).mtimeMs;
|
|
5355
|
-
if (ts > latestMtime) latestMtime = ts;
|
|
5356
|
-
} catch {
|
|
5357
|
-
}
|
|
5358
|
-
}),
|
|
5359
|
-
...uuidDirs.map(async (entry) => {
|
|
5360
|
-
try {
|
|
5361
|
-
const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name));
|
|
5362
|
-
if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
|
|
5363
|
-
} catch {
|
|
5364
|
-
}
|
|
5365
|
-
})
|
|
5366
|
-
]);
|
|
5367
|
-
return { count: jsonlNames.size + uuidDirs.length, latestMtime };
|
|
5706
|
+
const parsed = JSON.parse(err.responseBody);
|
|
5707
|
+
return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
|
|
5368
5708
|
} catch {
|
|
5369
|
-
return
|
|
5709
|
+
return false;
|
|
5370
5710
|
}
|
|
5371
5711
|
}
|
|
5372
5712
|
|
|
@@ -5492,7 +5832,10 @@ var AuthManager = class extends import_events3.EventEmitter {
|
|
|
5492
5832
|
email: parsed.email,
|
|
5493
5833
|
authMethod: parsed.authMethod
|
|
5494
5834
|
};
|
|
5495
|
-
} catch {
|
|
5835
|
+
} catch (err) {
|
|
5836
|
+
console.warn(
|
|
5837
|
+
`[AuthManager] checkAuth \u5931\u8D25 (claudePath=${CLAUDE_PATH2}): ${err instanceof Error ? err.message : String(err)}`
|
|
5838
|
+
);
|
|
5496
5839
|
return { loggedIn: false };
|
|
5497
5840
|
}
|
|
5498
5841
|
}
|
|
@@ -5588,7 +5931,7 @@ var AuthManager = class extends import_events3.EventEmitter {
|
|
|
5588
5931
|
|
|
5589
5932
|
// src/terminal/TerminalExecutor.ts
|
|
5590
5933
|
var import_node_child_process8 = require("child_process");
|
|
5591
|
-
var
|
|
5934
|
+
var import_uuid4 = require("uuid");
|
|
5592
5935
|
var EXEC_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
5593
5936
|
var TerminalExecutor = class {
|
|
5594
5937
|
processes = /* @__PURE__ */ new Map();
|
|
@@ -5610,7 +5953,7 @@ var TerminalExecutor = class {
|
|
|
5610
5953
|
}
|
|
5611
5954
|
}
|
|
5612
5955
|
exec(sessionId, command, cwd) {
|
|
5613
|
-
const execId = (0,
|
|
5956
|
+
const execId = (0, import_uuid4.v4)();
|
|
5614
5957
|
const shell = isWindows ? "powershell" : process.env.SHELL || "/bin/zsh";
|
|
5615
5958
|
const args = isWindows ? ["-Command", command] : ["-l", "-c", command];
|
|
5616
5959
|
const proc = (0, import_node_child_process8.spawn)(shell, args, {
|
|
@@ -5687,7 +6030,7 @@ var import_node_util = require("util");
|
|
|
5687
6030
|
var import_promises4 = require("fs/promises");
|
|
5688
6031
|
var import_node_path6 = require("path");
|
|
5689
6032
|
var import_node_os7 = require("os");
|
|
5690
|
-
var
|
|
6033
|
+
var import_uuid5 = require("uuid");
|
|
5691
6034
|
var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
|
|
5692
6035
|
var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
5693
6036
|
var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
@@ -5698,7 +6041,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
5698
6041
|
"DerivedData",
|
|
5699
6042
|
"Pods",
|
|
5700
6043
|
".build",
|
|
5701
|
-
"build",
|
|
5702
6044
|
"dist",
|
|
5703
6045
|
"__pycache__",
|
|
5704
6046
|
".next",
|
|
@@ -5875,7 +6217,7 @@ ${e.stderr ?? ""}`);
|
|
|
5875
6217
|
return null;
|
|
5876
6218
|
}
|
|
5877
6219
|
if (override) await this.saveConfig(projectPath, override);
|
|
5878
|
-
const buildId = (0,
|
|
6220
|
+
const buildId = (0, import_uuid5.v4)();
|
|
5879
6221
|
const args = buildArgs(config);
|
|
5880
6222
|
const proc = (0, import_node_child_process9.spawn)("xcodebuild", args, {
|
|
5881
6223
|
cwd: projectPath,
|
|
@@ -5939,7 +6281,7 @@ ${e.stderr ?? ""}`);
|
|
|
5939
6281
|
this.emitInstallError(sessionId, "", "\u672A\u627E\u5230\u6784\u5EFA\u914D\u7F6E\uFF0C\u8BF7\u5148\u6784\u5EFA\u4E00\u6B21\n");
|
|
5940
6282
|
return null;
|
|
5941
6283
|
}
|
|
5942
|
-
const installId = (0,
|
|
6284
|
+
const installId = (0, import_uuid5.v4)();
|
|
5943
6285
|
let appPath;
|
|
5944
6286
|
try {
|
|
5945
6287
|
appPath = await this.getAppPath(projectPath, config);
|
|
@@ -6510,7 +6852,7 @@ function sourceWeight(s) {
|
|
|
6510
6852
|
// src/git/GitExecutor.ts
|
|
6511
6853
|
var import_node_child_process10 = require("child_process");
|
|
6512
6854
|
var import_node_util2 = require("util");
|
|
6513
|
-
var
|
|
6855
|
+
var import_uuid6 = require("uuid");
|
|
6514
6856
|
var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
|
|
6515
6857
|
var STATUS_TIMEOUT_MS = 15e3;
|
|
6516
6858
|
var COMMIT_TIMEOUT_MS = 6e4;
|
|
@@ -6630,7 +6972,7 @@ var GitExecutor = class {
|
|
|
6630
6972
|
* - 若未提供 files:默认 git add -A(提交所有变更)
|
|
6631
6973
|
*/
|
|
6632
6974
|
async commit(sessionId, projectPath, message, files, alsoPush) {
|
|
6633
|
-
const opId = (0,
|
|
6975
|
+
const opId = (0, import_uuid6.v4)();
|
|
6634
6976
|
this.runSequence(sessionId, opId, "commit", projectPath, [
|
|
6635
6977
|
files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
|
|
6636
6978
|
["git", "commit", "-m", message]
|
|
@@ -6646,7 +6988,7 @@ var GitExecutor = class {
|
|
|
6646
6988
|
return opId;
|
|
6647
6989
|
}
|
|
6648
6990
|
async push(sessionId, projectPath) {
|
|
6649
|
-
const opId = (0,
|
|
6991
|
+
const opId = (0, import_uuid6.v4)();
|
|
6650
6992
|
this.runSequence(sessionId, opId, "push", projectPath, [
|
|
6651
6993
|
["git", "push"]
|
|
6652
6994
|
], PUSH_TIMEOUT_MS).catch((err) => {
|
|
@@ -6724,7 +7066,7 @@ var GitExecutor = class {
|
|
|
6724
7066
|
var import_promises6 = require("fs/promises");
|
|
6725
7067
|
var import_node_os8 = require("os");
|
|
6726
7068
|
var import_node_path8 = require("path");
|
|
6727
|
-
var
|
|
7069
|
+
var import_uuid7 = require("uuid");
|
|
6728
7070
|
var MAX_TIMEOUT_MS = 2147483647;
|
|
6729
7071
|
var ScheduledSessionManager = class {
|
|
6730
7072
|
tasks = /* @__PURE__ */ new Map();
|
|
@@ -6762,7 +7104,7 @@ var ScheduledSessionManager = class {
|
|
|
6762
7104
|
/** 注册一个定时任务(payload 由调用方校验) */
|
|
6763
7105
|
schedule(scheduledAt, payload) {
|
|
6764
7106
|
const task = {
|
|
6765
|
-
id: (0,
|
|
7107
|
+
id: (0, import_uuid7.v4)(),
|
|
6766
7108
|
scheduledAt,
|
|
6767
7109
|
createdAt: Date.now(),
|
|
6768
7110
|
payload
|
|
@@ -6866,10 +7208,11 @@ function isValidTask(value) {
|
|
|
6866
7208
|
// src/utils/cliCapabilities.ts
|
|
6867
7209
|
var import_node_child_process11 = require("child_process");
|
|
6868
7210
|
var DEFAULT_MODELS = [
|
|
6869
|
-
{ value: "opus", label: "Opus 4.
|
|
6870
|
-
{ value: "claude-opus-4-
|
|
6871
|
-
{ value: "
|
|
6872
|
-
{ value: "
|
|
7211
|
+
{ value: "opus", label: "Opus 4.8", sublabel: "Most capable for ambitious work", maxEffort: "max", defaultEffort: "xhigh" },
|
|
7212
|
+
{ value: "claude-opus-4-7", label: "Opus 4.7", sublabel: "Previous generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
|
|
7213
|
+
{ value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Earlier generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
|
|
7214
|
+
{ value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks", maxEffort: "high", defaultEffort: "high" },
|
|
7215
|
+
{ value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers", maxEffort: "medium", defaultEffort: "medium" }
|
|
6873
7216
|
];
|
|
6874
7217
|
var DEFAULT_CAPABILITIES = {
|
|
6875
7218
|
effortLevels: ["low", "medium", "high", "xhigh", "max"],
|
|
@@ -7002,7 +7345,7 @@ async function start(opts = {}) {
|
|
|
7002
7345
|
try {
|
|
7003
7346
|
token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
|
|
7004
7347
|
} catch {
|
|
7005
|
-
token = (0,
|
|
7348
|
+
token = (0, import_uuid8.v4)();
|
|
7006
7349
|
await (0, import_promises7.mkdir)(configDir, { recursive: true });
|
|
7007
7350
|
await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
|
|
7008
7351
|
}
|
|
@@ -7037,6 +7380,7 @@ async function start(opts = {}) {
|
|
|
7037
7380
|
const notificationService = new NotificationService(sessionManager, expoChannel);
|
|
7038
7381
|
notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
|
|
7039
7382
|
notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
|
|
7383
|
+
sessionManager.onSessionRemoved((sessionId) => notificationService.releaseSession(sessionId));
|
|
7040
7384
|
const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
|
|
7041
7385
|
if (activityPushOpts) {
|
|
7042
7386
|
try {
|
|
@@ -7194,6 +7538,7 @@ async function start(opts = {}) {
|
|
|
7194
7538
|
case "kill_session": {
|
|
7195
7539
|
wsBridge.broadcast({ type: "status_change", sessionId: event.sessionId, status: "idle" });
|
|
7196
7540
|
approvalProxy.clearPendingForSession(event.sessionId);
|
|
7541
|
+
approvalProxy.clearPendingQuestionsForSession(event.sessionId);
|
|
7197
7542
|
await sessionManager.killSession(event.sessionId);
|
|
7198
7543
|
wsBridge.broadcast({
|
|
7199
7544
|
type: "session_list",
|
|
@@ -7212,6 +7557,7 @@ async function start(opts = {}) {
|
|
|
7212
7557
|
}
|
|
7213
7558
|
case "answer_question": {
|
|
7214
7559
|
sessionManager.handleQuestionResponse(event.requestId, event.answer);
|
|
7560
|
+
approvalProxy.resolveQuestion(event.requestId, event.answer);
|
|
7215
7561
|
break;
|
|
7216
7562
|
}
|
|
7217
7563
|
case "subscribe": {
|
|
@@ -7641,6 +7987,9 @@ async function start(opts = {}) {
|
|
|
7641
7987
|
decision: decision.decision
|
|
7642
7988
|
});
|
|
7643
7989
|
});
|
|
7990
|
+
approvalProxy.onQuestionResolved((requestId) => {
|
|
7991
|
+
sessionManager.clearPendingQuestion(requestId);
|
|
7992
|
+
});
|
|
7644
7993
|
approvalProxy.onApprovalRequest((request) => {
|
|
7645
7994
|
wsBridge.broadcast({ type: "approval_request", request });
|
|
7646
7995
|
setTimeout(() => {
|
|
@@ -7657,6 +8006,9 @@ async function start(opts = {}) {
|
|
|
7657
8006
|
notificationService.notifyApproval(request, pendingCount);
|
|
7658
8007
|
}, 6e4);
|
|
7659
8008
|
});
|
|
8009
|
+
approvalProxy.setQuestionHandler(
|
|
8010
|
+
(sessionId, toolUseId, questions, requestId) => sessionManager.askQuestion(sessionId, toolUseId, questions, requestId)
|
|
8011
|
+
);
|
|
7660
8012
|
sessionManager.onEvent((event) => {
|
|
7661
8013
|
if (event.type !== "question_request") return;
|
|
7662
8014
|
const { request } = event;
|
|
@@ -7717,8 +8069,30 @@ async function start(opts = {}) {
|
|
|
7717
8069
|
const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
|
|
7718
8070
|
const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
|
|
7719
8071
|
const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
|
|
8072
|
+
const sessionEvictMs = Number(process.env.SESSIX_SESSION_EVICT_MS ?? 2 * 60 * 60 * 1e3);
|
|
8073
|
+
let gcFn;
|
|
8074
|
+
const maybeGc = () => {
|
|
8075
|
+
if (gcFn === void 0) {
|
|
8076
|
+
gcFn = globalThis.gc ?? null;
|
|
8077
|
+
if (!gcFn) {
|
|
8078
|
+
try {
|
|
8079
|
+
(0, import_node_v8.setFlagsFromString)("--expose-gc");
|
|
8080
|
+
const fn = (0, import_node_vm.runInNewContext)("gc");
|
|
8081
|
+
gcFn = typeof fn === "function" ? fn : null;
|
|
8082
|
+
} catch {
|
|
8083
|
+
gcFn = null;
|
|
8084
|
+
}
|
|
8085
|
+
}
|
|
8086
|
+
}
|
|
8087
|
+
if (gcFn) {
|
|
8088
|
+
try {
|
|
8089
|
+
gcFn();
|
|
8090
|
+
} catch {
|
|
8091
|
+
}
|
|
8092
|
+
}
|
|
8093
|
+
};
|
|
7720
8094
|
let idleSweepTimer = null;
|
|
7721
|
-
if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
|
|
8095
|
+
if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0 || sessionEvictMs > 0)) {
|
|
7722
8096
|
idleSweepTimer = setInterval(async () => {
|
|
7723
8097
|
try {
|
|
7724
8098
|
let totalSwept = 0;
|
|
@@ -7737,7 +8111,18 @@ async function start(opts = {}) {
|
|
|
7737
8111
|
swept.forEach(broadcastShrink);
|
|
7738
8112
|
totalSwept += swept.length;
|
|
7739
8113
|
}
|
|
8114
|
+
if (sessionEvictMs > 0 && typeof provider.listEvictableSessions === "function") {
|
|
8115
|
+
const evictable = provider.listEvictableSessions(sessionEvictMs);
|
|
8116
|
+
for (const id of evictable) {
|
|
8117
|
+
await sessionManager.killSession(id);
|
|
8118
|
+
}
|
|
8119
|
+
if (evictable.length > 0) {
|
|
8120
|
+
console.log(`[Server] Idle GC: evicted ${evictable.length} stale session(s)`);
|
|
8121
|
+
totalSwept += evictable.length;
|
|
8122
|
+
}
|
|
8123
|
+
}
|
|
7740
8124
|
}
|
|
8125
|
+
const hasRunning = sessionManager.getActiveSessions().some((s) => s.status === "running");
|
|
7741
8126
|
if (totalSwept > 0) {
|
|
7742
8127
|
console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
|
|
7743
8128
|
wsBridge.broadcast({
|
|
@@ -7745,6 +8130,9 @@ async function start(opts = {}) {
|
|
|
7745
8130
|
sessions: sessionManager.getActiveSessions()
|
|
7746
8131
|
});
|
|
7747
8132
|
}
|
|
8133
|
+
if (totalSwept > 0 || !hasRunning) {
|
|
8134
|
+
maybeGc();
|
|
8135
|
+
}
|
|
7748
8136
|
} catch (err) {
|
|
7749
8137
|
console.error("[Server] Idle GC failed:", err);
|
|
7750
8138
|
}
|
|
@@ -7799,7 +8187,7 @@ async function start(opts = {}) {
|
|
|
7799
8187
|
openPairing: (duration) => pairingManager.open(duration),
|
|
7800
8188
|
closePairing: () => pairingManager.close(),
|
|
7801
8189
|
regenerateToken: async () => {
|
|
7802
|
-
const newToken = (0,
|
|
8190
|
+
const newToken = (0, import_uuid8.v4)();
|
|
7803
8191
|
await (0, import_promises7.mkdir)(configDir, { recursive: true });
|
|
7804
8192
|
await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
|
|
7805
8193
|
instance.token = newToken;
|