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/index.js
CHANGED
|
@@ -302,16 +302,18 @@ function t(key, params) {
|
|
|
302
302
|
}
|
|
303
303
|
|
|
304
304
|
// src/server.ts
|
|
305
|
-
var
|
|
305
|
+
var import_uuid8 = require("uuid");
|
|
306
306
|
var import_promises7 = require("fs/promises");
|
|
307
307
|
var import_node_os9 = require("os");
|
|
308
308
|
var import_node_path9 = require("path");
|
|
309
309
|
var import_node_child_process12 = require("child_process");
|
|
310
310
|
var import_node_util3 = require("util");
|
|
311
|
+
var import_node_v8 = require("v8");
|
|
312
|
+
var import_node_vm = require("vm");
|
|
311
313
|
|
|
312
314
|
// src/providers/ProcessProvider.ts
|
|
313
315
|
var import_child_process = require("child_process");
|
|
314
|
-
var
|
|
316
|
+
var import_readline2 = require("readline");
|
|
315
317
|
var import_events = require("events");
|
|
316
318
|
var import_node_os2 = require("os");
|
|
317
319
|
var import_uuid = require("uuid");
|
|
@@ -362,12 +364,60 @@ function isNormalExit(code, signal) {
|
|
|
362
364
|
}
|
|
363
365
|
|
|
364
366
|
// src/utils/claudePath.ts
|
|
367
|
+
function resolveStable(candidate) {
|
|
368
|
+
if (!candidate) return null;
|
|
369
|
+
try {
|
|
370
|
+
const real = (0, import_node_fs.realpathSync)(candidate.trim());
|
|
371
|
+
(0, import_node_fs.accessSync)(real, import_node_fs.constants.X_OK);
|
|
372
|
+
return real;
|
|
373
|
+
} catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function resolveViaLoginShell() {
|
|
378
|
+
if (isWindows) return null;
|
|
379
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
380
|
+
try {
|
|
381
|
+
const out = (0, import_node_child_process2.execSync)(`${shell} -ilc 'command -v claude' 2>/dev/null`, {
|
|
382
|
+
encoding: "utf-8",
|
|
383
|
+
timeout: 8e3
|
|
384
|
+
}).trim().split("\n").filter(Boolean).pop();
|
|
385
|
+
return resolveStable(out);
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function resolveViaFnm() {
|
|
391
|
+
if (isWindows) return null;
|
|
392
|
+
const base = (0, import_node_path.join)((0, import_node_os.homedir)(), ".fnm", "node-versions");
|
|
393
|
+
try {
|
|
394
|
+
const versions = (0, import_node_fs.readdirSync)(base).filter((v) => /^v?\d+\./.test(v)).sort(
|
|
395
|
+
(a, b) => b.localeCompare(a, void 0, { numeric: true, sensitivity: "base" })
|
|
396
|
+
);
|
|
397
|
+
for (const v of versions) {
|
|
398
|
+
const p = resolveStable((0, import_node_path.join)(base, v, "installation", "bin", "claude"));
|
|
399
|
+
if (p) return p;
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
var cached = null;
|
|
365
406
|
function findClaudePath() {
|
|
407
|
+
if (cached) return cached;
|
|
408
|
+
const override = resolveStable(process.env.SESSIX_CLAUDE_PATH);
|
|
409
|
+
if (override) return cached = log(override, "env:SESSIX_CLAUDE_PATH");
|
|
366
410
|
try {
|
|
367
411
|
const cmd = isWindows ? "where claude" : "which claude";
|
|
368
|
-
|
|
412
|
+
const which = (0, import_node_child_process2.execSync)(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
|
|
413
|
+
const stable = resolveStable(which);
|
|
414
|
+
if (stable) return cached = log(stable, "which");
|
|
369
415
|
} catch {
|
|
370
416
|
}
|
|
417
|
+
const fnm = resolveViaFnm();
|
|
418
|
+
if (fnm) return cached = log(fnm, "fnm-scan");
|
|
419
|
+
const viaShell = resolveViaLoginShell();
|
|
420
|
+
if (viaShell) return cached = log(viaShell, "login-shell");
|
|
371
421
|
const candidates = isWindows ? [
|
|
372
422
|
(0, import_node_path.join)(process.env.LOCALAPPDATA ?? "", "Programs", "claude", "claude.exe"),
|
|
373
423
|
(0, import_node_path.join)((0, import_node_os.homedir)(), "AppData", "Local", "Programs", "claude", "claude.exe"),
|
|
@@ -378,631 +428,936 @@ function findClaudePath() {
|
|
|
378
428
|
"/opt/homebrew/bin/claude"
|
|
379
429
|
];
|
|
380
430
|
for (const candidate of candidates) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return candidate;
|
|
384
|
-
} catch {
|
|
385
|
-
}
|
|
431
|
+
const stable = resolveStable(candidate);
|
|
432
|
+
if (stable) return cached = log(stable, "candidate");
|
|
386
433
|
}
|
|
387
|
-
|
|
434
|
+
console.warn(
|
|
435
|
+
"[claudePath] \u672A\u80FD\u5B9A\u4F4D claude\uFF0C\u515C\u5E95\u4F7F\u7528\u88F8 'claude'\uFF08PATH \u4E0D\u542B claude \u65F6\u4F1A\u5931\u8D25\uFF09"
|
|
436
|
+
);
|
|
437
|
+
return cached = "claude";
|
|
438
|
+
}
|
|
439
|
+
function log(path2, via) {
|
|
440
|
+
console.log(`[claudePath] \u89E3\u6790\u5230 claude: ${path2} (via ${via})`);
|
|
441
|
+
return path2;
|
|
388
442
|
}
|
|
389
443
|
|
|
390
|
-
// src/
|
|
391
|
-
var
|
|
392
|
-
var
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const sessionId = existingSessionId ?? (0, import_uuid.v4)();
|
|
408
|
-
if (this.activeSessions.has(sessionId)) {
|
|
409
|
-
await this.killSession(sessionId);
|
|
410
|
-
}
|
|
411
|
-
const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
|
|
412
|
-
const session = {
|
|
413
|
-
id: sessionId,
|
|
414
|
-
projectId,
|
|
415
|
-
projectPath,
|
|
416
|
-
status: "running",
|
|
417
|
-
createdAt: Date.now(),
|
|
418
|
-
lastActiveAt: Date.now(),
|
|
419
|
-
summary: message.slice(0, 80)
|
|
420
|
-
};
|
|
421
|
-
const resume = opts.resume ?? !!existingSessionId;
|
|
422
|
-
const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, model, permissionMode, effort, fallbackModel, maxBudgetUsd);
|
|
423
|
-
this.writeUserMessage(proc, message, sessionId, images);
|
|
424
|
-
session.pid = proc.pid;
|
|
425
|
-
this.activeSessions.set(sessionId, { session, process: proc, model, permissionMode, effort, fallbackModel, maxBudgetUsd });
|
|
426
|
-
proc.on("error", (err) => {
|
|
427
|
-
console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
|
|
428
|
-
this.activeSessions.delete(sessionId);
|
|
429
|
-
const syntheticResult = {
|
|
430
|
-
type: "result",
|
|
431
|
-
subtype: "error",
|
|
432
|
-
result: `Process spawn failed: ${err.message}`,
|
|
433
|
-
session_id: sessionId,
|
|
434
|
-
duration_ms: 0,
|
|
435
|
-
is_error: true,
|
|
436
|
-
num_turns: 0
|
|
437
|
-
};
|
|
438
|
-
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
444
|
+
// src/session/ProjectReader.ts
|
|
445
|
+
var import_promises = require("fs/promises");
|
|
446
|
+
var import_readline = require("readline");
|
|
447
|
+
var import_path = require("path");
|
|
448
|
+
var import_os = require("os");
|
|
449
|
+
var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
|
|
450
|
+
function getSessionFilePath(projectPath, sessionId) {
|
|
451
|
+
return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
|
|
452
|
+
}
|
|
453
|
+
async function getSessionModel(projectPath, sessionId) {
|
|
454
|
+
const filePath = getSessionFilePath(projectPath, sessionId);
|
|
455
|
+
let fileHandle;
|
|
456
|
+
try {
|
|
457
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
458
|
+
const rl = (0, import_readline.createInterface)({
|
|
459
|
+
input: fileHandle.createReadStream({ encoding: "utf-8" }),
|
|
460
|
+
crlfDelay: Infinity
|
|
439
461
|
});
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return session;
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* 终止指定会话
|
|
447
|
-
*
|
|
448
|
-
* kill 进程并从活跃映射中移除。
|
|
449
|
-
*/
|
|
450
|
-
async killSession(sessionId) {
|
|
451
|
-
const entry = this.activeSessions.get(sessionId);
|
|
452
|
-
if (!entry) {
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
462
|
+
let lastModel;
|
|
463
|
+
for await (const line of rl) {
|
|
464
|
+
if (!line.trim()) continue;
|
|
456
465
|
try {
|
|
457
|
-
|
|
466
|
+
const obj = JSON.parse(line);
|
|
467
|
+
if (obj.type !== "assistant" || !obj.message) continue;
|
|
468
|
+
const model = obj.message.model;
|
|
469
|
+
if (typeof model === "string" && model && model !== "unknown") {
|
|
470
|
+
lastModel = model;
|
|
471
|
+
}
|
|
458
472
|
} catch {
|
|
459
473
|
}
|
|
460
|
-
await killProcessCrossPlatform(entry.process);
|
|
461
474
|
}
|
|
462
|
-
|
|
463
|
-
|
|
475
|
+
return lastModel;
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if (err.code === "ENOENT") return void 0;
|
|
478
|
+
throw err;
|
|
479
|
+
} finally {
|
|
480
|
+
await fileHandle?.close();
|
|
464
481
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
async sendMessage(sessionId, message, permissionMode, images) {
|
|
472
|
-
const entry = this.activeSessions.get(sessionId);
|
|
473
|
-
if (!entry) {
|
|
474
|
-
throw new Error(`Session ${sessionId} not found or already ended`);
|
|
482
|
+
}
|
|
483
|
+
async function getProjects() {
|
|
484
|
+
try {
|
|
485
|
+
const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
|
|
486
|
+
if (!dirExists) {
|
|
487
|
+
return { ok: true, value: [] };
|
|
475
488
|
}
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
entry.
|
|
480
|
-
|
|
481
|
-
|
|
489
|
+
const entries = await (0, import_promises.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
|
|
490
|
+
const projects = [];
|
|
491
|
+
for (const entry of entries) {
|
|
492
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const encodedPath = entry.name;
|
|
496
|
+
const decodedPath = decodeDirName(encodedPath);
|
|
497
|
+
const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
|
|
498
|
+
const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
499
|
+
const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
|
|
500
|
+
projects.push({
|
|
501
|
+
id: encodedPath,
|
|
502
|
+
path: decodedPath,
|
|
503
|
+
name,
|
|
504
|
+
sessionCount,
|
|
505
|
+
lastActiveAt: latestMtime
|
|
506
|
+
});
|
|
482
507
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
508
|
+
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
509
|
+
return { ok: true, value: projects };
|
|
510
|
+
} catch (err) {
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function getHistoricalSessions(projectPath) {
|
|
518
|
+
try {
|
|
519
|
+
const encodedPath = encodeDirName(projectPath);
|
|
520
|
+
const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
521
|
+
const dirExists = await directoryExists(projectDir);
|
|
522
|
+
if (!dirExists) {
|
|
523
|
+
return { ok: true, value: [] };
|
|
524
|
+
}
|
|
525
|
+
const entries = await (0, import_promises.readdir)(projectDir, { withFileTypes: true });
|
|
526
|
+
const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
527
|
+
const mtimeMap = /* @__PURE__ */ new Map();
|
|
528
|
+
await Promise.all(
|
|
529
|
+
jsonlFiles.map(async (entry) => {
|
|
530
|
+
const sessionId = entry.name.slice(0, -6);
|
|
531
|
+
const filePath = (0, import_path.join)(projectDir, entry.name);
|
|
486
532
|
try {
|
|
487
|
-
|
|
533
|
+
const contentTs = await extractLastTimestamp(filePath);
|
|
534
|
+
if (contentTs) {
|
|
535
|
+
mtimeMap.set(sessionId, contentTs);
|
|
536
|
+
} else {
|
|
537
|
+
const fileStat = await (0, import_promises.stat)(filePath);
|
|
538
|
+
mtimeMap.set(sessionId, fileStat.mtimeMs);
|
|
539
|
+
}
|
|
488
540
|
} catch {
|
|
541
|
+
mtimeMap.set(sessionId, 0);
|
|
489
542
|
}
|
|
490
|
-
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
const uuidDirs = entries.filter(
|
|
546
|
+
(e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
|
|
547
|
+
);
|
|
548
|
+
for (const entry of uuidDirs) {
|
|
549
|
+
try {
|
|
550
|
+
const fileStat = await (0, import_promises.stat)((0, import_path.join)(projectDir, entry.name));
|
|
551
|
+
mtimeMap.set(entry.name, fileStat.mtimeMs);
|
|
552
|
+
} catch {
|
|
553
|
+
mtimeMap.set(entry.name, 0);
|
|
491
554
|
}
|
|
492
|
-
} else {
|
|
493
|
-
console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
|
|
494
555
|
}
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
556
|
+
const indexPath = (0, import_path.join)(projectDir, "sessions-index.json");
|
|
557
|
+
const sessionMap = /* @__PURE__ */ new Map();
|
|
558
|
+
try {
|
|
559
|
+
const indexContent = await (0, import_promises.readFile)(indexPath, "utf-8");
|
|
560
|
+
const indexData = JSON.parse(indexContent);
|
|
561
|
+
if (indexData.version === 1 && Array.isArray(indexData.entries)) {
|
|
562
|
+
for (const entry of indexData.entries) {
|
|
563
|
+
const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
|
|
564
|
+
sessionMap.set(entry.sessionId, {
|
|
565
|
+
sessionId: entry.sessionId,
|
|
566
|
+
lastModified: mtime,
|
|
567
|
+
summary: entry.summary,
|
|
568
|
+
firstPrompt: entry.firstPrompt,
|
|
569
|
+
messageCount: entry.messageCount
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
await Promise.all(
|
|
573
|
+
Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
|
|
574
|
+
const filePath = (0, import_path.join)(projectDir, `${s.sessionId}.jsonl`);
|
|
575
|
+
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
576
|
+
if (firstPrompt) s.firstPrompt = firstPrompt;
|
|
577
|
+
})
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
}
|
|
582
|
+
const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
|
|
583
|
+
for (const [sessionId, mtime] of mtimeMap) {
|
|
584
|
+
if (!sessionMap.has(sessionId)) {
|
|
585
|
+
if (uuidDirSet.has(sessionId)) {
|
|
586
|
+
sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
|
|
587
|
+
} else {
|
|
588
|
+
const filePath = (0, import_path.join)(projectDir, `${sessionId}.jsonl`);
|
|
589
|
+
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
590
|
+
sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const sessions = Array.from(sessionMap.values()).filter((s) => {
|
|
595
|
+
if (s.messageCount === 0) return false;
|
|
596
|
+
if (s.messageCount === -1) return true;
|
|
597
|
+
if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
|
|
598
|
+
return true;
|
|
518
599
|
});
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
*
|
|
526
|
-
* @returns 取消订阅函数
|
|
527
|
-
*/
|
|
528
|
-
onEvent(sessionId, callback) {
|
|
529
|
-
const eventName = this.getEventName(sessionId);
|
|
530
|
-
this.emitter.on(eventName, callback);
|
|
531
|
-
return () => {
|
|
532
|
-
this.emitter.off(eventName, callback);
|
|
600
|
+
sessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
601
|
+
return { ok: true, value: sessions };
|
|
602
|
+
} catch (err) {
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
533
606
|
};
|
|
534
607
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
|
|
555
|
-
if (entry.session.status !== "idle") continue;
|
|
556
|
-
if (now - entry.session.lastActiveAt < maxIdleMs) continue;
|
|
557
|
-
const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
|
|
558
|
-
console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
|
|
608
|
+
}
|
|
609
|
+
async function getSessionHistory(projectPath, sessionId) {
|
|
610
|
+
let fileHandle;
|
|
611
|
+
try {
|
|
612
|
+
const encodedPath = encodeDirName(projectPath);
|
|
613
|
+
const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
|
|
614
|
+
try {
|
|
615
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (err.code === "ENOENT") return { ok: true, value: [] };
|
|
618
|
+
throw err;
|
|
619
|
+
}
|
|
620
|
+
const rl = (0, import_readline.createInterface)({
|
|
621
|
+
input: fileHandle.createReadStream({ encoding: "utf-8" }),
|
|
622
|
+
crlfDelay: Infinity
|
|
623
|
+
});
|
|
624
|
+
const events = [];
|
|
625
|
+
for await (const line of rl) {
|
|
626
|
+
if (!line.trim()) continue;
|
|
559
627
|
try {
|
|
560
|
-
|
|
628
|
+
const obj = JSON.parse(line);
|
|
629
|
+
const type = obj.type;
|
|
630
|
+
if (type === "user" && obj.message) {
|
|
631
|
+
const msgContent = obj.message.content;
|
|
632
|
+
if (typeof msgContent === "string") {
|
|
633
|
+
if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
|
|
634
|
+
} else if (Array.isArray(msgContent)) {
|
|
635
|
+
const hasText = msgContent.some(
|
|
636
|
+
(b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
|
|
637
|
+
);
|
|
638
|
+
if (!hasText) continue;
|
|
639
|
+
}
|
|
640
|
+
const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
|
|
641
|
+
if (normalizedContent.length === 0) continue;
|
|
642
|
+
events.push({
|
|
643
|
+
type: "user",
|
|
644
|
+
message: {
|
|
645
|
+
...obj.message,
|
|
646
|
+
content: normalizedContent
|
|
647
|
+
},
|
|
648
|
+
session_id: sessionId
|
|
649
|
+
});
|
|
650
|
+
} else if (type === "assistant" && obj.message) {
|
|
651
|
+
const content = (obj.message.content ?? []).filter(
|
|
652
|
+
(b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
|
|
653
|
+
);
|
|
654
|
+
if (content.length === 0) continue;
|
|
655
|
+
events.push({
|
|
656
|
+
type: "assistant",
|
|
657
|
+
message: {
|
|
658
|
+
id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
|
|
659
|
+
model: obj.message.model ?? "unknown",
|
|
660
|
+
role: "assistant",
|
|
661
|
+
content,
|
|
662
|
+
stop_reason: obj.message.stop_reason,
|
|
663
|
+
usage: obj.message.usage
|
|
664
|
+
},
|
|
665
|
+
session_id: sessionId
|
|
666
|
+
});
|
|
667
|
+
}
|
|
561
668
|
} catch {
|
|
562
669
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
670
|
+
}
|
|
671
|
+
if (events.length > 0) {
|
|
672
|
+
let totalInputTokens = 0;
|
|
673
|
+
let totalOutputTokens = 0;
|
|
674
|
+
for (const ev of events) {
|
|
675
|
+
if (ev.type === "assistant" && ev.message.usage) {
|
|
676
|
+
totalInputTokens += ev.message.usage.input_tokens ?? 0;
|
|
677
|
+
totalOutputTokens += ev.message.usage.output_tokens ?? 0;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (totalInputTokens > 0 || totalOutputTokens > 0) {
|
|
681
|
+
events.push({
|
|
682
|
+
type: "result",
|
|
683
|
+
subtype: "success",
|
|
684
|
+
is_error: false,
|
|
685
|
+
duration_ms: 0,
|
|
686
|
+
num_turns: events.filter((e) => e.type === "user").length,
|
|
687
|
+
result: "",
|
|
688
|
+
session_id: sessionId,
|
|
689
|
+
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
|
|
690
|
+
});
|
|
568
691
|
}
|
|
569
|
-
swept.push(sessionId);
|
|
570
692
|
}
|
|
571
|
-
return
|
|
693
|
+
return { ok: true, value: events };
|
|
694
|
+
} catch (err) {
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
698
|
+
};
|
|
699
|
+
} finally {
|
|
700
|
+
await fileHandle?.close();
|
|
572
701
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
const aliveEntries = Array.from(this.activeSessions.entries()).filter(
|
|
586
|
-
([, e]) => e.process.exitCode === null && e.process.signalCode === null
|
|
587
|
-
);
|
|
588
|
-
if (aliveEntries.length <= maxAlive) return swept;
|
|
589
|
-
const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
|
|
590
|
-
let aliveCount = aliveEntries.length;
|
|
591
|
-
for (const [sessionId, entry] of idleSorted) {
|
|
592
|
-
if (aliveCount <= maxAlive) break;
|
|
593
|
-
const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
|
|
594
|
-
console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
|
|
702
|
+
}
|
|
703
|
+
async function extractLastTimestamp(filePath) {
|
|
704
|
+
let fileHandle;
|
|
705
|
+
try {
|
|
706
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
707
|
+
const fileStat = await fileHandle.stat();
|
|
708
|
+
const readSize = Math.min(fileStat.size, 8192);
|
|
709
|
+
const buffer = Buffer.alloc(readSize);
|
|
710
|
+
await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
|
|
711
|
+
const tail = buffer.toString("utf-8");
|
|
712
|
+
const lines = tail.split("\n").filter((l) => l.trim());
|
|
713
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
595
714
|
try {
|
|
596
|
-
|
|
715
|
+
const obj = JSON.parse(lines[i]);
|
|
716
|
+
if (obj.timestamp) {
|
|
717
|
+
const ts = new Date(obj.timestamp).getTime();
|
|
718
|
+
if (!isNaN(ts)) return ts;
|
|
719
|
+
}
|
|
597
720
|
} catch {
|
|
598
721
|
}
|
|
722
|
+
}
|
|
723
|
+
} catch {
|
|
724
|
+
} finally {
|
|
725
|
+
await fileHandle?.close();
|
|
726
|
+
}
|
|
727
|
+
return void 0;
|
|
728
|
+
}
|
|
729
|
+
async function extractFirstPrompt(filePath) {
|
|
730
|
+
let fileHandle;
|
|
731
|
+
try {
|
|
732
|
+
fileHandle = await (0, import_promises.open)(filePath, "r");
|
|
733
|
+
const rl = (0, import_readline.createInterface)({
|
|
734
|
+
input: fileHandle.createReadStream({ encoding: "utf-8" }),
|
|
735
|
+
crlfDelay: Infinity
|
|
736
|
+
});
|
|
737
|
+
let lineCount = 0;
|
|
738
|
+
for await (const line of rl) {
|
|
739
|
+
if (++lineCount > 20) break;
|
|
740
|
+
if (!line.trim()) continue;
|
|
599
741
|
try {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
742
|
+
const obj = JSON.parse(line);
|
|
743
|
+
if (obj.type === "user" && obj.message) {
|
|
744
|
+
const msgContent = obj.message.content;
|
|
745
|
+
let text = "";
|
|
746
|
+
if (typeof msgContent === "string") {
|
|
747
|
+
text = msgContent;
|
|
748
|
+
} else if (Array.isArray(msgContent)) {
|
|
749
|
+
const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
|
|
750
|
+
text = textBlock?.text ?? "";
|
|
751
|
+
}
|
|
752
|
+
if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
|
|
753
|
+
text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
|
|
754
|
+
text = text.replace(/<[^>]+>/g, "").trim();
|
|
755
|
+
rl.close();
|
|
756
|
+
return text.length > 80 ? text.slice(0, 80) + "..." : text;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
} catch {
|
|
605
760
|
}
|
|
606
761
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
return swept;
|
|
762
|
+
} catch {
|
|
763
|
+
} finally {
|
|
764
|
+
await fileHandle?.close();
|
|
611
765
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
766
|
+
return void 0;
|
|
767
|
+
}
|
|
768
|
+
function decodeDirName(dirName) {
|
|
769
|
+
const placeholder = "\0";
|
|
770
|
+
const escaped = dirName.replace(/--/g, placeholder);
|
|
771
|
+
const decoded = escaped.replace(/-/g, "/");
|
|
772
|
+
return decoded.replace(new RegExp(placeholder, "g"), "-");
|
|
773
|
+
}
|
|
774
|
+
function encodeDirName(path2) {
|
|
775
|
+
const escaped = path2.replace(/-/g, "--");
|
|
776
|
+
return escaped.replace(/\//g, "-");
|
|
777
|
+
}
|
|
778
|
+
async function directoryExists(dirPath) {
|
|
779
|
+
try {
|
|
780
|
+
const s = await (0, import_promises.stat)(dirPath);
|
|
781
|
+
return s.isDirectory();
|
|
782
|
+
} catch {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
787
|
+
async function countJsonlFilesWithMtime(dirPath) {
|
|
788
|
+
try {
|
|
789
|
+
const entries = await (0, import_promises.readdir)(dirPath, { withFileTypes: true });
|
|
790
|
+
const jsonlNames = new Set(
|
|
791
|
+
entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
|
|
792
|
+
);
|
|
793
|
+
const uuidDirs = entries.filter(
|
|
794
|
+
(e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
|
|
795
|
+
);
|
|
796
|
+
let latestMtime = 0;
|
|
797
|
+
const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
798
|
+
await Promise.all([
|
|
799
|
+
...jsonlEntries.map(async (entry) => {
|
|
800
|
+
try {
|
|
801
|
+
const contentTs = await extractLastTimestamp((0, import_path.join)(dirPath, entry.name));
|
|
802
|
+
const ts = contentTs ?? (await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name))).mtimeMs;
|
|
803
|
+
if (ts > latestMtime) latestMtime = ts;
|
|
804
|
+
} catch {
|
|
805
|
+
}
|
|
806
|
+
}),
|
|
807
|
+
...uuidDirs.map(async (entry) => {
|
|
808
|
+
try {
|
|
809
|
+
const fileStat = await (0, import_promises.stat)((0, import_path.join)(dirPath, entry.name));
|
|
810
|
+
if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
|
|
811
|
+
} catch {
|
|
812
|
+
}
|
|
813
|
+
})
|
|
814
|
+
]);
|
|
815
|
+
return { count: jsonlNames.size + uuidDirs.length, latestMtime };
|
|
816
|
+
} catch {
|
|
817
|
+
return { count: 0, latestMtime: 0 };
|
|
656
818
|
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/providers/ProcessProvider.ts
|
|
822
|
+
var CLAUDE_PATH = findClaudePath();
|
|
823
|
+
var ProcessProvider = class {
|
|
824
|
+
/** 活跃会话映射表:sessionId -> { session, process } */
|
|
825
|
+
activeSessions = /* @__PURE__ */ new Map();
|
|
826
|
+
/** 事件发射器,用于分发 Claude 事件流 */
|
|
827
|
+
emitter = new import_events.EventEmitter();
|
|
657
828
|
/**
|
|
658
|
-
*
|
|
829
|
+
* 启动新会话或恢复已有会话
|
|
659
830
|
*
|
|
660
|
-
*
|
|
831
|
+
* 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
|
|
832
|
+
* 并开始监听 stdout 的 NDJSON 输出。
|
|
661
833
|
*/
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
for (let i = 0; i < images.length; i++) {
|
|
668
|
-
const img = images[i];
|
|
669
|
-
if (!ALLOWED_TYPES.has(img.media_type)) {
|
|
670
|
-
if (sessionId) {
|
|
671
|
-
this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
|
|
672
|
-
}
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
675
|
-
const sizeBytes = Math.floor(img.data.length * 0.75);
|
|
676
|
-
if (sizeBytes > MAX_IMAGE_BYTES) {
|
|
677
|
-
if (sessionId) {
|
|
678
|
-
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
|
|
679
|
-
this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
|
|
680
|
-
}
|
|
681
|
-
return;
|
|
682
|
-
}
|
|
683
|
-
content.push({
|
|
684
|
-
type: "image",
|
|
685
|
-
source: { type: "base64", media_type: img.media_type, data: img.data }
|
|
686
|
-
});
|
|
687
|
-
}
|
|
834
|
+
async startSession(opts) {
|
|
835
|
+
const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images, fallbackModel, maxBudgetUsd } = opts;
|
|
836
|
+
const sessionId = existingSessionId ?? (0, import_uuid.v4)();
|
|
837
|
+
if (this.activeSessions.has(sessionId)) {
|
|
838
|
+
await this.killSession(sessionId);
|
|
688
839
|
}
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
840
|
+
const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
|
|
841
|
+
const session = {
|
|
842
|
+
id: sessionId,
|
|
843
|
+
projectId,
|
|
844
|
+
projectPath,
|
|
845
|
+
status: "running",
|
|
846
|
+
createdAt: Date.now(),
|
|
847
|
+
lastActiveAt: Date.now(),
|
|
848
|
+
summary: message.slice(0, 80)
|
|
849
|
+
};
|
|
850
|
+
const resume = opts.resume ?? !!existingSessionId;
|
|
851
|
+
let effectiveModel = model;
|
|
852
|
+
if (resume && !effectiveModel) {
|
|
853
|
+
effectiveModel = await getSessionModel(projectPath, sessionId).catch(() => void 0);
|
|
854
|
+
if (effectiveModel) {
|
|
855
|
+
console.log(`[ProcessProvider] Session ${sessionId}: resume restored original model "${effectiveModel}"`);
|
|
700
856
|
}
|
|
701
|
-
return;
|
|
702
857
|
}
|
|
703
|
-
proc.
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
type: "
|
|
858
|
+
const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd);
|
|
859
|
+
this.writeUserMessage(proc, message, sessionId, images);
|
|
860
|
+
session.pid = proc.pid;
|
|
861
|
+
this.activeSessions.set(sessionId, { session, process: proc, model: effectiveModel, permissionMode, effort, fallbackModel, maxBudgetUsd });
|
|
862
|
+
proc.on("error", (err) => {
|
|
863
|
+
console.error(`[ProcessProvider] Session ${sessionId} process error:`, err.message);
|
|
864
|
+
this.activeSessions.delete(sessionId);
|
|
865
|
+
const syntheticResult = {
|
|
866
|
+
type: "result",
|
|
867
|
+
subtype: "error",
|
|
868
|
+
result: `Process spawn failed: ${err.message}`,
|
|
712
869
|
session_id: sessionId,
|
|
713
|
-
|
|
870
|
+
duration_ms: 0,
|
|
871
|
+
is_error: true,
|
|
872
|
+
num_turns: 0
|
|
714
873
|
};
|
|
715
|
-
this.emitter.emit(this.getEventName(sessionId),
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
emitWriteError(sessionId, message) {
|
|
722
|
-
const syntheticResult = {
|
|
723
|
-
type: "result",
|
|
724
|
-
subtype: "error",
|
|
725
|
-
result: message,
|
|
726
|
-
session_id: sessionId,
|
|
727
|
-
duration_ms: 0,
|
|
728
|
-
is_error: true,
|
|
729
|
-
num_turns: 0
|
|
730
|
-
};
|
|
731
|
-
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
874
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
875
|
+
});
|
|
876
|
+
this.attachStdoutListener(sessionId, proc);
|
|
877
|
+
this.attachStderrListener(sessionId, proc);
|
|
878
|
+
this.attachExitListener(sessionId, proc);
|
|
879
|
+
return session;
|
|
732
880
|
}
|
|
733
881
|
/**
|
|
734
|
-
*
|
|
882
|
+
* 终止指定会话
|
|
883
|
+
*
|
|
884
|
+
* kill 进程并从活跃映射中移除。
|
|
735
885
|
*/
|
|
736
|
-
|
|
737
|
-
if (!proc.stdout) {
|
|
738
|
-
console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
const rl = (0, import_readline.createInterface)({
|
|
742
|
-
input: proc.stdout,
|
|
743
|
-
crlfDelay: Infinity
|
|
744
|
-
});
|
|
886
|
+
async killSession(sessionId) {
|
|
745
887
|
const entry = this.activeSessions.get(sessionId);
|
|
746
|
-
if (entry) {
|
|
747
|
-
|
|
888
|
+
if (!entry) {
|
|
889
|
+
return;
|
|
748
890
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (result.ok) {
|
|
754
|
-
const event = result.value;
|
|
755
|
-
if (event.type === "assistant") {
|
|
756
|
-
for (const block of event.message.content) {
|
|
757
|
-
if (block.type === "tool_use") {
|
|
758
|
-
const isQuestion = block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion";
|
|
759
|
-
if (!isQuestion) continue;
|
|
760
|
-
const input = block.input;
|
|
761
|
-
let question = "";
|
|
762
|
-
let options;
|
|
763
|
-
let questions;
|
|
764
|
-
if (typeof input.question === "string") {
|
|
765
|
-
question = input.question;
|
|
766
|
-
options = Array.isArray(input.options) ? input.options : void 0;
|
|
767
|
-
} else if (Array.isArray(input.questions) && input.questions.length > 0) {
|
|
768
|
-
questions = input.questions.map((q) => {
|
|
769
|
-
const item = {
|
|
770
|
-
question: typeof q.question === "string" ? q.question : "",
|
|
771
|
-
header: typeof q.header === "string" ? q.header : void 0,
|
|
772
|
-
multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
|
|
773
|
-
};
|
|
774
|
-
if (Array.isArray(q.options)) {
|
|
775
|
-
item.options = q.options.map((o) => ({
|
|
776
|
-
label: typeof o.label === "string" ? o.label : String(o),
|
|
777
|
-
description: typeof o.description === "string" ? o.description : void 0
|
|
778
|
-
}));
|
|
779
|
-
}
|
|
780
|
-
return item;
|
|
781
|
-
});
|
|
782
|
-
const first = questions[0];
|
|
783
|
-
question = first.question;
|
|
784
|
-
options = first.options?.map((o) => o.label);
|
|
785
|
-
}
|
|
786
|
-
if (!question) continue;
|
|
787
|
-
const prevKey = `${block.id}:${question}:${JSON.stringify(options ?? [])}`;
|
|
788
|
-
let sessionSet = this.emittedQuestionToolUseIds.get(sessionId);
|
|
789
|
-
if (!sessionSet) {
|
|
790
|
-
sessionSet = /* @__PURE__ */ new Set();
|
|
791
|
-
this.emittedQuestionToolUseIds.set(sessionId, sessionSet);
|
|
792
|
-
}
|
|
793
|
-
if (sessionSet.has(prevKey)) continue;
|
|
794
|
-
sessionSet.add(prevKey);
|
|
795
|
-
console.log(`[ProcessProvider] Session ${sessionId}: detected ${block.name} (toolUseId=${block.id})`);
|
|
796
|
-
this.emitter.emit(this.getQuestionEventName(sessionId), {
|
|
797
|
-
toolUseId: block.id,
|
|
798
|
-
question,
|
|
799
|
-
options,
|
|
800
|
-
questions
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
this.updateSessionStatus(sessionId, event);
|
|
806
|
-
this.emitter.emit(this.getEventName(sessionId), event);
|
|
807
|
-
} else {
|
|
808
|
-
console.warn(
|
|
809
|
-
`[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
|
|
810
|
-
);
|
|
811
|
-
}
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
/**
|
|
815
|
-
* 挂载 stderr 监听器,记录日志
|
|
816
|
-
*/
|
|
817
|
-
attachStderrListener(sessionId, proc) {
|
|
818
|
-
if (!proc.stderr) return;
|
|
819
|
-
proc.stderr.on("data", (data) => {
|
|
820
|
-
const text = data.toString().trim();
|
|
821
|
-
if (text) {
|
|
822
|
-
console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
|
|
891
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
892
|
+
try {
|
|
893
|
+
entry.process.stdin?.end();
|
|
894
|
+
} catch {
|
|
823
895
|
}
|
|
824
|
-
|
|
896
|
+
await killProcessCrossPlatform(entry.process);
|
|
897
|
+
}
|
|
898
|
+
this.activeSessions.delete(sessionId);
|
|
825
899
|
}
|
|
826
900
|
/**
|
|
827
|
-
*
|
|
901
|
+
* 向已有会话发送新消息
|
|
828
902
|
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
* updateSessionStatus 会将 session.status 更新为 idle/error。
|
|
832
|
-
* 此时合成事件会重复触发,导致手机端出现两张总结卡。
|
|
833
|
-
* 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
|
|
834
|
-
* 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
|
|
903
|
+
* 快速路径:进程存活时直接写 stdin(毫秒级响应)。
|
|
904
|
+
* 慢速路径:进程已退出时 respawn 并 --resume。
|
|
835
905
|
*/
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
}
|
|
845
|
-
entry.session.pid = void 0;
|
|
906
|
+
async sendMessage(sessionId, message, permissionMode, images) {
|
|
907
|
+
const entry = this.activeSessions.get(sessionId);
|
|
908
|
+
if (!entry) {
|
|
909
|
+
throw new Error(`Session ${sessionId} not found or already ended`);
|
|
910
|
+
}
|
|
911
|
+
const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
|
|
912
|
+
if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
|
|
913
|
+
entry.session.status = "running";
|
|
846
914
|
entry.session.lastActiveAt = Date.now();
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
915
|
+
this.writeUserMessage(entry.process, message, sessionId, images);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (modeChanged) {
|
|
919
|
+
console.log(`[ProcessProvider] Session ${sessionId}: permission mode change ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}, respawn`);
|
|
920
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
921
|
+
try {
|
|
922
|
+
entry.process.stdin?.end();
|
|
923
|
+
} catch {
|
|
924
|
+
}
|
|
925
|
+
killProcessCrossPlatform(entry.process);
|
|
855
926
|
}
|
|
927
|
+
} else {
|
|
928
|
+
console.log(`[ProcessProvider] Session ${sessionId}: process exited, respawning`);
|
|
929
|
+
}
|
|
930
|
+
const newMode = permissionMode ?? entry.permissionMode;
|
|
931
|
+
const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort, entry.fallbackModel, entry.maxBudgetUsd);
|
|
932
|
+
this.writeUserMessage(proc, message, sessionId, images);
|
|
933
|
+
entry.session.status = "running";
|
|
934
|
+
entry.session.lastActiveAt = Date.now();
|
|
935
|
+
entry.session.pid = proc.pid;
|
|
936
|
+
entry.process = proc;
|
|
937
|
+
entry.permissionMode = newMode;
|
|
938
|
+
proc.on("error", (err) => {
|
|
939
|
+
console.error(`[ProcessProvider] Session ${sessionId} sendMessage process error:`, err.message);
|
|
940
|
+
this.activeSessions.delete(sessionId);
|
|
856
941
|
const syntheticResult = {
|
|
857
942
|
type: "result",
|
|
858
|
-
subtype:
|
|
943
|
+
subtype: "error",
|
|
944
|
+
result: `Failed to send message: ${err.message}`,
|
|
859
945
|
session_id: sessionId,
|
|
860
|
-
is_error: !isNormal,
|
|
861
|
-
result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
|
|
862
946
|
duration_ms: 0,
|
|
947
|
+
is_error: true,
|
|
863
948
|
num_turns: 0
|
|
864
949
|
};
|
|
865
950
|
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
866
951
|
});
|
|
952
|
+
this.attachStdoutListener(sessionId, proc);
|
|
953
|
+
this.attachStderrListener(sessionId, proc);
|
|
954
|
+
this.attachExitListener(sessionId, proc);
|
|
867
955
|
}
|
|
868
956
|
/**
|
|
869
|
-
*
|
|
957
|
+
* 订阅指定会话的 Claude 事件流
|
|
958
|
+
*
|
|
959
|
+
* @returns 取消订阅函数
|
|
870
960
|
*/
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
ok: false,
|
|
878
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
879
|
-
};
|
|
880
|
-
}
|
|
961
|
+
onEvent(sessionId, callback) {
|
|
962
|
+
const eventName = this.getEventName(sessionId);
|
|
963
|
+
this.emitter.on(eventName, callback);
|
|
964
|
+
return () => {
|
|
965
|
+
this.emitter.off(eventName, callback);
|
|
966
|
+
};
|
|
881
967
|
}
|
|
882
968
|
/**
|
|
883
|
-
*
|
|
969
|
+
* 获取当前所有活跃会话列表
|
|
884
970
|
*/
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if (!entry) return;
|
|
888
|
-
entry.session.lastActiveAt = Date.now();
|
|
889
|
-
switch (event.type) {
|
|
890
|
-
case "system":
|
|
891
|
-
if (event.subtype === "init") {
|
|
892
|
-
entry.session.status = "running";
|
|
893
|
-
}
|
|
894
|
-
break;
|
|
895
|
-
case "assistant":
|
|
896
|
-
entry.session.status = "running";
|
|
897
|
-
break;
|
|
898
|
-
case "result":
|
|
899
|
-
entry.session.status = event.is_error ? "error" : "idle";
|
|
900
|
-
break;
|
|
901
|
-
}
|
|
971
|
+
getActiveSessions() {
|
|
972
|
+
return Array.from(this.activeSessions.values()).map((entry) => entry.session);
|
|
902
973
|
}
|
|
903
974
|
/**
|
|
904
|
-
*
|
|
975
|
+
* 清理空闲进程
|
|
905
976
|
*
|
|
906
|
-
*
|
|
977
|
+
* 找出所有 status='idle' 且 lastActiveAt 距今超过 maxIdleMs 的活跃进程,
|
|
978
|
+
* kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
|
|
979
|
+
* 走 slow path 自动 --resume 重启进程。
|
|
980
|
+
*
|
|
981
|
+
* @returns 被 sweep 的 sessionId 列表
|
|
907
982
|
*/
|
|
908
|
-
async
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
}
|
|
931
|
-
});
|
|
932
|
-
proc.once("error", reject);
|
|
933
|
-
});
|
|
983
|
+
async sweepIdleProcesses(maxIdleMs) {
|
|
984
|
+
const now = Date.now();
|
|
985
|
+
const swept = [];
|
|
986
|
+
for (const [sessionId, entry] of this.activeSessions) {
|
|
987
|
+
if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
|
|
988
|
+
if (entry.session.status !== "idle") continue;
|
|
989
|
+
if (now - entry.session.lastActiveAt < maxIdleMs) continue;
|
|
990
|
+
const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
|
|
991
|
+
console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
|
|
992
|
+
try {
|
|
993
|
+
entry.process.stdin?.end();
|
|
994
|
+
} catch {
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
await killProcessCrossPlatform(entry.process);
|
|
998
|
+
} catch (err) {
|
|
999
|
+
console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
swept.push(sessionId);
|
|
1003
|
+
}
|
|
1004
|
+
return swept;
|
|
934
1005
|
}
|
|
935
1006
|
/**
|
|
936
|
-
*
|
|
1007
|
+
* LRU 上限清理
|
|
1008
|
+
*
|
|
1009
|
+
* 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
|
|
1010
|
+
* 状态为 idle 的进程,直到活跃数回到上限以内。
|
|
1011
|
+
* running / waiting_question 状态的进程永远不会被 kill。
|
|
937
1012
|
*
|
|
938
|
-
*
|
|
939
|
-
* Claude 收到后继续执行。
|
|
1013
|
+
* @returns 被 sweep 的 sessionId 列表
|
|
940
1014
|
*/
|
|
941
|
-
async
|
|
942
|
-
const
|
|
943
|
-
if (
|
|
944
|
-
|
|
1015
|
+
async sweepLruProcesses(maxAlive) {
|
|
1016
|
+
const swept = [];
|
|
1017
|
+
if (maxAlive <= 0) return swept;
|
|
1018
|
+
const aliveEntries = Array.from(this.activeSessions.entries()).filter(
|
|
1019
|
+
([, e]) => e.process.exitCode === null && e.process.signalCode === null
|
|
1020
|
+
);
|
|
1021
|
+
if (aliveEntries.length <= maxAlive) return swept;
|
|
1022
|
+
const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
|
|
1023
|
+
let aliveCount = aliveEntries.length;
|
|
1024
|
+
for (const [sessionId, entry] of idleSorted) {
|
|
1025
|
+
if (aliveCount <= maxAlive) break;
|
|
1026
|
+
const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
|
|
1027
|
+
console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
|
|
1028
|
+
try {
|
|
1029
|
+
entry.process.stdin?.end();
|
|
1030
|
+
} catch {
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
await killProcessCrossPlatform(entry.process);
|
|
1034
|
+
swept.push(sessionId);
|
|
1035
|
+
aliveCount--;
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
|
|
1038
|
+
}
|
|
945
1039
|
}
|
|
946
|
-
if (
|
|
947
|
-
|
|
1040
|
+
if (aliveCount > maxAlive) {
|
|
1041
|
+
console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
|
|
948
1042
|
}
|
|
949
|
-
|
|
950
|
-
type: "user",
|
|
951
|
-
session_id: "",
|
|
952
|
-
message: {
|
|
953
|
-
role: "user",
|
|
954
|
-
content: [{ type: "tool_result", tool_use_id: toolUseId, content: answer }]
|
|
955
|
-
},
|
|
956
|
-
parent_tool_use_id: toolUseId
|
|
957
|
-
});
|
|
958
|
-
await new Promise((resolve, reject) => {
|
|
959
|
-
entry.process.stdin.write(toolResult + "\n", (err) => {
|
|
960
|
-
if (err) reject(err);
|
|
961
|
-
else resolve();
|
|
962
|
-
});
|
|
963
|
-
});
|
|
964
|
-
console.log(`[ProcessProvider] Session ${sessionId}: AskUserQuestion answered (toolUseId=${toolUseId})`);
|
|
1043
|
+
return swept;
|
|
965
1044
|
}
|
|
966
1045
|
/**
|
|
967
|
-
*
|
|
1046
|
+
* 枚举可淘汰的老会话
|
|
968
1047
|
*
|
|
969
|
-
*
|
|
1048
|
+
* 进程已退出(已被空闲 GC kill)且空闲超过 maxIdleMs 的会话——其 entry 与各 Map
|
|
1049
|
+
* 仍长期占内存。调用方对返回 id 执行 killSession 彻底清除;淘汰后手机端发消息
|
|
1050
|
+
* 会自动走 resume 路径(--resume + JSONL),不影响继续对话。
|
|
1051
|
+
*
|
|
1052
|
+
* @returns 可淘汰的 sessionId 列表(仅枚举,不删除)
|
|
970
1053
|
*/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1054
|
+
listEvictableSessions(maxIdleMs) {
|
|
1055
|
+
if (maxIdleMs <= 0) return [];
|
|
1056
|
+
const now = Date.now();
|
|
1057
|
+
const evictable = [];
|
|
1058
|
+
for (const [sessionId, entry] of this.activeSessions) {
|
|
1059
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) continue;
|
|
1060
|
+
if (entry.session.status === "running" || entry.session.status === "waiting_question" || entry.session.status === "waiting_approval") continue;
|
|
1061
|
+
if (now - entry.session.lastActiveAt < maxIdleMs) continue;
|
|
1062
|
+
evictable.push(sessionId);
|
|
1063
|
+
}
|
|
1064
|
+
return evictable;
|
|
977
1065
|
}
|
|
1066
|
+
// ============================================
|
|
1067
|
+
// 私有方法
|
|
1068
|
+
// ============================================
|
|
978
1069
|
/**
|
|
979
|
-
*
|
|
1070
|
+
* 启动 claude CLI 进程(持久模式,stdin 保持开放接收多条消息)
|
|
980
1071
|
*/
|
|
981
|
-
|
|
982
|
-
|
|
1072
|
+
spawnClaudeProcess(sessionId, projectPath, resume = false, model, permissionMode, effort, fallbackModel, maxBudgetUsd) {
|
|
1073
|
+
const args = [
|
|
1074
|
+
"--input-format",
|
|
1075
|
+
"stream-json",
|
|
1076
|
+
"--output-format",
|
|
1077
|
+
"stream-json",
|
|
1078
|
+
"--verbose",
|
|
1079
|
+
"--include-partial-messages",
|
|
1080
|
+
"--include-hook-events"
|
|
1081
|
+
];
|
|
1082
|
+
if (resume) {
|
|
1083
|
+
args.push("--resume", sessionId);
|
|
1084
|
+
} else {
|
|
1085
|
+
args.push("--session-id", sessionId);
|
|
1086
|
+
}
|
|
1087
|
+
if (model) {
|
|
1088
|
+
args.push("--model", model);
|
|
1089
|
+
}
|
|
1090
|
+
if (permissionMode && permissionMode !== "default") {
|
|
1091
|
+
args.push("--permission-mode", permissionMode);
|
|
1092
|
+
}
|
|
1093
|
+
if (effort) {
|
|
1094
|
+
args.push("--effort", effort);
|
|
1095
|
+
}
|
|
1096
|
+
if (fallbackModel) {
|
|
1097
|
+
args.push("--fallback-model", fallbackModel);
|
|
1098
|
+
}
|
|
1099
|
+
if (maxBudgetUsd != null) {
|
|
1100
|
+
args.push("--max-budget-usd", String(maxBudgetUsd));
|
|
1101
|
+
}
|
|
1102
|
+
const env = { ...process.env, SESSIX_SESSION_ID: sessionId };
|
|
1103
|
+
delete env.CLAUDECODE;
|
|
1104
|
+
const proc = (0, import_child_process.spawn)(CLAUDE_PATH, args, {
|
|
1105
|
+
cwd: projectPath,
|
|
1106
|
+
env,
|
|
1107
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1108
|
+
});
|
|
1109
|
+
return proc;
|
|
983
1110
|
}
|
|
984
1111
|
/**
|
|
985
|
-
*
|
|
1112
|
+
* 向持久进程的 stdin 写入一条用户消息(NDJSON 格式)
|
|
1113
|
+
*
|
|
1114
|
+
* 写入失败时合成 error result 事件,确保 SessionManager 能感知到失败。
|
|
986
1115
|
*/
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1116
|
+
writeUserMessage(proc, message, sessionId, images) {
|
|
1117
|
+
const content = [];
|
|
1118
|
+
if (images?.length) {
|
|
1119
|
+
const ALLOWED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
1120
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
1121
|
+
for (let i = 0; i < images.length; i++) {
|
|
1122
|
+
const img = images[i];
|
|
1123
|
+
if (!ALLOWED_TYPES.has(img.media_type)) {
|
|
1124
|
+
if (sessionId) {
|
|
1125
|
+
this.emitWriteError(sessionId, `Image #${i + 1} rejected: unsupported media_type "${img.media_type}". Only JPEG/PNG/WebP/GIF are accepted.`);
|
|
1126
|
+
}
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const sizeBytes = Math.floor(img.data.length * 0.75);
|
|
1130
|
+
if (sizeBytes > MAX_IMAGE_BYTES) {
|
|
1131
|
+
if (sessionId) {
|
|
1132
|
+
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(1);
|
|
1133
|
+
this.emitWriteError(sessionId, `Image #${i + 1} rejected: ${sizeMb}MB exceeds 5MB per-image limit.`);
|
|
1134
|
+
}
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
content.push({
|
|
1138
|
+
type: "image",
|
|
1139
|
+
source: { type: "base64", media_type: img.media_type, data: img.data }
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
content.push({ type: "text", text: message });
|
|
1144
|
+
const payload = JSON.stringify({
|
|
1145
|
+
type: "user",
|
|
1146
|
+
session_id: "",
|
|
1147
|
+
message: { role: "user", content },
|
|
1148
|
+
parent_tool_use_id: null
|
|
1149
|
+
});
|
|
1150
|
+
if (!proc.stdin || proc.stdin.destroyed) {
|
|
1151
|
+
console.error(`[ProcessProvider] stdin unavailable, message lost`);
|
|
1152
|
+
if (sessionId) {
|
|
1153
|
+
this.emitWriteError(sessionId, "Process stdin closed, message not delivered");
|
|
1154
|
+
}
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
proc.stdin.write(payload + "\n", (err) => {
|
|
1158
|
+
if (err && sessionId) {
|
|
1159
|
+
console.error(`[ProcessProvider] Session ${sessionId} stdin write failed:`, err.message);
|
|
1160
|
+
this.emitWriteError(sessionId, `Failed to send message: ${err.message}`);
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
if (sessionId) {
|
|
1164
|
+
const syntheticUser = {
|
|
1165
|
+
type: "user",
|
|
1166
|
+
session_id: sessionId,
|
|
1167
|
+
message: { role: "user", content }
|
|
1168
|
+
};
|
|
1169
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticUser);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* 发出写入失败的合成错误事件
|
|
1174
|
+
*/
|
|
1175
|
+
emitWriteError(sessionId, message) {
|
|
1176
|
+
const syntheticResult = {
|
|
1177
|
+
type: "result",
|
|
1178
|
+
subtype: "error",
|
|
1179
|
+
result: message,
|
|
1180
|
+
session_id: sessionId,
|
|
1181
|
+
duration_ms: 0,
|
|
1182
|
+
is_error: true,
|
|
1183
|
+
num_turns: 0
|
|
1184
|
+
};
|
|
1185
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* 挂载 stdout 监听器,逐行解析 NDJSON
|
|
1189
|
+
*/
|
|
1190
|
+
attachStdoutListener(sessionId, proc) {
|
|
1191
|
+
if (!proc.stdout) {
|
|
1192
|
+
console.warn(`[ProcessProvider] Session ${sessionId}: stdout unavailable`);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const rl = (0, import_readline2.createInterface)({
|
|
1196
|
+
input: proc.stdout,
|
|
1197
|
+
crlfDelay: Infinity
|
|
1198
|
+
});
|
|
1199
|
+
const entry = this.activeSessions.get(sessionId);
|
|
1200
|
+
if (entry) {
|
|
1201
|
+
entry.rl = rl;
|
|
1202
|
+
}
|
|
1203
|
+
rl.on("line", (line) => {
|
|
1204
|
+
const trimmed = line.trim();
|
|
1205
|
+
if (!trimmed) return;
|
|
1206
|
+
const result = this.parseLine(trimmed);
|
|
1207
|
+
if (result.ok) {
|
|
1208
|
+
const event = result.value;
|
|
1209
|
+
this.updateSessionStatus(sessionId, event);
|
|
1210
|
+
this.emitter.emit(this.getEventName(sessionId), event);
|
|
1211
|
+
} else {
|
|
1212
|
+
console.warn(
|
|
1213
|
+
`[ProcessProvider] Session ${sessionId}: failed to parse line: ${trimmed.substring(0, 100)}`
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* 挂载 stderr 监听器,记录日志
|
|
1220
|
+
*/
|
|
1221
|
+
attachStderrListener(sessionId, proc) {
|
|
1222
|
+
if (!proc.stderr) return;
|
|
1223
|
+
proc.stderr.on("data", (data) => {
|
|
1224
|
+
const text = data.toString().trim();
|
|
1225
|
+
if (text) {
|
|
1226
|
+
console.error(`[ProcessProvider] Session ${sessionId} stderr: ${text}`);
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* 挂载进程退出监听器
|
|
1232
|
+
*
|
|
1233
|
+
* 当进程退出时发出合成的 result 事件,确保 SessionManager 能感知到退出。
|
|
1234
|
+
* 正常退出时 Claude 会先通过 stdout 发送真实 result 事件,
|
|
1235
|
+
* updateSessionStatus 会将 session.status 更新为 idle/error。
|
|
1236
|
+
* 此时合成事件会重复触发,导致手机端出现两张总结卡。
|
|
1237
|
+
* 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
|
|
1238
|
+
* 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
|
|
1239
|
+
*/
|
|
1240
|
+
attachExitListener(sessionId, proc) {
|
|
1241
|
+
proc.once("exit", (code, signal) => {
|
|
1242
|
+
const entry = this.activeSessions.get(sessionId);
|
|
1243
|
+
if (!entry) return;
|
|
1244
|
+
if (entry.process !== proc) return;
|
|
1245
|
+
if (entry.rl) {
|
|
1246
|
+
entry.rl.close();
|
|
1247
|
+
entry.rl = void 0;
|
|
1248
|
+
}
|
|
1249
|
+
entry.session.pid = void 0;
|
|
1250
|
+
entry.session.lastActiveAt = Date.now();
|
|
1251
|
+
const alreadyHasResult = entry.session.status === "idle" || entry.session.status === "error";
|
|
1252
|
+
if (alreadyHasResult) return;
|
|
1253
|
+
const isNormal = isNormalExit(code, signal);
|
|
1254
|
+
entry.session.status = isNormal ? "idle" : "error";
|
|
1255
|
+
if (!isNormal) {
|
|
1256
|
+
console.error(
|
|
1257
|
+
`[ProcessProvider] Session ${sessionId}: process exited abnormally code=${code} signal=${signal}`
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
const syntheticResult = {
|
|
1261
|
+
type: "result",
|
|
1262
|
+
subtype: isNormal ? "success" : "error",
|
|
1263
|
+
session_id: sessionId,
|
|
1264
|
+
is_error: !isNormal,
|
|
1265
|
+
result: isNormal ? "" : `Process exited code=${code} signal=${signal}`,
|
|
1266
|
+
duration_ms: 0,
|
|
1267
|
+
num_turns: 0
|
|
1268
|
+
};
|
|
1269
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* 解析一行 NDJSON 文本为 ClaudeStreamEvent
|
|
1274
|
+
*/
|
|
1275
|
+
parseLine(line) {
|
|
1276
|
+
try {
|
|
1277
|
+
const parsed = JSON.parse(line);
|
|
1278
|
+
return { ok: true, value: parsed };
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
return {
|
|
1281
|
+
ok: false,
|
|
1282
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* 根据 Claude 事件更新会话状态
|
|
1288
|
+
*/
|
|
1289
|
+
updateSessionStatus(sessionId, event) {
|
|
1290
|
+
const entry = this.activeSessions.get(sessionId);
|
|
1291
|
+
if (!entry) return;
|
|
1292
|
+
entry.session.lastActiveAt = Date.now();
|
|
1293
|
+
switch (event.type) {
|
|
1294
|
+
case "system":
|
|
1295
|
+
if (event.subtype === "init") {
|
|
1296
|
+
entry.session.status = "running";
|
|
1297
|
+
}
|
|
1298
|
+
break;
|
|
1299
|
+
case "assistant":
|
|
1300
|
+
entry.session.status = "running";
|
|
1301
|
+
break;
|
|
1302
|
+
case "result":
|
|
1303
|
+
entry.session.status = event.is_error ? "error" : "idle";
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* 根据对话上下文生成下一步建议指令
|
|
1309
|
+
*
|
|
1310
|
+
* 使用 --output-format text 做一次性调用,返回纯文本结果。
|
|
1311
|
+
*/
|
|
1312
|
+
async generateSuggestion(context) {
|
|
1313
|
+
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):
|
|
1314
|
+
|
|
1315
|
+
${context}`;
|
|
1316
|
+
return new Promise((resolve, reject) => {
|
|
1317
|
+
const env = { ...process.env };
|
|
1318
|
+
delete env.CLAUDECODE;
|
|
1319
|
+
const proc = (0, import_child_process.spawn)(CLAUDE_PATH, ["-p", prompt, "--output-format", "text"], {
|
|
1320
|
+
cwd: (0, import_node_os2.homedir)(),
|
|
1321
|
+
env,
|
|
1322
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1323
|
+
});
|
|
1324
|
+
proc.stdin.end();
|
|
1325
|
+
let output = "";
|
|
1326
|
+
proc.stdout?.on("data", (data) => {
|
|
1327
|
+
output += data.toString();
|
|
1328
|
+
});
|
|
1329
|
+
proc.once("exit", (code) => {
|
|
1330
|
+
if (code === 0) {
|
|
1331
|
+
resolve(output.trim());
|
|
1332
|
+
} else {
|
|
1333
|
+
reject(new Error(`generateSuggestion process exit code: ${code}`));
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
proc.once("error", reject);
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* 生成事件名称
|
|
1341
|
+
*/
|
|
1342
|
+
getEventName(sessionId) {
|
|
1343
|
+
return `claude:${sessionId}`;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
// src/providers/CodexProvider.ts
|
|
1348
|
+
var import_child_process2 = require("child_process");
|
|
1349
|
+
var import_readline3 = require("readline");
|
|
1350
|
+
var import_events2 = require("events");
|
|
1351
|
+
var import_fs = require("fs");
|
|
1352
|
+
var import_path2 = require("path");
|
|
1353
|
+
var import_os2 = require("os");
|
|
1354
|
+
var import_uuid2 = require("uuid");
|
|
1355
|
+
|
|
1356
|
+
// src/utils/codexPath.ts
|
|
1357
|
+
var import_node_child_process3 = require("child_process");
|
|
1358
|
+
var import_node_fs2 = require("fs");
|
|
1359
|
+
var import_node_path2 = require("path");
|
|
1360
|
+
var import_node_os3 = require("os");
|
|
1006
1361
|
function findCodexPath() {
|
|
1007
1362
|
try {
|
|
1008
1363
|
const cmd = isWindows ? "where codex" : "which codex";
|
|
@@ -1074,9 +1429,9 @@ function isCodexAvailable() {
|
|
|
1074
1429
|
}
|
|
1075
1430
|
|
|
1076
1431
|
// src/providers/CodexProvider.ts
|
|
1077
|
-
var SESSIX_DIR = (0,
|
|
1078
|
-
var CODEX_SESSIONS_FILE = (0,
|
|
1079
|
-
var CODEX_EVENTS_DIR = (0,
|
|
1432
|
+
var SESSIX_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".sessix");
|
|
1433
|
+
var CODEX_SESSIONS_FILE = (0, import_path2.join)(SESSIX_DIR, "codex-sessions.json");
|
|
1434
|
+
var CODEX_EVENTS_DIR = (0, import_path2.join)(SESSIX_DIR, "codex-events");
|
|
1080
1435
|
var SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1081
1436
|
var CodexProvider = class {
|
|
1082
1437
|
activeSessions = /* @__PURE__ */ new Map();
|
|
@@ -1223,12 +1578,6 @@ var CodexProvider = class {
|
|
|
1223
1578
|
async generateSuggestion(_context) {
|
|
1224
1579
|
return "";
|
|
1225
1580
|
}
|
|
1226
|
-
async answerQuestion(_sessionId, _toolUseId, _answer) {
|
|
1227
|
-
}
|
|
1228
|
-
onQuestion(_sessionId, _callback) {
|
|
1229
|
-
return () => {
|
|
1230
|
-
};
|
|
1231
|
-
}
|
|
1232
1581
|
// ============================================
|
|
1233
1582
|
// 私有方法
|
|
1234
1583
|
// ============================================
|
|
@@ -1280,7 +1629,7 @@ var CodexProvider = class {
|
|
|
1280
1629
|
*/
|
|
1281
1630
|
attachStdoutListener(sessionId, proc) {
|
|
1282
1631
|
if (!proc.stdout) return;
|
|
1283
|
-
const rl = (0,
|
|
1632
|
+
const rl = (0, import_readline3.createInterface)({ input: proc.stdout, crlfDelay: Infinity });
|
|
1284
1633
|
const entry = this.activeSessions.get(sessionId);
|
|
1285
1634
|
if (entry) entry.rl = rl;
|
|
1286
1635
|
rl.on("line", (line) => {
|
|
@@ -1562,9 +1911,9 @@ var CodexProvider = class {
|
|
|
1562
1911
|
* 优先从内存读,miss 时从磁盘加载
|
|
1563
1912
|
*/
|
|
1564
1913
|
getSessionHistory(sessionId) {
|
|
1565
|
-
const
|
|
1566
|
-
if (
|
|
1567
|
-
const filePath = (0,
|
|
1914
|
+
const cached2 = this.sessionEvents.get(sessionId);
|
|
1915
|
+
if (cached2 && cached2.length > 0) return cached2;
|
|
1916
|
+
const filePath = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
|
|
1568
1917
|
try {
|
|
1569
1918
|
if (!(0, import_fs.existsSync)(filePath)) return [];
|
|
1570
1919
|
const data = JSON.parse((0, import_fs.readFileSync)(filePath, "utf-8"));
|
|
@@ -1585,7 +1934,7 @@ var CodexProvider = class {
|
|
|
1585
1934
|
(0, import_fs.mkdirSync)(CODEX_EVENTS_DIR, { recursive: true });
|
|
1586
1935
|
}
|
|
1587
1936
|
(0, import_fs.writeFileSync)(
|
|
1588
|
-
(0,
|
|
1937
|
+
(0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`),
|
|
1589
1938
|
JSON.stringify(events),
|
|
1590
1939
|
"utf-8"
|
|
1591
1940
|
);
|
|
@@ -1610,7 +1959,7 @@ var CodexProvider = class {
|
|
|
1610
1959
|
if (now - m.lastActiveAt > SESSION_EXPIRY_MS) {
|
|
1611
1960
|
expiredCount++;
|
|
1612
1961
|
try {
|
|
1613
|
-
const eventsFile = (0,
|
|
1962
|
+
const eventsFile = (0, import_path2.join)(CODEX_EVENTS_DIR, `${sessionId}.json`);
|
|
1614
1963
|
if ((0, import_fs.existsSync)(eventsFile)) (0, import_fs.unlinkSync)(eventsFile);
|
|
1615
1964
|
} catch {
|
|
1616
1965
|
}
|
|
@@ -1723,7 +2072,6 @@ var ProviderFactory = class {
|
|
|
1723
2072
|
};
|
|
1724
2073
|
|
|
1725
2074
|
// src/session/SessionManager.ts
|
|
1726
|
-
var import_uuid3 = require("uuid");
|
|
1727
2075
|
var BUFFER_MAX = 5e3;
|
|
1728
2076
|
var SessionManager = class {
|
|
1729
2077
|
provider;
|
|
@@ -1731,10 +2079,18 @@ var SessionManager = class {
|
|
|
1731
2079
|
sessionAgentType = /* @__PURE__ */ new Map();
|
|
1732
2080
|
/** 事件回调列表(事件会被转发到 WsBridge) */
|
|
1733
2081
|
eventCallbacks = [];
|
|
2082
|
+
/** 会话被移除(kill / 淘汰)时的回调列表(用于释放外部模块的会话级状态,如 NotificationService) */
|
|
2083
|
+
sessionRemovedCallbacks = [];
|
|
1734
2084
|
/** 每个会话的事件流取消订阅函数 */
|
|
1735
2085
|
unsubscribeMap = /* @__PURE__ */ new Map();
|
|
1736
2086
|
/** 每个会话的事件缓冲区(用于新订阅者重放)*/
|
|
1737
2087
|
sessionEventBuffers = /* @__PURE__ */ new Map();
|
|
2088
|
+
/**
|
|
2089
|
+
* 每个会话最近一次 AskUserQuestion tool_use 的真实 id(从 claude_event 流捕获)。
|
|
2090
|
+
* PreToolUse hook payload 不含 tool_use_id,但内联卡片需要它来匹配状态,
|
|
2091
|
+
* 故在转发流事件时记录,askQuestion 时兜底回填。
|
|
2092
|
+
*/
|
|
2093
|
+
lastAskQuestionToolUseId = /* @__PURE__ */ new Map();
|
|
1738
2094
|
/** AskUserQuestion 问题映射:requestId → resolve 回调 + 原始问题内容 */
|
|
1739
2095
|
pendingQuestions = /* @__PURE__ */ new Map();
|
|
1740
2096
|
/**
|
|
@@ -1843,6 +2199,7 @@ var SessionManager = class {
|
|
|
1843
2199
|
this.bufferTruncated.delete(sessionId);
|
|
1844
2200
|
this.sessionProjectPaths.delete(sessionId);
|
|
1845
2201
|
this.sessionStats.delete(sessionId);
|
|
2202
|
+
this.lastAskQuestionToolUseId.delete(sessionId);
|
|
1846
2203
|
const pending = this.pendingAssistantEvents.get(sessionId);
|
|
1847
2204
|
if (pending) {
|
|
1848
2205
|
clearTimeout(pending.timer);
|
|
@@ -1851,6 +2208,13 @@ var SessionManager = class {
|
|
|
1851
2208
|
const provider = this.getProviderForSession(sessionId);
|
|
1852
2209
|
await provider.killSession(sessionId);
|
|
1853
2210
|
this.sessionAgentType.delete(sessionId);
|
|
2211
|
+
for (const cb of this.sessionRemovedCallbacks) {
|
|
2212
|
+
try {
|
|
2213
|
+
cb(sessionId);
|
|
2214
|
+
} catch (err) {
|
|
2215
|
+
console.error("[SessionManager] sessionRemoved callback failed:", err);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
1854
2218
|
console.log(`[SessionManager] Session killed: ${sessionId}`);
|
|
1855
2219
|
}
|
|
1856
2220
|
/**
|
|
@@ -1903,7 +2267,22 @@ var SessionManager = class {
|
|
|
1903
2267
|
}
|
|
1904
2268
|
}
|
|
1905
2269
|
/**
|
|
1906
|
-
*
|
|
2270
|
+
* 幂等清理单个待回答问题(由 ApprovalProxy onQuestionResolved 触发:
|
|
2271
|
+
* 答案到达 / 325s 超时 / 会话 kill / 服务关闭)。
|
|
2272
|
+
* 已不存在则静默返回(不打 warn,与 handleQuestionResponse 区分)。
|
|
2273
|
+
*/
|
|
2274
|
+
clearPendingQuestion(requestId) {
|
|
2275
|
+
const pending = this.pendingQuestions.get(requestId);
|
|
2276
|
+
if (!pending) return;
|
|
2277
|
+
const { sessionId } = pending;
|
|
2278
|
+
this.pendingQuestions.delete(requestId);
|
|
2279
|
+
pending.resolve("");
|
|
2280
|
+
if (!this.hasPendingQuestionsForSession(sessionId)) {
|
|
2281
|
+
this.updateSessionStatus(sessionId, "running");
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* 获取指定会话的所有待回答问题(用于重连时恢复)
|
|
1907
2286
|
*/
|
|
1908
2287
|
getPendingQuestionsForSession(sessionId) {
|
|
1909
2288
|
const result = [];
|
|
@@ -2026,6 +2405,21 @@ var SessionManager = class {
|
|
|
2026
2405
|
}
|
|
2027
2406
|
};
|
|
2028
2407
|
}
|
|
2408
|
+
/**
|
|
2409
|
+
* 注册"会话被移除"回调(会话 kill 或淘汰时触发,传入 sessionId)。
|
|
2410
|
+
* 用于让外部模块释放会话级状态,如 NotificationService.releaseSession。
|
|
2411
|
+
*
|
|
2412
|
+
* @returns 取消注册的函数
|
|
2413
|
+
*/
|
|
2414
|
+
onSessionRemoved(callback) {
|
|
2415
|
+
this.sessionRemovedCallbacks.push(callback);
|
|
2416
|
+
return () => {
|
|
2417
|
+
const index = this.sessionRemovedCallbacks.indexOf(callback);
|
|
2418
|
+
if (index !== -1) {
|
|
2419
|
+
this.sessionRemovedCallbacks.splice(index, 1);
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2029
2423
|
/**
|
|
2030
2424
|
* 清理所有资源
|
|
2031
2425
|
*/
|
|
@@ -2043,31 +2437,28 @@ var SessionManager = class {
|
|
|
2043
2437
|
clearTimeout(pending.timer);
|
|
2044
2438
|
}
|
|
2045
2439
|
this.pendingAssistantEvents.clear();
|
|
2440
|
+
for (const pending of this.pendingQuestions.values()) {
|
|
2441
|
+
pending.resolve("");
|
|
2442
|
+
}
|
|
2046
2443
|
this.pendingQuestions.clear();
|
|
2047
2444
|
this.lastBroadcastStatus.clear();
|
|
2048
2445
|
this.eventCallbacks.length = 0;
|
|
2446
|
+
this.sessionRemovedCallbacks.length = 0;
|
|
2049
2447
|
console.log("[SessionManager] Destroyed");
|
|
2050
2448
|
}
|
|
2051
2449
|
// ============================================
|
|
2052
2450
|
// 内部方法
|
|
2053
2451
|
// ============================================
|
|
2054
2452
|
/**
|
|
2055
|
-
*
|
|
2453
|
+
* 订阅指定会话的事件流(AskUserQuestion 已改由 ApprovalProxy hook 驱动)
|
|
2056
2454
|
*/
|
|
2057
2455
|
subscribeToSession(sessionId) {
|
|
2058
2456
|
const provider = this.getProviderForSession(sessionId);
|
|
2059
2457
|
const unsubscribeEvent = provider.onEvent(sessionId, (event) => {
|
|
2060
2458
|
this.handleClaudeEvent(sessionId, event);
|
|
2061
2459
|
});
|
|
2062
|
-
const unsubscribeQuestion = provider.onQuestion(
|
|
2063
|
-
sessionId,
|
|
2064
|
-
({ toolUseId, question, options, questions }) => {
|
|
2065
|
-
this.handleAskUserQuestion(sessionId, toolUseId, question, options, questions);
|
|
2066
|
-
}
|
|
2067
|
-
);
|
|
2068
2460
|
this.unsubscribeMap.set(sessionId, () => {
|
|
2069
2461
|
unsubscribeEvent();
|
|
2070
|
-
unsubscribeQuestion();
|
|
2071
2462
|
});
|
|
2072
2463
|
}
|
|
2073
2464
|
/**
|
|
@@ -2101,6 +2492,13 @@ var SessionManager = class {
|
|
|
2101
2492
|
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(",")}`);
|
|
2102
2493
|
}
|
|
2103
2494
|
}
|
|
2495
|
+
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
2496
|
+
for (const block of event.message.content) {
|
|
2497
|
+
if (block.type === "tool_use" && (block.name === "AskUserQuestion" || block.name === "AskFollowupQuestion") && typeof block.id === "string") {
|
|
2498
|
+
this.lastAskQuestionToolUseId.set(sessionId, block.id);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2104
2502
|
switch (event.type) {
|
|
2105
2503
|
case "assistant":
|
|
2106
2504
|
this.bufferAssistantEvent(sessionId, event);
|
|
@@ -2216,55 +2614,35 @@ var SessionManager = class {
|
|
|
2216
2614
|
return runningStartedAt ? { ...base, runningStartedAt } : base;
|
|
2217
2615
|
}
|
|
2218
2616
|
/**
|
|
2219
|
-
*
|
|
2617
|
+
* 由 ApprovalProxy 在 PreToolUse hook 拦截到 AskUserQuestion 时调用。
|
|
2618
|
+
* 登记 pendingQuestion、广播 question_request、置 waiting_question,
|
|
2619
|
+
* 返回的 Promise 在 handleQuestionResponse 时 resolve。
|
|
2220
2620
|
*/
|
|
2221
|
-
|
|
2222
|
-
const
|
|
2223
|
-
([, v]) => v.toolUseId === toolUseId
|
|
2224
|
-
);
|
|
2225
|
-
if (existingEntry) {
|
|
2226
|
-
const [existingRequestId, existingPending] = existingEntry;
|
|
2227
|
-
existingPending.question = question;
|
|
2228
|
-
existingPending.options = options;
|
|
2229
|
-
existingPending.questions = questions;
|
|
2230
|
-
existingPending.createdAt = Date.now();
|
|
2231
|
-
const updatedRequest = {
|
|
2232
|
-
id: existingRequestId,
|
|
2233
|
-
sessionId,
|
|
2234
|
-
toolUseId,
|
|
2235
|
-
question,
|
|
2236
|
-
options,
|
|
2237
|
-
questions,
|
|
2238
|
-
createdAt: existingPending.createdAt
|
|
2239
|
-
};
|
|
2240
|
-
this.emit({ type: "question_request", request: updatedRequest });
|
|
2241
|
-
console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion updated (requestId=${existingRequestId})`);
|
|
2242
|
-
return;
|
|
2243
|
-
}
|
|
2244
|
-
const requestId = (0, import_uuid3.v4)();
|
|
2621
|
+
askQuestion(sessionId, toolUseId, questions, requestId) {
|
|
2622
|
+
const resolvedToolUseId = toolUseId || this.lastAskQuestionToolUseId.get(sessionId) || "";
|
|
2245
2623
|
const request = {
|
|
2246
2624
|
id: requestId,
|
|
2247
2625
|
sessionId,
|
|
2248
|
-
toolUseId,
|
|
2249
|
-
question,
|
|
2250
|
-
options,
|
|
2626
|
+
toolUseId: resolvedToolUseId,
|
|
2627
|
+
question: questions[0]?.question ?? "",
|
|
2628
|
+
options: questions[0]?.options?.map((o) => o.label),
|
|
2251
2629
|
questions,
|
|
2252
2630
|
createdAt: Date.now()
|
|
2253
2631
|
};
|
|
2254
2632
|
this.updateSessionStatus(sessionId, "waiting_question");
|
|
2255
2633
|
this.emit({ type: "question_request", request });
|
|
2256
|
-
const answerPromise = new Promise((resolve) => {
|
|
2257
|
-
this.pendingQuestions.set(requestId, { sessionId, toolUseId, question, options, questions, createdAt: request.createdAt, resolve });
|
|
2258
|
-
});
|
|
2259
|
-
answerPromise.then(async (answer) => {
|
|
2260
|
-
try {
|
|
2261
|
-
const provider = this.getProviderForSession(sessionId);
|
|
2262
|
-
await provider.answerQuestion(sessionId, toolUseId, answer);
|
|
2263
|
-
} catch (err) {
|
|
2264
|
-
console.error(`[SessionManager] answerQuestion failed (${sessionId}):`, err);
|
|
2265
|
-
}
|
|
2266
|
-
}).catch((err) => console.error("[SessionManager] answerPromise rejected:", err));
|
|
2267
2634
|
console.log(`[SessionManager] Session ${sessionId}: AskUserQuestion pushed (requestId=${requestId})`);
|
|
2635
|
+
return new Promise((resolve) => {
|
|
2636
|
+
this.pendingQuestions.set(requestId, {
|
|
2637
|
+
sessionId,
|
|
2638
|
+
toolUseId: resolvedToolUseId,
|
|
2639
|
+
question: request.question,
|
|
2640
|
+
options: request.options,
|
|
2641
|
+
questions,
|
|
2642
|
+
createdAt: request.createdAt,
|
|
2643
|
+
resolve
|
|
2644
|
+
});
|
|
2645
|
+
});
|
|
2268
2646
|
}
|
|
2269
2647
|
/**
|
|
2270
2648
|
* 清除指定会话的所有待回答问题
|
|
@@ -2277,6 +2655,7 @@ var SessionManager = class {
|
|
|
2277
2655
|
}
|
|
2278
2656
|
}
|
|
2279
2657
|
for (const requestId of toRemove) {
|
|
2658
|
+
this.pendingQuestions.get(requestId)?.resolve("");
|
|
2280
2659
|
this.pendingQuestions.delete(requestId);
|
|
2281
2660
|
}
|
|
2282
2661
|
}
|
|
@@ -2296,7 +2675,7 @@ var SessionManager = class {
|
|
|
2296
2675
|
|
|
2297
2676
|
// src/session/SessionFileWatcher.ts
|
|
2298
2677
|
var import_chokidar = __toESM(require("chokidar"));
|
|
2299
|
-
var
|
|
2678
|
+
var import_promises2 = require("fs/promises");
|
|
2300
2679
|
var import_node_readline = require("readline");
|
|
2301
2680
|
var SessionFileWatcher = class {
|
|
2302
2681
|
watchers = /* @__PURE__ */ new Map();
|
|
@@ -2375,7 +2754,7 @@ var SessionFileWatcher = class {
|
|
|
2375
2754
|
let fileHandle;
|
|
2376
2755
|
let rl;
|
|
2377
2756
|
try {
|
|
2378
|
-
fileHandle = await (0,
|
|
2757
|
+
fileHandle = await (0, import_promises2.open)(entry.filePath, "r");
|
|
2379
2758
|
const fileStat = await fileHandle.stat();
|
|
2380
2759
|
const newSize = fileStat.size;
|
|
2381
2760
|
if (newSize <= entry.byteOffset) return;
|
|
@@ -2685,7 +3064,7 @@ var import_node_http = __toESM(require("http"));
|
|
|
2685
3064
|
var import_node_fs3 = __toESM(require("fs"));
|
|
2686
3065
|
var import_node_path3 = __toESM(require("path"));
|
|
2687
3066
|
var import_node_os4 = __toESM(require("os"));
|
|
2688
|
-
var
|
|
3067
|
+
var import_uuid3 = require("uuid");
|
|
2689
3068
|
var ApprovalProxy = class _ApprovalProxy {
|
|
2690
3069
|
server;
|
|
2691
3070
|
token;
|
|
@@ -2693,10 +3072,16 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2693
3072
|
settingsPath = import_node_path3.default.join(import_node_os4.default.homedir(), ".claude", "settings.json");
|
|
2694
3073
|
/** 待处理的审批请求:requestId -> { resolve, timer, request } */
|
|
2695
3074
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
3075
|
+
/** 待回答的 AskUserQuestion:requestId -> { resolve, timer, request } */
|
|
3076
|
+
pendingQuestions = /* @__PURE__ */ new Map();
|
|
3077
|
+
/** 由外部注入:把问题推给手机并等待答案(返回用户答案文本) */
|
|
3078
|
+
questionHandler = null;
|
|
2696
3079
|
/** 审批请求回调(通知外部推送到手机) */
|
|
2697
3080
|
approvalRequestCallbacks = [];
|
|
2698
3081
|
/** 审批 resolve 回调(任何来源的 resolve 都会触发,用于 WS 广播清理 */
|
|
2699
3082
|
approvalResolvedCallbacks = [];
|
|
3083
|
+
/** 问题 resolve 回调(任何来源的 resolve 都会触发,用于 SessionManager 清理) */
|
|
3084
|
+
questionResolvedCallbacks = [];
|
|
2700
3085
|
/** Hook 通知回调(PreCompact / PermissionDenied / 等非阻塞 hook) */
|
|
2701
3086
|
notifyCallbacks = [];
|
|
2702
3087
|
/** YOLO 模式状态:sessionId -> enabled */
|
|
@@ -2760,6 +3145,30 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2760
3145
|
}
|
|
2761
3146
|
}
|
|
2762
3147
|
}
|
|
3148
|
+
/**
|
|
3149
|
+
* 注册问题 resolve 回调
|
|
3150
|
+
*
|
|
3151
|
+
* 任何来源的 resolve 都会触发:
|
|
3152
|
+
* - resolveQuestion(手机端答案到达)
|
|
3153
|
+
* - 325s 超时自动空答案
|
|
3154
|
+
* - clearPendingQuestionsForSession(会话被 kill)
|
|
3155
|
+
* - close()(服务关闭)
|
|
3156
|
+
*
|
|
3157
|
+
* 用于通知 SessionManager 清理 pendingQuestions,避免会话卡在 waiting_question。
|
|
3158
|
+
*/
|
|
3159
|
+
onQuestionResolved(callback) {
|
|
3160
|
+
this.questionResolvedCallbacks.push(callback);
|
|
3161
|
+
}
|
|
3162
|
+
/** 通知所有问题 resolve 回调(内部调用) */
|
|
3163
|
+
notifyQuestionResolved(requestId) {
|
|
3164
|
+
for (const cb of this.questionResolvedCallbacks) {
|
|
3165
|
+
try {
|
|
3166
|
+
cb(requestId);
|
|
3167
|
+
} catch (err) {
|
|
3168
|
+
console.error("[ApprovalProxy] question resolved callback error:", err);
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
2763
3172
|
/**
|
|
2764
3173
|
* 注册非阻塞 hook 通知回调(如 PreCompact、PermissionDenied)
|
|
2765
3174
|
*
|
|
@@ -2814,6 +3223,42 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2814
3223
|
this.notifyApprovalResolved(requestId, decision);
|
|
2815
3224
|
return true;
|
|
2816
3225
|
}
|
|
3226
|
+
/** 注入问题处理器(server.ts 接到 SessionManager.askQuestion) */
|
|
3227
|
+
setQuestionHandler(handler) {
|
|
3228
|
+
this.questionHandler = handler;
|
|
3229
|
+
}
|
|
3230
|
+
/** 解析一个待回答问题(手机端答案到达时由 server.ts 调用) */
|
|
3231
|
+
resolveQuestion(requestId, answer) {
|
|
3232
|
+
const pending = this.pendingQuestions.get(requestId);
|
|
3233
|
+
if (!pending) {
|
|
3234
|
+
console.warn(`[ApprovalProxy] Question request not found: ${requestId}`);
|
|
3235
|
+
return false;
|
|
3236
|
+
}
|
|
3237
|
+
clearTimeout(pending.timer);
|
|
3238
|
+
pending.resolve(answer);
|
|
3239
|
+
this.pendingQuestions.delete(requestId);
|
|
3240
|
+
console.log(`[ApprovalProxy] Question answered: ${requestId}`);
|
|
3241
|
+
this.notifyQuestionResolved(requestId);
|
|
3242
|
+
return true;
|
|
3243
|
+
}
|
|
3244
|
+
/** 清理会话的待回答问题(会话被 kill 时,给空答案让 hook 不再阻塞) */
|
|
3245
|
+
clearPendingQuestionsForSession(sessionId) {
|
|
3246
|
+
const toRemove = [];
|
|
3247
|
+
for (const [requestId, pending] of this.pendingQuestions) {
|
|
3248
|
+
if (pending.request.sessionId === sessionId) {
|
|
3249
|
+
toRemove.push(requestId);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
for (const requestId of toRemove) {
|
|
3253
|
+
const pending = this.pendingQuestions.get(requestId);
|
|
3254
|
+
if (!pending) continue;
|
|
3255
|
+
clearTimeout(pending.timer);
|
|
3256
|
+
pending.resolve("");
|
|
3257
|
+
this.pendingQuestions.delete(requestId);
|
|
3258
|
+
console.log(`[ApprovalProxy] Session ${sessionId} killed, cleared pending question ${requestId}`);
|
|
3259
|
+
this.notifyQuestionResolved(requestId);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
2817
3262
|
/** 获取当前待处理的审批数量 */
|
|
2818
3263
|
getPendingCount() {
|
|
2819
3264
|
return this.pendingApprovals.size;
|
|
@@ -2935,6 +3380,13 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2935
3380
|
pending.resolve({ decision: "deny", reason: t("approval.serverClosed") });
|
|
2936
3381
|
}
|
|
2937
3382
|
this.pendingApprovals.clear();
|
|
3383
|
+
const pendingQuestionEntries = Array.from(this.pendingQuestions.entries());
|
|
3384
|
+
for (const [requestId, pending] of pendingQuestionEntries) {
|
|
3385
|
+
clearTimeout(pending.timer);
|
|
3386
|
+
pending.resolve("");
|
|
3387
|
+
this.notifyQuestionResolved(requestId);
|
|
3388
|
+
}
|
|
3389
|
+
this.pendingQuestions.clear();
|
|
2938
3390
|
this.server.close((err) => {
|
|
2939
3391
|
if (err) {
|
|
2940
3392
|
reject(err);
|
|
@@ -2990,7 +3442,7 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
2990
3442
|
try {
|
|
2991
3443
|
const body = await this.parseJsonBody(req);
|
|
2992
3444
|
const payload = body.payload ?? body;
|
|
2993
|
-
const requestId = (0,
|
|
3445
|
+
const requestId = (0, import_uuid3.v4)();
|
|
2994
3446
|
const projectPath = String(body.projectPath ?? "unknown");
|
|
2995
3447
|
const toolName = String(payload.tool_name ?? body.tool_name ?? "unknown");
|
|
2996
3448
|
const toolInput = payload.tool_input ?? body.tool_input ?? {};
|
|
@@ -3004,6 +3456,51 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
3004
3456
|
createdAt: Date.now()
|
|
3005
3457
|
};
|
|
3006
3458
|
console.log(`[ApprovalProxy] ${t("approval.received")}: ${requestId} (${approvalRequest.toolName})`);
|
|
3459
|
+
if ((approvalRequest.toolName === "AskUserQuestion" || approvalRequest.toolName === "AskFollowupQuestion") && this.questionHandler) {
|
|
3460
|
+
const questions = parseQuestionsFromInput(toolInput);
|
|
3461
|
+
if (questions.length === 0) {
|
|
3462
|
+
this.sendJson(res, 200, { decision: "allow" });
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
const toolUseId = String(
|
|
3466
|
+
payload.tool_use_id ?? body.tool_use_id ?? ""
|
|
3467
|
+
);
|
|
3468
|
+
const qRequest = {
|
|
3469
|
+
id: requestId,
|
|
3470
|
+
sessionId: approvalRequest.sessionId,
|
|
3471
|
+
toolUseId,
|
|
3472
|
+
question: questions[0].question,
|
|
3473
|
+
options: questions[0].options?.map((o) => o.label),
|
|
3474
|
+
questions,
|
|
3475
|
+
createdAt: Date.now()
|
|
3476
|
+
};
|
|
3477
|
+
const answer = await new Promise((resolve) => {
|
|
3478
|
+
const timer = setTimeout(() => {
|
|
3479
|
+
this.pendingQuestions.delete(requestId);
|
|
3480
|
+
console.log(`[ApprovalProxy] Question timeout: ${requestId}`);
|
|
3481
|
+
resolve("");
|
|
3482
|
+
this.notifyQuestionResolved(requestId);
|
|
3483
|
+
}, 325e3);
|
|
3484
|
+
this.pendingQuestions.set(requestId, { resolve, timer, request: qRequest });
|
|
3485
|
+
this.questionHandler(qRequest.sessionId, toolUseId, questions, requestId).then((ans) => {
|
|
3486
|
+
if (ans && this.pendingQuestions.has(requestId)) this.resolveQuestion(requestId, ans);
|
|
3487
|
+
}).catch((err) => console.error("[ApprovalProxy] questionHandler error:", err));
|
|
3488
|
+
});
|
|
3489
|
+
if (!answer) {
|
|
3490
|
+
this.sendJson(res, 200, {
|
|
3491
|
+
decision: "deny",
|
|
3492
|
+
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",
|
|
3493
|
+
systemMessage: "\u7528\u6237\u672A\u56DE\u7B54 AskUserQuestion\uFF1B\u8BF7\u52FF\u91CD\u8BD5\u8BE5\u5DE5\u5177\uFF0C\u81EA\u884C\u51B3\u7B56\u7EE7\u7EED\u3002"
|
|
3494
|
+
});
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
this.sendJson(res, 200, {
|
|
3498
|
+
decision: "deny",
|
|
3499
|
+
reason: formatQuestionAnswer(questions, answer),
|
|
3500
|
+
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"
|
|
3501
|
+
});
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3007
3504
|
if (this.isToolAlwaysAllowed(approvalRequest.toolName, projectPath !== "unknown" ? projectPath : void 0)) {
|
|
3008
3505
|
console.log(`[ApprovalProxy] ${t("approval.alwaysAllowPassThrough", { tool: approvalRequest.toolName })}`);
|
|
3009
3506
|
this.sendJson(res, 200, { decision: "allow" });
|
|
@@ -3193,6 +3690,49 @@ var ApprovalProxy = class _ApprovalProxy {
|
|
|
3193
3690
|
res.end(body);
|
|
3194
3691
|
}
|
|
3195
3692
|
};
|
|
3693
|
+
function parseQuestionsFromInput(input) {
|
|
3694
|
+
if (Array.isArray(input.questions) && input.questions.length > 0) {
|
|
3695
|
+
return input.questions.map((q) => {
|
|
3696
|
+
const item = {
|
|
3697
|
+
question: typeof q.question === "string" ? q.question : "",
|
|
3698
|
+
header: typeof q.header === "string" ? q.header : void 0,
|
|
3699
|
+
multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : void 0
|
|
3700
|
+
};
|
|
3701
|
+
if (Array.isArray(q.options)) {
|
|
3702
|
+
item.options = q.options.map((o) => ({
|
|
3703
|
+
label: typeof o.label === "string" ? o.label : "",
|
|
3704
|
+
description: typeof o.description === "string" ? o.description : void 0
|
|
3705
|
+
}));
|
|
3706
|
+
}
|
|
3707
|
+
return item;
|
|
3708
|
+
});
|
|
3709
|
+
}
|
|
3710
|
+
if (typeof input.question === "string") {
|
|
3711
|
+
const opts = Array.isArray(input.options) ? input.options.map((o) => ({ label: String(o) })) : void 0;
|
|
3712
|
+
return [{ question: input.question, options: opts }];
|
|
3713
|
+
}
|
|
3714
|
+
return [];
|
|
3715
|
+
}
|
|
3716
|
+
function formatQuestionAnswer(questions, raw) {
|
|
3717
|
+
let pairs = [];
|
|
3718
|
+
try {
|
|
3719
|
+
const parsed = JSON.parse(raw);
|
|
3720
|
+
if (parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object") {
|
|
3721
|
+
const answers = parsed.answers;
|
|
3722
|
+
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) }));
|
|
3723
|
+
}
|
|
3724
|
+
} catch {
|
|
3725
|
+
}
|
|
3726
|
+
if (pairs.length === 0) {
|
|
3727
|
+
pairs = [{ q: questions[0]?.question ?? "\uFF08\u672A\u77E5\u95EE\u9898\uFF09", a: raw }];
|
|
3728
|
+
}
|
|
3729
|
+
const body = pairs.map((p, i) => `${i + 1}. \u95EE\u9898\uFF1A${p.q}
|
|
3730
|
+
\u7528\u6237\u56DE\u7B54\uFF1A${p.a}`).join("\n");
|
|
3731
|
+
return `\u3010\u7528\u6237\u5DF2\u901A\u8FC7 Sessix \u56DE\u7B54\u3011
|
|
3732
|
+
${body}
|
|
3733
|
+
|
|
3734
|
+
\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`;
|
|
3735
|
+
}
|
|
3196
3736
|
|
|
3197
3737
|
// src/mdns/MdnsService.ts
|
|
3198
3738
|
var import_node_child_process5 = require("child_process");
|
|
@@ -3358,7 +3898,7 @@ function getLanAddresses(networkInterfacesFn) {
|
|
|
3358
3898
|
}
|
|
3359
3899
|
|
|
3360
3900
|
// src/hooks/HookInstaller.ts
|
|
3361
|
-
var
|
|
3901
|
+
var import_promises3 = require("fs/promises");
|
|
3362
3902
|
var import_node_path4 = require("path");
|
|
3363
3903
|
var import_node_os6 = require("os");
|
|
3364
3904
|
var SESSIX_HOOKS_DIR = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".sessix", "hooks");
|
|
@@ -3378,7 +3918,7 @@ var LEGACY_HOOK_COMMANDS = [
|
|
|
3378
3918
|
"~/.sessix/hooks/permission-accept.sh"
|
|
3379
3919
|
];
|
|
3380
3920
|
var HOOK_SCRIPT_TEMPLATE = `#!/usr/bin/env node
|
|
3381
|
-
// Sessix Approval Hook
|
|
3921
|
+
// Sessix Approval Hook v2 (systemMessage passthrough)
|
|
3382
3922
|
// \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
|
|
3383
3923
|
|
|
3384
3924
|
const sessionId = process.env.SESSIX_SESSION_ID
|
|
@@ -3409,6 +3949,9 @@ process.stdin.on('end', async () => {
|
|
|
3409
3949
|
if (decision === 'deny' && data.reason) {
|
|
3410
3950
|
output.hookSpecificOutput.permissionDecisionReason = String(data.reason)
|
|
3411
3951
|
}
|
|
3952
|
+
if (data.systemMessage) {
|
|
3953
|
+
output.systemMessage = String(data.systemMessage)
|
|
3954
|
+
}
|
|
3412
3955
|
process.stdout.write(JSON.stringify(output))
|
|
3413
3956
|
process.exit(0)
|
|
3414
3957
|
} catch {
|
|
@@ -3548,17 +4091,17 @@ var HookInstaller = class {
|
|
|
3548
4091
|
* 4. 更新 Claude Code settings.json 添加 hook 配置
|
|
3549
4092
|
*/
|
|
3550
4093
|
async install() {
|
|
3551
|
-
await (0,
|
|
3552
|
-
await (0,
|
|
3553
|
-
await (0,
|
|
3554
|
-
await (0,
|
|
3555
|
-
await (0,
|
|
3556
|
-
await (0,
|
|
3557
|
-
await (0,
|
|
3558
|
-
await (0,
|
|
3559
|
-
await (0,
|
|
3560
|
-
await (0,
|
|
3561
|
-
await (0,
|
|
4094
|
+
await (0, import_promises3.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
|
|
4095
|
+
await (0, import_promises3.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
|
|
4096
|
+
await (0, import_promises3.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
|
|
4097
|
+
await (0, import_promises3.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
|
|
4098
|
+
await (0, import_promises3.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
|
|
4099
|
+
await (0, import_promises3.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
|
|
4100
|
+
await (0, import_promises3.chmod)(HOOK_SCRIPT_PATH, 493);
|
|
4101
|
+
await (0, import_promises3.chmod)(PERMISSION_ACCEPT_PATH, 493);
|
|
4102
|
+
await (0, import_promises3.chmod)(COMPACT_HOOK_PATH, 493);
|
|
4103
|
+
await (0, import_promises3.chmod)(POST_COMPACT_HOOK_PATH, 493);
|
|
4104
|
+
await (0, import_promises3.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
|
|
3562
4105
|
await this.addHookToSettings();
|
|
3563
4106
|
console.log("[HookInstaller] Hook installation complete");
|
|
3564
4107
|
}
|
|
@@ -3588,33 +4131,34 @@ var HookInstaller = class {
|
|
|
3588
4131
|
let postCompactScriptExists = false;
|
|
3589
4132
|
let permissionDeniedScriptExists = false;
|
|
3590
4133
|
try {
|
|
3591
|
-
approvalScriptContent = await (0,
|
|
4134
|
+
approvalScriptContent = await (0, import_promises3.readFile)(HOOK_SCRIPT_PATH, "utf-8");
|
|
3592
4135
|
} catch {
|
|
3593
4136
|
}
|
|
3594
4137
|
try {
|
|
3595
|
-
await (0,
|
|
4138
|
+
await (0, import_promises3.access)(PERMISSION_ACCEPT_PATH);
|
|
3596
4139
|
permissionScriptExists = true;
|
|
3597
4140
|
} catch {
|
|
3598
4141
|
}
|
|
3599
4142
|
try {
|
|
3600
|
-
await (0,
|
|
4143
|
+
await (0, import_promises3.access)(COMPACT_HOOK_PATH);
|
|
3601
4144
|
compactScriptExists = true;
|
|
3602
4145
|
} catch {
|
|
3603
4146
|
}
|
|
3604
4147
|
try {
|
|
3605
|
-
await (0,
|
|
4148
|
+
await (0, import_promises3.access)(POST_COMPACT_HOOK_PATH);
|
|
3606
4149
|
postCompactScriptExists = true;
|
|
3607
4150
|
} catch {
|
|
3608
4151
|
}
|
|
3609
4152
|
try {
|
|
3610
|
-
await (0,
|
|
4153
|
+
await (0, import_promises3.access)(PERMISSION_DENIED_HOOK_PATH);
|
|
3611
4154
|
permissionDeniedScriptExists = true;
|
|
3612
4155
|
} catch {
|
|
3613
4156
|
}
|
|
3614
|
-
const isLatestVersion = approvalScriptContent.includes("permissionDecision");
|
|
4157
|
+
const isLatestVersion = approvalScriptContent.includes("permissionDecision") && approvalScriptContent.includes("Sessix Approval Hook v2");
|
|
3615
4158
|
const settings = await this.readClaudeSettings();
|
|
3616
4159
|
const configExists = this.hasHookConfig(settings);
|
|
3617
|
-
|
|
4160
|
+
const hasLegacyHook = this.hasHookEntry(settings?.hooks?.PreToolUse, LEGACY_HOOK_COMMANDS[0]) || this.hasHookEntry(settings?.hooks?.PermissionRequest, LEGACY_HOOK_COMMANDS[1]);
|
|
4161
|
+
return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists && !hasLegacyHook;
|
|
3618
4162
|
}
|
|
3619
4163
|
// ============================================
|
|
3620
4164
|
// 内部方法
|
|
@@ -3626,8 +4170,14 @@ var HookInstaller = class {
|
|
|
3626
4170
|
let settings = await this.readClaudeSettings();
|
|
3627
4171
|
let changed = false;
|
|
3628
4172
|
for (const cmd of LEGACY_HOOK_COMMANDS) {
|
|
3629
|
-
this.
|
|
3630
|
-
|
|
4173
|
+
if (this.hasHookEntry(settings?.hooks?.PreToolUse, cmd)) {
|
|
4174
|
+
this.removeHookCommand(settings, "PreToolUse", cmd);
|
|
4175
|
+
changed = true;
|
|
4176
|
+
}
|
|
4177
|
+
if (this.hasHookEntry(settings?.hooks?.PermissionRequest, cmd)) {
|
|
4178
|
+
this.removeHookCommand(settings, "PermissionRequest", cmd);
|
|
4179
|
+
changed = true;
|
|
4180
|
+
}
|
|
3631
4181
|
}
|
|
3632
4182
|
if (!settings.hooks) {
|
|
3633
4183
|
settings.hooks = {};
|
|
@@ -3719,7 +4269,7 @@ var HookInstaller = class {
|
|
|
3719
4269
|
*/
|
|
3720
4270
|
async readClaudeSettings() {
|
|
3721
4271
|
try {
|
|
3722
|
-
const content = await (0,
|
|
4272
|
+
const content = await (0, import_promises3.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
|
|
3723
4273
|
return JSON.parse(content);
|
|
3724
4274
|
} catch {
|
|
3725
4275
|
return {};
|
|
@@ -3729,8 +4279,8 @@ var HookInstaller = class {
|
|
|
3729
4279
|
* 写入 Claude Code settings.json
|
|
3730
4280
|
*/
|
|
3731
4281
|
async writeClaudeSettings(settings) {
|
|
3732
|
-
await (0,
|
|
3733
|
-
await (0,
|
|
4282
|
+
await (0, import_promises3.mkdir)((0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude"), { recursive: true });
|
|
4283
|
+
await (0, import_promises3.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
3734
4284
|
}
|
|
3735
4285
|
/**
|
|
3736
4286
|
* 检查 settings 中是否已包含所有 Sessix hook 配置
|
|
@@ -3771,7 +4321,7 @@ var HookInstaller = class {
|
|
|
3771
4321
|
var import_node_path5 = require("path");
|
|
3772
4322
|
var RECENT_ACTIVITY_MAX = 6;
|
|
3773
4323
|
var ACTIVITY_PUSH_THROTTLE_MS = 4e3;
|
|
3774
|
-
var NotificationService = class {
|
|
4324
|
+
var NotificationService = class _NotificationService {
|
|
3775
4325
|
constructor(sessionManager, expoChannel = null) {
|
|
3776
4326
|
this.sessionManager = sessionManager;
|
|
3777
4327
|
this.expoChannel = expoChannel;
|
|
@@ -3813,6 +4363,14 @@ var NotificationService = class {
|
|
|
3813
4363
|
* token 注册时启动,flushActivityEnd / removeActivityPushToken 时停止。
|
|
3814
4364
|
*/
|
|
3815
4365
|
laHeartbeatTimers = /* @__PURE__ */ new Map();
|
|
4366
|
+
/**
|
|
4367
|
+
* 上次推送的内容指纹(status + recentActivity + approvalId)。
|
|
4368
|
+
* 只在内容实际变化时发 priority-10 推送;未变化时低频刷新(30s),
|
|
4369
|
+
* 节省 APNs push budget,避免 iOS 节流导致 LA 停滞。
|
|
4370
|
+
*/
|
|
4371
|
+
lastPushedFingerprint = /* @__PURE__ */ new Map();
|
|
4372
|
+
/** 内容未变化时低频刷新间隔(仅刷新 stats/timer,不含内容变化) */
|
|
4373
|
+
static STATS_REFRESH_INTERVAL_MS = 3e4;
|
|
3816
4374
|
/** 添加通知渠道(id 唯一,可用于后续动态开关) */
|
|
3817
4375
|
addChannel(id, channel, enabled = true) {
|
|
3818
4376
|
this.channelMap.set(id, { channel, enabled });
|
|
@@ -3869,6 +4427,7 @@ var NotificationService = class {
|
|
|
3869
4427
|
this.recentActivityState.delete(sessionId);
|
|
3870
4428
|
this.lastActivityPushAt.delete(sessionId);
|
|
3871
4429
|
this.activityCounters.delete(sessionId);
|
|
4430
|
+
this.lastPushedFingerprint.delete(sessionId);
|
|
3872
4431
|
}
|
|
3873
4432
|
/** 注入"会话 → 待审批列表"提供器(server.ts 启动时调用) */
|
|
3874
4433
|
setPendingApprovalsProvider(fn) {
|
|
@@ -4015,10 +4574,39 @@ var NotificationService = class {
|
|
|
4015
4574
|
this.latestAssistantText.clear();
|
|
4016
4575
|
for (const timer of this.activityPushTimers.values()) clearTimeout(timer);
|
|
4017
4576
|
this.activityPushTimers.clear();
|
|
4577
|
+
for (const timer of this.idleEndTimers.values()) clearTimeout(timer);
|
|
4578
|
+
this.idleEndTimers.clear();
|
|
4579
|
+
for (const timer of this.laHeartbeatTimers.values()) clearInterval(timer);
|
|
4580
|
+
this.laHeartbeatTimers.clear();
|
|
4018
4581
|
this.recentActivityState.clear();
|
|
4019
4582
|
this.lastActivityPushAt.clear();
|
|
4020
4583
|
this.pendingPriority.clear();
|
|
4021
4584
|
this.activityCounters.clear();
|
|
4585
|
+
this.lastPushedFingerprint.clear();
|
|
4586
|
+
}
|
|
4587
|
+
/**
|
|
4588
|
+
* 释放单个会话的全部内存状态(会话被 kill 或淘汰时调用)。
|
|
4589
|
+
* 由 SessionManager.onSessionRemoved 钩子触发,覆盖用户主动 kill 和自动淘汰两条路径。
|
|
4590
|
+
* 幂等:重复调用或对未知会话调用都安全。
|
|
4591
|
+
*/
|
|
4592
|
+
releaseSession(sessionId) {
|
|
4593
|
+
this.clearActivityPushTimer(sessionId);
|
|
4594
|
+
this.cancelIdleEndTimer(sessionId);
|
|
4595
|
+
this.stopLaHeartbeat(sessionId);
|
|
4596
|
+
this.clearSessionActivityState(sessionId);
|
|
4597
|
+
this.yoloModeState.delete(sessionId);
|
|
4598
|
+
this.lastActivityPushAt.delete(sessionId);
|
|
4599
|
+
this.lastPushedFingerprint.delete(sessionId);
|
|
4600
|
+
this.pendingPriority.delete(sessionId);
|
|
4601
|
+
}
|
|
4602
|
+
/**
|
|
4603
|
+
* 清空单会话可重建的重状态(recentActivity / 计数器 / 最新文本)。
|
|
4604
|
+
* 会话走到 idle 时调用即可释放内存——resume 后这些状态会随新事件自动重建。
|
|
4605
|
+
*/
|
|
4606
|
+
clearSessionActivityState(sessionId) {
|
|
4607
|
+
this.recentActivityState.delete(sessionId);
|
|
4608
|
+
this.activityCounters.delete(sessionId);
|
|
4609
|
+
this.latestAssistantText.delete(sessionId);
|
|
4022
4610
|
}
|
|
4023
4611
|
// ============================================
|
|
4024
4612
|
// 内部方法
|
|
@@ -4057,6 +4645,7 @@ var NotificationService = class {
|
|
|
4057
4645
|
badge: this.getGlobalPendingCount(),
|
|
4058
4646
|
data: { type: "task_complete", sessionId: event.sessionId }
|
|
4059
4647
|
});
|
|
4648
|
+
this.clearSessionActivityState(event.sessionId);
|
|
4060
4649
|
}
|
|
4061
4650
|
} else if (event.status === "running" || event.status === "waiting_approval" || event.status === "waiting_question") {
|
|
4062
4651
|
this.cancelIdleEndTimer(event.sessionId);
|
|
@@ -4119,6 +4708,8 @@ var NotificationService = class {
|
|
|
4119
4708
|
while (state2.history.length > RECENT_ACTIVITY_MAX) state2.history.shift();
|
|
4120
4709
|
state2.currentEntries = [];
|
|
4121
4710
|
state2.currentMessageId = null;
|
|
4711
|
+
state2.accumulatedText = "";
|
|
4712
|
+
state2.countedToolIds = /* @__PURE__ */ new Set();
|
|
4122
4713
|
}
|
|
4123
4714
|
return;
|
|
4124
4715
|
}
|
|
@@ -4127,7 +4718,7 @@ var NotificationService = class {
|
|
|
4127
4718
|
if (!Array.isArray(msg.content)) return;
|
|
4128
4719
|
let state = this.recentActivityState.get(sessionId);
|
|
4129
4720
|
if (!state) {
|
|
4130
|
-
state = { history: [], currentMessageId: null, currentEntries: [] };
|
|
4721
|
+
state = { history: [], currentMessageId: null, currentEntries: [], accumulatedText: "", countedToolIds: /* @__PURE__ */ new Set() };
|
|
4131
4722
|
this.recentActivityState.set(sessionId, state);
|
|
4132
4723
|
}
|
|
4133
4724
|
if (state.currentMessageId !== msg.id) {
|
|
@@ -4137,16 +4728,25 @@ var NotificationService = class {
|
|
|
4137
4728
|
}
|
|
4138
4729
|
state.currentEntries = [];
|
|
4139
4730
|
state.currentMessageId = msg.id;
|
|
4731
|
+
state.accumulatedText = "";
|
|
4732
|
+
state.countedToolIds = /* @__PURE__ */ new Set();
|
|
4733
|
+
}
|
|
4734
|
+
for (const block of msg.content) {
|
|
4735
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4736
|
+
state.accumulatedText += block.text;
|
|
4737
|
+
}
|
|
4140
4738
|
}
|
|
4141
4739
|
const next = [];
|
|
4740
|
+
const accText = this.summarizeText(state.accumulatedText);
|
|
4741
|
+
if (accText.length >= 4) next.push(accText);
|
|
4142
4742
|
for (const block of msg.content) {
|
|
4143
|
-
if (block.type === "
|
|
4144
|
-
const line = this.summarizeText(block.text);
|
|
4145
|
-
if (line.length >= 4) next.push(line);
|
|
4146
|
-
} else if (block.type === "tool_use") {
|
|
4743
|
+
if (block.type === "tool_use") {
|
|
4147
4744
|
const line = this.summarizeToolCall(block.name, block.input ?? {});
|
|
4148
4745
|
if (line) next.push(line);
|
|
4149
|
-
|
|
4746
|
+
if (!state.countedToolIds.has(block.id)) {
|
|
4747
|
+
state.countedToolIds.add(block.id);
|
|
4748
|
+
this.incrementCounter(sessionId, block.name);
|
|
4749
|
+
}
|
|
4150
4750
|
}
|
|
4151
4751
|
}
|
|
4152
4752
|
state.currentEntries = next;
|
|
@@ -4262,7 +4862,7 @@ var NotificationService = class {
|
|
|
4262
4862
|
return;
|
|
4263
4863
|
}
|
|
4264
4864
|
if (session.status === "running" || session.status === "waiting_approval" || session.status === "waiting_question") {
|
|
4265
|
-
this.scheduleActivityPush(sessionId);
|
|
4865
|
+
this.scheduleActivityPush(sessionId, true);
|
|
4266
4866
|
}
|
|
4267
4867
|
}, ACTIVITY_PUSH_THROTTLE_MS);
|
|
4268
4868
|
this.laHeartbeatTimers.set(sessionId, timer);
|
|
@@ -4334,17 +4934,38 @@ var NotificationService = class {
|
|
|
4334
4934
|
});
|
|
4335
4935
|
}
|
|
4336
4936
|
this.stopLaHeartbeat(sessionId);
|
|
4337
|
-
this.
|
|
4937
|
+
this.clearSessionActivityState(sessionId);
|
|
4338
4938
|
this.lastActivityPushAt.delete(sessionId);
|
|
4339
|
-
this.
|
|
4939
|
+
this.lastPushedFingerprint.delete(sessionId);
|
|
4340
4940
|
console.log(`[NotificationService] \u{1F3C1} LA end (${reason}) session=${sessionId.slice(0, 8)}\u2026`);
|
|
4341
4941
|
}
|
|
4942
|
+
/**
|
|
4943
|
+
* 计算内容指纹:status + recentActivity + latestApproval。
|
|
4944
|
+
* 用于判断 LA 内容是否实际变化,避免重复推送消耗 APNs budget。
|
|
4945
|
+
*/
|
|
4946
|
+
computeContentFingerprint(sessionId) {
|
|
4947
|
+
const activity = this.getRecentActivity(sessionId);
|
|
4948
|
+
const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
|
|
4949
|
+
const approvals = this.pendingApprovalsProvider?.(sessionId) ?? [];
|
|
4950
|
+
const latestApproval = approvals[approvals.length - 1];
|
|
4951
|
+
return `${session?.status ?? ""}|${activity.join(" ")}|${latestApproval?.id ?? ""}`;
|
|
4952
|
+
}
|
|
4342
4953
|
/** 真正发送一次 LA content push(无 alert) */
|
|
4343
4954
|
flushActivityPush(sessionId) {
|
|
4344
4955
|
const channel = this.activityPushChannel;
|
|
4345
4956
|
if (!channel?.hasToken(sessionId)) return;
|
|
4346
4957
|
const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
|
|
4347
4958
|
if (!session) return;
|
|
4959
|
+
const fingerprint = this.computeContentFingerprint(sessionId);
|
|
4960
|
+
const lastFingerprint = this.lastPushedFingerprint.get(sessionId);
|
|
4961
|
+
const contentChanged = fingerprint !== lastFingerprint;
|
|
4962
|
+
if (!contentChanged) {
|
|
4963
|
+
const lastPush = this.lastActivityPushAt.get(sessionId) ?? 0;
|
|
4964
|
+
if (Date.now() - lastPush < _NotificationService.STATS_REFRESH_INTERVAL_MS) {
|
|
4965
|
+
return;
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4968
|
+
this.lastPushedFingerprint.set(sessionId, fingerprint);
|
|
4348
4969
|
const recentActivity = this.getRecentActivity(sessionId);
|
|
4349
4970
|
const latestMessage = recentActivity[recentActivity.length - 1] ?? this.latestAssistantText.get(sessionId) ?? "";
|
|
4350
4971
|
const sessionTitle = this.getSessionTitle(sessionId);
|
|
@@ -4373,13 +4994,14 @@ var NotificationService = class {
|
|
|
4373
4994
|
};
|
|
4374
4995
|
}
|
|
4375
4996
|
contentState.stats = this.buildStatsPayload(session);
|
|
4376
|
-
const
|
|
4997
|
+
const explicitPriority = this.pendingPriority.get(sessionId);
|
|
4998
|
+
const priority = explicitPriority ?? (contentChanged ? "10" : "5");
|
|
4377
4999
|
this.pendingPriority.delete(sessionId);
|
|
4378
5000
|
this.lastActivityPushAt.set(sessionId, Date.now());
|
|
4379
5001
|
const lineCount = recentActivity.length;
|
|
4380
5002
|
channel.updateActivity(sessionId, contentState, { priority }).then((ok) => {
|
|
4381
5003
|
if (ok) {
|
|
4382
|
-
console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} lines=${lineCount}`);
|
|
5004
|
+
console.log(`[NotificationService] \u{1F4E1} LA push \u2713 session=${sessionId.slice(0, 8)}\u2026 status=${status} p=${priority} changed=${contentChanged} lines=${lineCount}`);
|
|
4383
5005
|
}
|
|
4384
5006
|
}).catch((err) => {
|
|
4385
5007
|
console.warn(`[NotificationService] \u{1F4E1} LA push \u2717 session=${sessionId.slice(0, 8)}\u2026:`, err instanceof Error ? err.message : err);
|
|
@@ -4508,6 +5130,8 @@ var DesktopNotificationChannel = class {
|
|
|
4508
5130
|
|
|
4509
5131
|
// src/notification/ExpoNotificationChannel.ts
|
|
4510
5132
|
var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
|
|
5133
|
+
var EXPO_RECEIPT_API = "https://exp.host/--/api/v2/push/getReceipts";
|
|
5134
|
+
var RECEIPT_CHECK_DELAY_MS = 1e4;
|
|
4511
5135
|
var ExpoNotificationChannel = class {
|
|
4512
5136
|
tokens = /* @__PURE__ */ new Set();
|
|
4513
5137
|
/** push token → WebSocket 连接映射,用于前台抑制 */
|
|
@@ -4555,6 +5179,7 @@ var ExpoNotificationChannel = class {
|
|
|
4555
5179
|
if (prefs) {
|
|
4556
5180
|
const notifType = payload.data?.type ?? "";
|
|
4557
5181
|
if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
|
|
5182
|
+
else if (notifType === "question_request" && prefs.approval) sound = prefs.approval;
|
|
4558
5183
|
else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
|
|
4559
5184
|
else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
|
|
4560
5185
|
}
|
|
@@ -4585,6 +5210,7 @@ var ExpoNotificationChannel = class {
|
|
|
4585
5210
|
console.warn(`[ExpoNotificationChannel] ${t("notification.pushApiFormatError")}`, JSON.stringify(body));
|
|
4586
5211
|
return;
|
|
4587
5212
|
}
|
|
5213
|
+
const receiptIdToToken = /* @__PURE__ */ new Map();
|
|
4588
5214
|
for (let i = 0; i < body.data.length; i++) {
|
|
4589
5215
|
const ticket = body.data[i];
|
|
4590
5216
|
if (ticket.status === "error") {
|
|
@@ -4597,13 +5223,63 @@ var ExpoNotificationChannel = class {
|
|
|
4597
5223
|
this.soundPreferences.delete(staleToken);
|
|
4598
5224
|
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`);
|
|
4599
5225
|
}
|
|
5226
|
+
} else if (ticket.status === "ok" && typeof ticket.id === "string" && targetTokens[i]) {
|
|
5227
|
+
receiptIdToToken.set(ticket.id, targetTokens[i]);
|
|
4600
5228
|
}
|
|
4601
5229
|
}
|
|
5230
|
+
this.scheduleReceiptCheck(receiptIdToToken);
|
|
4602
5231
|
}
|
|
4603
5232
|
} catch (err) {
|
|
4604
5233
|
console.warn(`[ExpoNotificationChannel] ${t("notification.sendFailed")}`, err);
|
|
4605
5234
|
}
|
|
4606
5235
|
}
|
|
5236
|
+
/**
|
|
5237
|
+
* Expo push 二阶段:延迟查 receipt,暴露 ticket 阶段看不到的 APNs 投递失败。
|
|
5238
|
+
*
|
|
5239
|
+
* 关键诊断点:InvalidCredentials / MismatchSenderId 表示 Expo 项目的 APNs
|
|
5240
|
+
* 凭证配置问题(不是用户机器问题)——这正是"只有开发者能收到推送"的根因,
|
|
5241
|
+
* 且 ticket 全为 ok、不查 receipt 永远静默。
|
|
5242
|
+
*/
|
|
5243
|
+
scheduleReceiptCheck(receiptIdToToken) {
|
|
5244
|
+
if (receiptIdToToken.size === 0) return;
|
|
5245
|
+
const timer = setTimeout(async () => {
|
|
5246
|
+
try {
|
|
5247
|
+
const ids = Array.from(receiptIdToToken.keys());
|
|
5248
|
+
const res = await fetch(EXPO_RECEIPT_API, {
|
|
5249
|
+
method: "POST",
|
|
5250
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
5251
|
+
body: JSON.stringify({ ids })
|
|
5252
|
+
});
|
|
5253
|
+
const body = await res.json();
|
|
5254
|
+
if (!res.ok || !body?.data || typeof body.data !== "object") {
|
|
5255
|
+
console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5931\u8D25", res.status, JSON.stringify(body));
|
|
5256
|
+
return;
|
|
5257
|
+
}
|
|
5258
|
+
const receipts = body.data;
|
|
5259
|
+
for (const [receiptId, receipt] of Object.entries(receipts)) {
|
|
5260
|
+
if (receipt?.status !== "error") continue;
|
|
5261
|
+
const errorCode = receipt.details?.error ?? "unknown";
|
|
5262
|
+
const token = receiptIdToToken.get(receiptId);
|
|
5263
|
+
console.error(
|
|
5264
|
+
`[ExpoNotificationChannel] \u274C APNs \u6295\u9012\u5931\u8D25 receipt=${receiptId} error=${errorCode}` + (receipt.message ? ` \u2014 ${receipt.message}` : "")
|
|
5265
|
+
);
|
|
5266
|
+
if (errorCode === "DeviceNotRegistered" && token) {
|
|
5267
|
+
this.tokens.delete(token);
|
|
5268
|
+
this.tokenWsMap.delete(token);
|
|
5269
|
+
this.soundPreferences.delete(token);
|
|
5270
|
+
console.warn("[ExpoNotificationChannel] \u26A0\uFE0F \u5DF2\u79FB\u9664\u5931\u6548 token\uFF08receipt DeviceNotRegistered\uFF09\u3002\u91CD\u542F App \u53EF\u91CD\u65B0\u6CE8\u518C\u3002");
|
|
5271
|
+
} else if (errorCode === "InvalidCredentials" || errorCode === "MismatchSenderId") {
|
|
5272
|
+
console.error(
|
|
5273
|
+
"[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"
|
|
5274
|
+
);
|
|
5275
|
+
}
|
|
5276
|
+
}
|
|
5277
|
+
} catch (err) {
|
|
5278
|
+
console.warn("[ExpoNotificationChannel] \u26A0\uFE0F push receipt \u67E5\u8BE2\u5F02\u5E38:", err);
|
|
5279
|
+
}
|
|
5280
|
+
}, RECEIPT_CHECK_DELAY_MS);
|
|
5281
|
+
timer.unref?.();
|
|
5282
|
+
}
|
|
4607
5283
|
};
|
|
4608
5284
|
|
|
4609
5285
|
// src/notification/ActivityPushChannel.ts
|
|
@@ -4898,470 +5574,134 @@ var ActivityPushChannel = class {
|
|
|
4898
5574
|
const reason = err instanceof ApnsError ? JSON.parse(err.responseBody || "{}")?.reason ?? err.statusCode : String(err);
|
|
4899
5575
|
console.log(`[ActivityPushChannel] \u{1F50D} probe ${env} failed: ${reason}`);
|
|
4900
5576
|
if (isProviderTokenError(err)) {
|
|
4901
|
-
console.error(
|
|
4902
|
-
`[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`
|
|
4903
|
-
);
|
|
4904
|
-
throw err;
|
|
4905
|
-
}
|
|
4906
|
-
if (!isBadDeviceTokenError(err)) {
|
|
4907
|
-
throw err;
|
|
4908
|
-
}
|
|
4909
|
-
}
|
|
4910
|
-
}
|
|
4911
|
-
this.deadTokens.add(deviceToken);
|
|
4912
|
-
for (const [sid, tok] of this.tokens) {
|
|
4913
|
-
if (tok === deviceToken) {
|
|
4914
|
-
this.tokens.delete(sid);
|
|
4915
|
-
console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
|
|
4916
|
-
break;
|
|
4917
|
-
}
|
|
4918
|
-
}
|
|
4919
|
-
throw lastErr ?? new Error("APNs send failed: all environments rejected token");
|
|
4920
|
-
}
|
|
4921
|
-
/** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
|
|
4922
|
-
async sendToAPNsOnce(deviceToken, payload, opts, env) {
|
|
4923
|
-
const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
|
|
4924
|
-
const pushType = opts.pushType ?? "liveactivity";
|
|
4925
|
-
const jwt = this.getJWT();
|
|
4926
|
-
const payloadStr = JSON.stringify(payload);
|
|
4927
|
-
const priority = opts.priority ?? "10";
|
|
4928
|
-
return new Promise((resolve, reject) => {
|
|
4929
|
-
let client;
|
|
4930
|
-
try {
|
|
4931
|
-
client = this.getHttp2Client(env);
|
|
4932
|
-
} catch (err) {
|
|
4933
|
-
return reject(err);
|
|
4934
|
-
}
|
|
4935
|
-
const headers = {
|
|
4936
|
-
":method": "POST",
|
|
4937
|
-
":path": `/3/device/${deviceToken}`,
|
|
4938
|
-
"authorization": `bearer ${jwt}`,
|
|
4939
|
-
"apns-topic": topic,
|
|
4940
|
-
"apns-push-type": pushType,
|
|
4941
|
-
"apns-priority": priority,
|
|
4942
|
-
"apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
|
|
4943
|
-
"content-type": "application/json",
|
|
4944
|
-
"content-length": Buffer.byteLength(payloadStr)
|
|
4945
|
-
};
|
|
4946
|
-
if (opts.collapseId) {
|
|
4947
|
-
headers["apns-collapse-id"] = opts.collapseId;
|
|
4948
|
-
}
|
|
4949
|
-
const req = client.request(headers);
|
|
4950
|
-
let statusCode = 0;
|
|
4951
|
-
let responseData = "";
|
|
4952
|
-
req.on("response", (headers2) => {
|
|
4953
|
-
statusCode = Number(headers2[":status"] ?? 0);
|
|
4954
|
-
});
|
|
4955
|
-
req.on("data", (chunk) => {
|
|
4956
|
-
responseData += chunk;
|
|
4957
|
-
});
|
|
4958
|
-
req.on("end", () => {
|
|
4959
|
-
if (statusCode === 200) {
|
|
4960
|
-
resolve();
|
|
4961
|
-
} else {
|
|
4962
|
-
if (statusCode === 0) {
|
|
4963
|
-
const c = this.http2Clients[env];
|
|
4964
|
-
c?.destroy();
|
|
4965
|
-
delete this.http2Clients[env];
|
|
4966
|
-
}
|
|
4967
|
-
reject(new ApnsError(statusCode, responseData));
|
|
4968
|
-
}
|
|
4969
|
-
});
|
|
4970
|
-
req.on("error", (err) => {
|
|
4971
|
-
reject(err);
|
|
4972
|
-
});
|
|
4973
|
-
req.write(payloadStr);
|
|
4974
|
-
req.end();
|
|
4975
|
-
});
|
|
4976
|
-
}
|
|
4977
|
-
/** 生成或获取缓存的 APNs JWT token */
|
|
4978
|
-
getJWT() {
|
|
4979
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
4980
|
-
if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
|
|
4981
|
-
return this.cachedJwt.token;
|
|
4982
|
-
}
|
|
4983
|
-
const header = Buffer.from(JSON.stringify({
|
|
4984
|
-
alg: "ES256",
|
|
4985
|
-
kid: this.keyId
|
|
4986
|
-
})).toString("base64url");
|
|
4987
|
-
const claims = Buffer.from(JSON.stringify({
|
|
4988
|
-
iss: this.teamId,
|
|
4989
|
-
iat: now
|
|
4990
|
-
})).toString("base64url");
|
|
4991
|
-
const signingInput = `${header}.${claims}`;
|
|
4992
|
-
const sign = crypto.createSign("SHA256");
|
|
4993
|
-
sign.update(signingInput);
|
|
4994
|
-
const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
|
|
4995
|
-
const token = `${signingInput}.${signature}`;
|
|
4996
|
-
this.cachedJwt = { token, expiresAt: now + 3e3 };
|
|
4997
|
-
return token;
|
|
4998
|
-
}
|
|
4999
|
-
};
|
|
5000
|
-
var ApnsError = class extends Error {
|
|
5001
|
-
constructor(statusCode, responseBody) {
|
|
5002
|
-
super(`APNs returned ${statusCode}: ${responseBody}`);
|
|
5003
|
-
this.statusCode = statusCode;
|
|
5004
|
-
this.responseBody = responseBody;
|
|
5005
|
-
this.name = "ApnsError";
|
|
5006
|
-
}
|
|
5007
|
-
};
|
|
5008
|
-
function isProviderTokenError(err) {
|
|
5009
|
-
if (!(err instanceof ApnsError)) return false;
|
|
5010
|
-
if (err.statusCode !== 403) return false;
|
|
5011
|
-
try {
|
|
5012
|
-
const parsed = JSON.parse(err.responseBody);
|
|
5013
|
-
return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
|
|
5014
|
-
} catch {
|
|
5015
|
-
return false;
|
|
5016
|
-
}
|
|
5017
|
-
}
|
|
5018
|
-
function isBadDeviceTokenError(err) {
|
|
5019
|
-
if (!(err instanceof ApnsError)) return false;
|
|
5020
|
-
if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
|
|
5021
|
-
try {
|
|
5022
|
-
const parsed = JSON.parse(err.responseBody);
|
|
5023
|
-
return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
|
|
5024
|
-
} catch {
|
|
5025
|
-
return false;
|
|
5026
|
-
}
|
|
5027
|
-
}
|
|
5028
|
-
|
|
5029
|
-
// src/session/ProjectReader.ts
|
|
5030
|
-
var import_promises3 = require("fs/promises");
|
|
5031
|
-
var import_readline3 = require("readline");
|
|
5032
|
-
var import_path2 = require("path");
|
|
5033
|
-
var import_os2 = require("os");
|
|
5034
|
-
var CLAUDE_PROJECTS_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "projects");
|
|
5035
|
-
function getSessionFilePath(projectPath, sessionId) {
|
|
5036
|
-
return (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
|
|
5037
|
-
}
|
|
5038
|
-
async function getProjects() {
|
|
5039
|
-
try {
|
|
5040
|
-
const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
|
|
5041
|
-
if (!dirExists) {
|
|
5042
|
-
return { ok: true, value: [] };
|
|
5043
|
-
}
|
|
5044
|
-
const entries = await (0, import_promises3.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
|
|
5045
|
-
const projects = [];
|
|
5046
|
-
for (const entry of entries) {
|
|
5047
|
-
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
|
5048
|
-
continue;
|
|
5049
|
-
}
|
|
5050
|
-
const encodedPath = entry.name;
|
|
5051
|
-
const decodedPath = decodeDirName(encodedPath);
|
|
5052
|
-
const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
|
|
5053
|
-
const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
5054
|
-
const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
|
|
5055
|
-
projects.push({
|
|
5056
|
-
id: encodedPath,
|
|
5057
|
-
path: decodedPath,
|
|
5058
|
-
name,
|
|
5059
|
-
sessionCount,
|
|
5060
|
-
lastActiveAt: latestMtime
|
|
5061
|
-
});
|
|
5062
|
-
}
|
|
5063
|
-
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
5064
|
-
return { ok: true, value: projects };
|
|
5065
|
-
} catch (err) {
|
|
5066
|
-
return {
|
|
5067
|
-
ok: false,
|
|
5068
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
5069
|
-
};
|
|
5070
|
-
}
|
|
5071
|
-
}
|
|
5072
|
-
async function getHistoricalSessions(projectPath) {
|
|
5073
|
-
try {
|
|
5074
|
-
const encodedPath = encodeDirName(projectPath);
|
|
5075
|
-
const projectDir = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
5076
|
-
const dirExists = await directoryExists(projectDir);
|
|
5077
|
-
if (!dirExists) {
|
|
5078
|
-
return { ok: true, value: [] };
|
|
5079
|
-
}
|
|
5080
|
-
const entries = await (0, import_promises3.readdir)(projectDir, { withFileTypes: true });
|
|
5081
|
-
const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
5082
|
-
const mtimeMap = /* @__PURE__ */ new Map();
|
|
5083
|
-
await Promise.all(
|
|
5084
|
-
jsonlFiles.map(async (entry) => {
|
|
5085
|
-
const sessionId = entry.name.slice(0, -6);
|
|
5086
|
-
const filePath = (0, import_path2.join)(projectDir, entry.name);
|
|
5087
|
-
try {
|
|
5088
|
-
const contentTs = await extractLastTimestamp(filePath);
|
|
5089
|
-
if (contentTs) {
|
|
5090
|
-
mtimeMap.set(sessionId, contentTs);
|
|
5091
|
-
} else {
|
|
5092
|
-
const fileStat = await (0, import_promises3.stat)(filePath);
|
|
5093
|
-
mtimeMap.set(sessionId, fileStat.mtimeMs);
|
|
5094
|
-
}
|
|
5095
|
-
} catch {
|
|
5096
|
-
mtimeMap.set(sessionId, 0);
|
|
5097
|
-
}
|
|
5098
|
-
})
|
|
5099
|
-
);
|
|
5100
|
-
const uuidDirs = entries.filter(
|
|
5101
|
-
(e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
|
|
5102
|
-
);
|
|
5103
|
-
for (const entry of uuidDirs) {
|
|
5104
|
-
try {
|
|
5105
|
-
const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(projectDir, entry.name));
|
|
5106
|
-
mtimeMap.set(entry.name, fileStat.mtimeMs);
|
|
5107
|
-
} catch {
|
|
5108
|
-
mtimeMap.set(entry.name, 0);
|
|
5109
|
-
}
|
|
5110
|
-
}
|
|
5111
|
-
const indexPath = (0, import_path2.join)(projectDir, "sessions-index.json");
|
|
5112
|
-
const sessionMap = /* @__PURE__ */ new Map();
|
|
5113
|
-
try {
|
|
5114
|
-
const indexContent = await (0, import_promises3.readFile)(indexPath, "utf-8");
|
|
5115
|
-
const indexData = JSON.parse(indexContent);
|
|
5116
|
-
if (indexData.version === 1 && Array.isArray(indexData.entries)) {
|
|
5117
|
-
for (const entry of indexData.entries) {
|
|
5118
|
-
const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
|
|
5119
|
-
sessionMap.set(entry.sessionId, {
|
|
5120
|
-
sessionId: entry.sessionId,
|
|
5121
|
-
lastModified: mtime,
|
|
5122
|
-
summary: entry.summary,
|
|
5123
|
-
firstPrompt: entry.firstPrompt,
|
|
5124
|
-
messageCount: entry.messageCount
|
|
5125
|
-
});
|
|
5126
|
-
}
|
|
5127
|
-
await Promise.all(
|
|
5128
|
-
Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
|
|
5129
|
-
const filePath = (0, import_path2.join)(projectDir, `${s.sessionId}.jsonl`);
|
|
5130
|
-
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
5131
|
-
if (firstPrompt) s.firstPrompt = firstPrompt;
|
|
5132
|
-
})
|
|
5133
|
-
);
|
|
5134
|
-
}
|
|
5135
|
-
} catch {
|
|
5136
|
-
}
|
|
5137
|
-
const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
|
|
5138
|
-
for (const [sessionId, mtime] of mtimeMap) {
|
|
5139
|
-
if (!sessionMap.has(sessionId)) {
|
|
5140
|
-
if (uuidDirSet.has(sessionId)) {
|
|
5141
|
-
sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
|
|
5142
|
-
} else {
|
|
5143
|
-
const filePath = (0, import_path2.join)(projectDir, `${sessionId}.jsonl`);
|
|
5144
|
-
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
5145
|
-
sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
|
|
5146
|
-
}
|
|
5147
|
-
}
|
|
5148
|
-
}
|
|
5149
|
-
const sessions = Array.from(sessionMap.values()).filter((s) => {
|
|
5150
|
-
if (s.messageCount === 0) return false;
|
|
5151
|
-
if (s.messageCount === -1) return true;
|
|
5152
|
-
if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
|
|
5153
|
-
return true;
|
|
5154
|
-
});
|
|
5155
|
-
sessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
5156
|
-
return { ok: true, value: sessions };
|
|
5157
|
-
} catch (err) {
|
|
5158
|
-
return {
|
|
5159
|
-
ok: false,
|
|
5160
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
5161
|
-
};
|
|
5162
|
-
}
|
|
5163
|
-
}
|
|
5164
|
-
async function getSessionHistory(projectPath, sessionId) {
|
|
5165
|
-
try {
|
|
5166
|
-
const encodedPath = encodeDirName(projectPath);
|
|
5167
|
-
const filePath = (0, import_path2.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
|
|
5168
|
-
const raw = await (0, import_promises3.readFile)(filePath, "utf-8").catch((err) => {
|
|
5169
|
-
if (err.code === "ENOENT") return null;
|
|
5170
|
-
throw err;
|
|
5171
|
-
});
|
|
5172
|
-
if (raw === null) return { ok: true, value: [] };
|
|
5173
|
-
const lines = raw.split("\n").filter((l) => l.trim());
|
|
5174
|
-
const events = [];
|
|
5175
|
-
for (const line of lines) {
|
|
5176
|
-
try {
|
|
5177
|
-
const obj = JSON.parse(line);
|
|
5178
|
-
const type = obj.type;
|
|
5179
|
-
if (type === "user" && obj.message) {
|
|
5180
|
-
const msgContent = obj.message.content;
|
|
5181
|
-
if (typeof msgContent === "string") {
|
|
5182
|
-
if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
|
|
5183
|
-
} else if (Array.isArray(msgContent)) {
|
|
5184
|
-
const hasText = msgContent.some(
|
|
5185
|
-
(b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
|
|
5186
|
-
);
|
|
5187
|
-
if (!hasText) continue;
|
|
5188
|
-
}
|
|
5189
|
-
const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
|
|
5190
|
-
if (normalizedContent.length === 0) continue;
|
|
5191
|
-
events.push({
|
|
5192
|
-
type: "user",
|
|
5193
|
-
message: {
|
|
5194
|
-
...obj.message,
|
|
5195
|
-
content: normalizedContent
|
|
5196
|
-
},
|
|
5197
|
-
session_id: sessionId
|
|
5198
|
-
});
|
|
5199
|
-
} else if (type === "assistant" && obj.message) {
|
|
5200
|
-
const content = (obj.message.content ?? []).filter(
|
|
5201
|
-
(b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
|
|
5202
|
-
);
|
|
5203
|
-
if (content.length === 0) continue;
|
|
5204
|
-
events.push({
|
|
5205
|
-
type: "assistant",
|
|
5206
|
-
message: {
|
|
5207
|
-
id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
|
|
5208
|
-
model: obj.message.model ?? "unknown",
|
|
5209
|
-
role: "assistant",
|
|
5210
|
-
content,
|
|
5211
|
-
stop_reason: obj.message.stop_reason,
|
|
5212
|
-
usage: obj.message.usage
|
|
5213
|
-
},
|
|
5214
|
-
session_id: sessionId
|
|
5215
|
-
});
|
|
5577
|
+
console.error(
|
|
5578
|
+
`[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`
|
|
5579
|
+
);
|
|
5580
|
+
throw err;
|
|
5216
5581
|
}
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
}
|
|
5220
|
-
if (events.length > 0) {
|
|
5221
|
-
let totalInputTokens = 0;
|
|
5222
|
-
let totalOutputTokens = 0;
|
|
5223
|
-
for (const ev of events) {
|
|
5224
|
-
if (ev.type === "assistant" && ev.message.usage) {
|
|
5225
|
-
totalInputTokens += ev.message.usage.input_tokens ?? 0;
|
|
5226
|
-
totalOutputTokens += ev.message.usage.output_tokens ?? 0;
|
|
5582
|
+
if (!isBadDeviceTokenError(err)) {
|
|
5583
|
+
throw err;
|
|
5227
5584
|
}
|
|
5228
5585
|
}
|
|
5229
|
-
if (totalInputTokens > 0 || totalOutputTokens > 0) {
|
|
5230
|
-
events.push({
|
|
5231
|
-
type: "result",
|
|
5232
|
-
subtype: "success",
|
|
5233
|
-
is_error: false,
|
|
5234
|
-
duration_ms: 0,
|
|
5235
|
-
num_turns: events.filter((e) => e.type === "user").length,
|
|
5236
|
-
result: "",
|
|
5237
|
-
session_id: sessionId,
|
|
5238
|
-
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
|
|
5239
|
-
});
|
|
5240
|
-
}
|
|
5241
5586
|
}
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
}
|
|
5249
|
-
}
|
|
5250
|
-
async function extractLastTimestamp(filePath) {
|
|
5251
|
-
let fileHandle;
|
|
5252
|
-
try {
|
|
5253
|
-
fileHandle = await (0, import_promises3.open)(filePath, "r");
|
|
5254
|
-
const fileStat = await fileHandle.stat();
|
|
5255
|
-
const readSize = Math.min(fileStat.size, 8192);
|
|
5256
|
-
const buffer = Buffer.alloc(readSize);
|
|
5257
|
-
await fileHandle.read(buffer, 0, readSize, fileStat.size - readSize);
|
|
5258
|
-
const tail = buffer.toString("utf-8");
|
|
5259
|
-
const lines = tail.split("\n").filter((l) => l.trim());
|
|
5260
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
5261
|
-
try {
|
|
5262
|
-
const obj = JSON.parse(lines[i]);
|
|
5263
|
-
if (obj.timestamp) {
|
|
5264
|
-
const ts = new Date(obj.timestamp).getTime();
|
|
5265
|
-
if (!isNaN(ts)) return ts;
|
|
5266
|
-
}
|
|
5267
|
-
} catch {
|
|
5587
|
+
this.deadTokens.add(deviceToken);
|
|
5588
|
+
for (const [sid, tok] of this.tokens) {
|
|
5589
|
+
if (tok === deviceToken) {
|
|
5590
|
+
this.tokens.delete(sid);
|
|
5591
|
+
console.warn(`[ActivityPushChannel] \u2620\uFE0F dead token, session removed: session=${sid.slice(0, 8)}\u2026 token=${short}\u2026 (Activity ended/iOS reinstalled)`);
|
|
5592
|
+
break;
|
|
5268
5593
|
}
|
|
5269
5594
|
}
|
|
5270
|
-
|
|
5271
|
-
} finally {
|
|
5272
|
-
await fileHandle?.close();
|
|
5595
|
+
throw lastErr ?? new Error("APNs send failed: all environments rejected token");
|
|
5273
5596
|
}
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
const
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
});
|
|
5284
|
-
let lineCount = 0;
|
|
5285
|
-
for await (const line of rl) {
|
|
5286
|
-
if (++lineCount > 20) break;
|
|
5287
|
-
if (!line.trim()) continue;
|
|
5597
|
+
/** 单次 APNs HTTP/2 请求(内部 helper,不做探测) */
|
|
5598
|
+
async sendToAPNsOnce(deviceToken, payload, opts, env) {
|
|
5599
|
+
const topic = opts.topic ?? `${this.bundleId}.push-type.liveactivity`;
|
|
5600
|
+
const pushType = opts.pushType ?? "liveactivity";
|
|
5601
|
+
const jwt = this.getJWT();
|
|
5602
|
+
const payloadStr = JSON.stringify(payload);
|
|
5603
|
+
const priority = opts.priority ?? "10";
|
|
5604
|
+
return new Promise((resolve, reject) => {
|
|
5605
|
+
let client;
|
|
5288
5606
|
try {
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5607
|
+
client = this.getHttp2Client(env);
|
|
5608
|
+
} catch (err) {
|
|
5609
|
+
return reject(err);
|
|
5610
|
+
}
|
|
5611
|
+
const headers = {
|
|
5612
|
+
":method": "POST",
|
|
5613
|
+
":path": `/3/device/${deviceToken}`,
|
|
5614
|
+
"authorization": `bearer ${jwt}`,
|
|
5615
|
+
"apns-topic": topic,
|
|
5616
|
+
"apns-push-type": pushType,
|
|
5617
|
+
"apns-priority": priority,
|
|
5618
|
+
"apns-expiration": String(Math.floor(Date.now() / 1e3) + 300),
|
|
5619
|
+
"content-type": "application/json",
|
|
5620
|
+
"content-length": Buffer.byteLength(payloadStr)
|
|
5621
|
+
};
|
|
5622
|
+
if (opts.collapseId) {
|
|
5623
|
+
headers["apns-collapse-id"] = opts.collapseId;
|
|
5624
|
+
}
|
|
5625
|
+
const req = client.request(headers);
|
|
5626
|
+
req.setTimeout(1e4, () => {
|
|
5627
|
+
req.close(http2.constants.NGHTTP2_CANCEL);
|
|
5628
|
+
});
|
|
5629
|
+
let statusCode = 0;
|
|
5630
|
+
let responseData = "";
|
|
5631
|
+
req.on("response", (headers2) => {
|
|
5632
|
+
statusCode = Number(headers2[":status"] ?? 0);
|
|
5633
|
+
});
|
|
5634
|
+
req.on("data", (chunk) => {
|
|
5635
|
+
responseData += chunk;
|
|
5636
|
+
});
|
|
5637
|
+
req.on("end", () => {
|
|
5638
|
+
if (statusCode === 200) {
|
|
5639
|
+
resolve();
|
|
5640
|
+
} else {
|
|
5641
|
+
if (statusCode === 0) {
|
|
5642
|
+
const c = this.http2Clients[env];
|
|
5643
|
+
c?.destroy();
|
|
5644
|
+
delete this.http2Clients[env];
|
|
5304
5645
|
}
|
|
5646
|
+
reject(new ApnsError(statusCode, responseData));
|
|
5305
5647
|
}
|
|
5306
|
-
}
|
|
5307
|
-
|
|
5648
|
+
});
|
|
5649
|
+
req.on("error", (err) => {
|
|
5650
|
+
reject(err);
|
|
5651
|
+
});
|
|
5652
|
+
req.write(payloadStr);
|
|
5653
|
+
req.end();
|
|
5654
|
+
});
|
|
5655
|
+
}
|
|
5656
|
+
/** 生成或获取缓存的 APNs JWT token */
|
|
5657
|
+
getJWT() {
|
|
5658
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
5659
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
|
|
5660
|
+
return this.cachedJwt.token;
|
|
5308
5661
|
}
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5662
|
+
const header = Buffer.from(JSON.stringify({
|
|
5663
|
+
alg: "ES256",
|
|
5664
|
+
kid: this.keyId
|
|
5665
|
+
})).toString("base64url");
|
|
5666
|
+
const claims = Buffer.from(JSON.stringify({
|
|
5667
|
+
iss: this.teamId,
|
|
5668
|
+
iat: now
|
|
5669
|
+
})).toString("base64url");
|
|
5670
|
+
const signingInput = `${header}.${claims}`;
|
|
5671
|
+
const sign = crypto.createSign("SHA256");
|
|
5672
|
+
sign.update(signingInput);
|
|
5673
|
+
const signature = sign.sign({ key: this.authKey, dsaEncoding: "ieee-p1363" }, "base64url");
|
|
5674
|
+
const token = `${signingInput}.${signature}`;
|
|
5675
|
+
this.cachedJwt = { token, expiresAt: now + 3e3 };
|
|
5676
|
+
return token;
|
|
5312
5677
|
}
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
}
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
async function directoryExists(dirPath) {
|
|
5678
|
+
};
|
|
5679
|
+
var ApnsError = class extends Error {
|
|
5680
|
+
constructor(statusCode, responseBody) {
|
|
5681
|
+
super(`APNs returned ${statusCode}: ${responseBody}`);
|
|
5682
|
+
this.statusCode = statusCode;
|
|
5683
|
+
this.responseBody = responseBody;
|
|
5684
|
+
this.name = "ApnsError";
|
|
5685
|
+
}
|
|
5686
|
+
};
|
|
5687
|
+
function isProviderTokenError(err) {
|
|
5688
|
+
if (!(err instanceof ApnsError)) return false;
|
|
5689
|
+
if (err.statusCode !== 403) return false;
|
|
5326
5690
|
try {
|
|
5327
|
-
const
|
|
5328
|
-
return
|
|
5691
|
+
const parsed = JSON.parse(err.responseBody);
|
|
5692
|
+
return parsed.reason === "InvalidProviderToken" || parsed.reason === "ExpiredProviderToken";
|
|
5329
5693
|
} catch {
|
|
5330
5694
|
return false;
|
|
5331
5695
|
}
|
|
5332
5696
|
}
|
|
5333
|
-
|
|
5334
|
-
|
|
5697
|
+
function isBadDeviceTokenError(err) {
|
|
5698
|
+
if (!(err instanceof ApnsError)) return false;
|
|
5699
|
+
if (err.statusCode !== 400 && err.statusCode !== 403 && err.statusCode !== 410) return false;
|
|
5335
5700
|
try {
|
|
5336
|
-
const
|
|
5337
|
-
|
|
5338
|
-
entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
|
|
5339
|
-
);
|
|
5340
|
-
const uuidDirs = entries.filter(
|
|
5341
|
-
(e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
|
|
5342
|
-
);
|
|
5343
|
-
let latestMtime = 0;
|
|
5344
|
-
const jsonlEntries = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
5345
|
-
await Promise.all([
|
|
5346
|
-
...jsonlEntries.map(async (entry) => {
|
|
5347
|
-
try {
|
|
5348
|
-
const contentTs = await extractLastTimestamp((0, import_path2.join)(dirPath, entry.name));
|
|
5349
|
-
const ts = contentTs ?? (await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name))).mtimeMs;
|
|
5350
|
-
if (ts > latestMtime) latestMtime = ts;
|
|
5351
|
-
} catch {
|
|
5352
|
-
}
|
|
5353
|
-
}),
|
|
5354
|
-
...uuidDirs.map(async (entry) => {
|
|
5355
|
-
try {
|
|
5356
|
-
const fileStat = await (0, import_promises3.stat)((0, import_path2.join)(dirPath, entry.name));
|
|
5357
|
-
if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
|
|
5358
|
-
} catch {
|
|
5359
|
-
}
|
|
5360
|
-
})
|
|
5361
|
-
]);
|
|
5362
|
-
return { count: jsonlNames.size + uuidDirs.length, latestMtime };
|
|
5701
|
+
const parsed = JSON.parse(err.responseBody);
|
|
5702
|
+
return parsed.reason === "BadDeviceToken" || parsed.reason === "BadEnvironmentKeyInToken" || parsed.reason === "Unregistered";
|
|
5363
5703
|
} catch {
|
|
5364
|
-
return
|
|
5704
|
+
return false;
|
|
5365
5705
|
}
|
|
5366
5706
|
}
|
|
5367
5707
|
|
|
@@ -5487,7 +5827,10 @@ var AuthManager = class extends import_events3.EventEmitter {
|
|
|
5487
5827
|
email: parsed.email,
|
|
5488
5828
|
authMethod: parsed.authMethod
|
|
5489
5829
|
};
|
|
5490
|
-
} catch {
|
|
5830
|
+
} catch (err) {
|
|
5831
|
+
console.warn(
|
|
5832
|
+
`[AuthManager] checkAuth \u5931\u8D25 (claudePath=${CLAUDE_PATH2}): ${err instanceof Error ? err.message : String(err)}`
|
|
5833
|
+
);
|
|
5491
5834
|
return { loggedIn: false };
|
|
5492
5835
|
}
|
|
5493
5836
|
}
|
|
@@ -5583,7 +5926,7 @@ var AuthManager = class extends import_events3.EventEmitter {
|
|
|
5583
5926
|
|
|
5584
5927
|
// src/terminal/TerminalExecutor.ts
|
|
5585
5928
|
var import_node_child_process8 = require("child_process");
|
|
5586
|
-
var
|
|
5929
|
+
var import_uuid4 = require("uuid");
|
|
5587
5930
|
var EXEC_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
5588
5931
|
var TerminalExecutor = class {
|
|
5589
5932
|
processes = /* @__PURE__ */ new Map();
|
|
@@ -5605,7 +5948,7 @@ var TerminalExecutor = class {
|
|
|
5605
5948
|
}
|
|
5606
5949
|
}
|
|
5607
5950
|
exec(sessionId, command, cwd) {
|
|
5608
|
-
const execId = (0,
|
|
5951
|
+
const execId = (0, import_uuid4.v4)();
|
|
5609
5952
|
const shell = isWindows ? "powershell" : process.env.SHELL || "/bin/zsh";
|
|
5610
5953
|
const args = isWindows ? ["-Command", command] : ["-l", "-c", command];
|
|
5611
5954
|
const proc = (0, import_node_child_process8.spawn)(shell, args, {
|
|
@@ -5682,7 +6025,7 @@ var import_node_util = require("util");
|
|
|
5682
6025
|
var import_promises4 = require("fs/promises");
|
|
5683
6026
|
var import_node_path6 = require("path");
|
|
5684
6027
|
var import_node_os7 = require("os");
|
|
5685
|
-
var
|
|
6028
|
+
var import_uuid5 = require("uuid");
|
|
5686
6029
|
var execAsync = (0, import_node_util.promisify)(import_node_child_process9.exec);
|
|
5687
6030
|
var BUILD_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
5688
6031
|
var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
@@ -5693,7 +6036,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
5693
6036
|
"DerivedData",
|
|
5694
6037
|
"Pods",
|
|
5695
6038
|
".build",
|
|
5696
|
-
"build",
|
|
5697
6039
|
"dist",
|
|
5698
6040
|
"__pycache__",
|
|
5699
6041
|
".next",
|
|
@@ -5870,7 +6212,7 @@ ${e.stderr ?? ""}`);
|
|
|
5870
6212
|
return null;
|
|
5871
6213
|
}
|
|
5872
6214
|
if (override) await this.saveConfig(projectPath, override);
|
|
5873
|
-
const buildId = (0,
|
|
6215
|
+
const buildId = (0, import_uuid5.v4)();
|
|
5874
6216
|
const args = buildArgs(config);
|
|
5875
6217
|
const proc = (0, import_node_child_process9.spawn)("xcodebuild", args, {
|
|
5876
6218
|
cwd: projectPath,
|
|
@@ -5934,7 +6276,7 @@ ${e.stderr ?? ""}`);
|
|
|
5934
6276
|
this.emitInstallError(sessionId, "", "\u672A\u627E\u5230\u6784\u5EFA\u914D\u7F6E\uFF0C\u8BF7\u5148\u6784\u5EFA\u4E00\u6B21\n");
|
|
5935
6277
|
return null;
|
|
5936
6278
|
}
|
|
5937
|
-
const installId = (0,
|
|
6279
|
+
const installId = (0, import_uuid5.v4)();
|
|
5938
6280
|
let appPath;
|
|
5939
6281
|
try {
|
|
5940
6282
|
appPath = await this.getAppPath(projectPath, config);
|
|
@@ -6505,7 +6847,7 @@ function sourceWeight(s) {
|
|
|
6505
6847
|
// src/git/GitExecutor.ts
|
|
6506
6848
|
var import_node_child_process10 = require("child_process");
|
|
6507
6849
|
var import_node_util2 = require("util");
|
|
6508
|
-
var
|
|
6850
|
+
var import_uuid6 = require("uuid");
|
|
6509
6851
|
var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
|
|
6510
6852
|
var STATUS_TIMEOUT_MS = 15e3;
|
|
6511
6853
|
var COMMIT_TIMEOUT_MS = 6e4;
|
|
@@ -6625,7 +6967,7 @@ var GitExecutor = class {
|
|
|
6625
6967
|
* - 若未提供 files:默认 git add -A(提交所有变更)
|
|
6626
6968
|
*/
|
|
6627
6969
|
async commit(sessionId, projectPath, message, files, alsoPush) {
|
|
6628
|
-
const opId = (0,
|
|
6970
|
+
const opId = (0, import_uuid6.v4)();
|
|
6629
6971
|
this.runSequence(sessionId, opId, "commit", projectPath, [
|
|
6630
6972
|
files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
|
|
6631
6973
|
["git", "commit", "-m", message]
|
|
@@ -6641,7 +6983,7 @@ var GitExecutor = class {
|
|
|
6641
6983
|
return opId;
|
|
6642
6984
|
}
|
|
6643
6985
|
async push(sessionId, projectPath) {
|
|
6644
|
-
const opId = (0,
|
|
6986
|
+
const opId = (0, import_uuid6.v4)();
|
|
6645
6987
|
this.runSequence(sessionId, opId, "push", projectPath, [
|
|
6646
6988
|
["git", "push"]
|
|
6647
6989
|
], PUSH_TIMEOUT_MS).catch((err) => {
|
|
@@ -6719,7 +7061,7 @@ var GitExecutor = class {
|
|
|
6719
7061
|
var import_promises6 = require("fs/promises");
|
|
6720
7062
|
var import_node_os8 = require("os");
|
|
6721
7063
|
var import_node_path8 = require("path");
|
|
6722
|
-
var
|
|
7064
|
+
var import_uuid7 = require("uuid");
|
|
6723
7065
|
var MAX_TIMEOUT_MS = 2147483647;
|
|
6724
7066
|
var ScheduledSessionManager = class {
|
|
6725
7067
|
tasks = /* @__PURE__ */ new Map();
|
|
@@ -6757,7 +7099,7 @@ var ScheduledSessionManager = class {
|
|
|
6757
7099
|
/** 注册一个定时任务(payload 由调用方校验) */
|
|
6758
7100
|
schedule(scheduledAt, payload) {
|
|
6759
7101
|
const task = {
|
|
6760
|
-
id: (0,
|
|
7102
|
+
id: (0, import_uuid7.v4)(),
|
|
6761
7103
|
scheduledAt,
|
|
6762
7104
|
createdAt: Date.now(),
|
|
6763
7105
|
payload
|
|
@@ -6861,10 +7203,11 @@ function isValidTask(value) {
|
|
|
6861
7203
|
// src/utils/cliCapabilities.ts
|
|
6862
7204
|
var import_node_child_process11 = require("child_process");
|
|
6863
7205
|
var DEFAULT_MODELS = [
|
|
6864
|
-
{ value: "opus", label: "Opus 4.
|
|
6865
|
-
{ value: "claude-opus-4-
|
|
6866
|
-
{ value: "
|
|
6867
|
-
{ value: "
|
|
7206
|
+
{ value: "opus", label: "Opus 4.8", sublabel: "Most capable for ambitious work", maxEffort: "max", defaultEffort: "xhigh" },
|
|
7207
|
+
{ value: "claude-opus-4-7", label: "Opus 4.7", sublabel: "Previous generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
|
|
7208
|
+
{ value: "claude-opus-4-6", label: "Opus 4.6", sublabel: "Earlier generation flagship", maxEffort: "max", defaultEffort: "xhigh" },
|
|
7209
|
+
{ value: "sonnet", label: "Sonnet 4.6", sublabel: "Most efficient for everyday tasks", maxEffort: "high", defaultEffort: "high" },
|
|
7210
|
+
{ value: "haiku", label: "Haiku 4.5", sublabel: "Fastest for quick answers", maxEffort: "medium", defaultEffort: "medium" }
|
|
6868
7211
|
];
|
|
6869
7212
|
var DEFAULT_CAPABILITIES = {
|
|
6870
7213
|
effortLevels: ["low", "medium", "high", "xhigh", "max"],
|
|
@@ -6997,7 +7340,7 @@ async function start(opts = {}) {
|
|
|
6997
7340
|
try {
|
|
6998
7341
|
token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
|
|
6999
7342
|
} catch {
|
|
7000
|
-
token = (0,
|
|
7343
|
+
token = (0, import_uuid8.v4)();
|
|
7001
7344
|
await (0, import_promises7.mkdir)(configDir, { recursive: true });
|
|
7002
7345
|
await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
|
|
7003
7346
|
}
|
|
@@ -7032,6 +7375,7 @@ async function start(opts = {}) {
|
|
|
7032
7375
|
const notificationService = new NotificationService(sessionManager, expoChannel);
|
|
7033
7376
|
notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
|
|
7034
7377
|
notificationService.addChannel("mac", new DesktopNotificationChannel(), opts.enableMacNotification !== false);
|
|
7378
|
+
sessionManager.onSessionRemoved((sessionId) => notificationService.releaseSession(sessionId));
|
|
7035
7379
|
const activityPushOpts = opts.activityPush ?? await loadApnsConfigFromFile();
|
|
7036
7380
|
if (activityPushOpts) {
|
|
7037
7381
|
try {
|
|
@@ -7189,6 +7533,7 @@ async function start(opts = {}) {
|
|
|
7189
7533
|
case "kill_session": {
|
|
7190
7534
|
wsBridge.broadcast({ type: "status_change", sessionId: event.sessionId, status: "idle" });
|
|
7191
7535
|
approvalProxy.clearPendingForSession(event.sessionId);
|
|
7536
|
+
approvalProxy.clearPendingQuestionsForSession(event.sessionId);
|
|
7192
7537
|
await sessionManager.killSession(event.sessionId);
|
|
7193
7538
|
wsBridge.broadcast({
|
|
7194
7539
|
type: "session_list",
|
|
@@ -7207,6 +7552,7 @@ async function start(opts = {}) {
|
|
|
7207
7552
|
}
|
|
7208
7553
|
case "answer_question": {
|
|
7209
7554
|
sessionManager.handleQuestionResponse(event.requestId, event.answer);
|
|
7555
|
+
approvalProxy.resolveQuestion(event.requestId, event.answer);
|
|
7210
7556
|
break;
|
|
7211
7557
|
}
|
|
7212
7558
|
case "subscribe": {
|
|
@@ -7636,6 +7982,9 @@ async function start(opts = {}) {
|
|
|
7636
7982
|
decision: decision.decision
|
|
7637
7983
|
});
|
|
7638
7984
|
});
|
|
7985
|
+
approvalProxy.onQuestionResolved((requestId) => {
|
|
7986
|
+
sessionManager.clearPendingQuestion(requestId);
|
|
7987
|
+
});
|
|
7639
7988
|
approvalProxy.onApprovalRequest((request) => {
|
|
7640
7989
|
wsBridge.broadcast({ type: "approval_request", request });
|
|
7641
7990
|
setTimeout(() => {
|
|
@@ -7652,6 +8001,9 @@ async function start(opts = {}) {
|
|
|
7652
8001
|
notificationService.notifyApproval(request, pendingCount);
|
|
7653
8002
|
}, 6e4);
|
|
7654
8003
|
});
|
|
8004
|
+
approvalProxy.setQuestionHandler(
|
|
8005
|
+
(sessionId, toolUseId, questions, requestId) => sessionManager.askQuestion(sessionId, toolUseId, questions, requestId)
|
|
8006
|
+
);
|
|
7655
8007
|
sessionManager.onEvent((event) => {
|
|
7656
8008
|
if (event.type !== "question_request") return;
|
|
7657
8009
|
const { request } = event;
|
|
@@ -7712,8 +8064,30 @@ async function start(opts = {}) {
|
|
|
7712
8064
|
const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
|
|
7713
8065
|
const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
|
|
7714
8066
|
const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
|
|
8067
|
+
const sessionEvictMs = Number(process.env.SESSIX_SESSION_EVICT_MS ?? 2 * 60 * 60 * 1e3);
|
|
8068
|
+
let gcFn;
|
|
8069
|
+
const maybeGc = () => {
|
|
8070
|
+
if (gcFn === void 0) {
|
|
8071
|
+
gcFn = globalThis.gc ?? null;
|
|
8072
|
+
if (!gcFn) {
|
|
8073
|
+
try {
|
|
8074
|
+
(0, import_node_v8.setFlagsFromString)("--expose-gc");
|
|
8075
|
+
const fn = (0, import_node_vm.runInNewContext)("gc");
|
|
8076
|
+
gcFn = typeof fn === "function" ? fn : null;
|
|
8077
|
+
} catch {
|
|
8078
|
+
gcFn = null;
|
|
8079
|
+
}
|
|
8080
|
+
}
|
|
8081
|
+
}
|
|
8082
|
+
if (gcFn) {
|
|
8083
|
+
try {
|
|
8084
|
+
gcFn();
|
|
8085
|
+
} catch {
|
|
8086
|
+
}
|
|
8087
|
+
}
|
|
8088
|
+
};
|
|
7715
8089
|
let idleSweepTimer = null;
|
|
7716
|
-
if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
|
|
8090
|
+
if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0 || sessionEvictMs > 0)) {
|
|
7717
8091
|
idleSweepTimer = setInterval(async () => {
|
|
7718
8092
|
try {
|
|
7719
8093
|
let totalSwept = 0;
|
|
@@ -7732,7 +8106,18 @@ async function start(opts = {}) {
|
|
|
7732
8106
|
swept.forEach(broadcastShrink);
|
|
7733
8107
|
totalSwept += swept.length;
|
|
7734
8108
|
}
|
|
8109
|
+
if (sessionEvictMs > 0 && typeof provider.listEvictableSessions === "function") {
|
|
8110
|
+
const evictable = provider.listEvictableSessions(sessionEvictMs);
|
|
8111
|
+
for (const id of evictable) {
|
|
8112
|
+
await sessionManager.killSession(id);
|
|
8113
|
+
}
|
|
8114
|
+
if (evictable.length > 0) {
|
|
8115
|
+
console.log(`[Server] Idle GC: evicted ${evictable.length} stale session(s)`);
|
|
8116
|
+
totalSwept += evictable.length;
|
|
8117
|
+
}
|
|
8118
|
+
}
|
|
7735
8119
|
}
|
|
8120
|
+
const hasRunning = sessionManager.getActiveSessions().some((s) => s.status === "running");
|
|
7736
8121
|
if (totalSwept > 0) {
|
|
7737
8122
|
console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
|
|
7738
8123
|
wsBridge.broadcast({
|
|
@@ -7740,6 +8125,9 @@ async function start(opts = {}) {
|
|
|
7740
8125
|
sessions: sessionManager.getActiveSessions()
|
|
7741
8126
|
});
|
|
7742
8127
|
}
|
|
8128
|
+
if (totalSwept > 0 || !hasRunning) {
|
|
8129
|
+
maybeGc();
|
|
8130
|
+
}
|
|
7743
8131
|
} catch (err) {
|
|
7744
8132
|
console.error("[Server] Idle GC failed:", err);
|
|
7745
8133
|
}
|
|
@@ -7794,7 +8182,7 @@ async function start(opts = {}) {
|
|
|
7794
8182
|
openPairing: (duration) => pairingManager.open(duration),
|
|
7795
8183
|
closePairing: () => pairingManager.close(),
|
|
7796
8184
|
regenerateToken: async () => {
|
|
7797
|
-
const newToken = (0,
|
|
8185
|
+
const newToken = (0, import_uuid8.v4)();
|
|
7798
8186
|
await (0, import_promises7.mkdir)(configDir, { recursive: true });
|
|
7799
8187
|
await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
|
|
7800
8188
|
instance.token = newToken;
|