sessix-server 0.4.2 → 0.4.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 +1021 -32
- package/dist/server.js +1015 -26
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -307,12 +307,12 @@ function t(key, params) {
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
// src/server.ts
|
|
310
|
-
var
|
|
311
|
-
var
|
|
312
|
-
var
|
|
313
|
-
var
|
|
314
|
-
var
|
|
315
|
-
var
|
|
310
|
+
var import_uuid9 = require("uuid");
|
|
311
|
+
var import_promises7 = require("fs/promises");
|
|
312
|
+
var import_node_os9 = require("os");
|
|
313
|
+
var import_node_path9 = require("path");
|
|
314
|
+
var import_node_child_process11 = require("child_process");
|
|
315
|
+
var import_node_util3 = require("util");
|
|
316
316
|
|
|
317
317
|
// src/providers/ProcessProvider.ts
|
|
318
318
|
var import_child_process = require("child_process");
|
|
@@ -543,6 +543,77 @@ var ProcessProvider = class {
|
|
|
543
543
|
getActiveSessions() {
|
|
544
544
|
return Array.from(this.activeSessions.values()).map((entry) => entry.session);
|
|
545
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* 清理空闲进程
|
|
548
|
+
*
|
|
549
|
+
* 找出所有 status='idle' 且 lastActiveAt 距今超过 maxIdleMs 的活跃进程,
|
|
550
|
+
* kill 进程释放内存。entry 保留在 activeSessions 中,用户下次 sendMessage
|
|
551
|
+
* 走 slow path 自动 --resume 重启进程。
|
|
552
|
+
*
|
|
553
|
+
* @returns 被 sweep 的 sessionId 列表
|
|
554
|
+
*/
|
|
555
|
+
async sweepIdleProcesses(maxIdleMs) {
|
|
556
|
+
const now = Date.now();
|
|
557
|
+
const swept = [];
|
|
558
|
+
for (const [sessionId, entry] of this.activeSessions) {
|
|
559
|
+
if (entry.process.exitCode !== null || entry.process.signalCode !== null) continue;
|
|
560
|
+
if (entry.session.status !== "idle") continue;
|
|
561
|
+
if (now - entry.session.lastActiveAt < maxIdleMs) continue;
|
|
562
|
+
const idleMin = Math.round((now - entry.session.lastActiveAt) / 6e4);
|
|
563
|
+
console.log(`[ProcessProvider] sweeping idle process: ${sessionId} (idle ${idleMin}m)`);
|
|
564
|
+
try {
|
|
565
|
+
entry.process.stdin?.end();
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
await killProcessCrossPlatform(entry.process);
|
|
570
|
+
} catch (err) {
|
|
571
|
+
console.error(`[ProcessProvider] sweep kill failed for ${sessionId}:`, err);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
swept.push(sessionId);
|
|
575
|
+
}
|
|
576
|
+
return swept;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* LRU 上限清理
|
|
580
|
+
*
|
|
581
|
+
* 当活跃进程数超过 maxAlive 时,按 lastActiveAt 升序(最久未用优先)kill
|
|
582
|
+
* 状态为 idle 的进程,直到活跃数回到上限以内。
|
|
583
|
+
* running / waiting_question 状态的进程永远不会被 kill。
|
|
584
|
+
*
|
|
585
|
+
* @returns 被 sweep 的 sessionId 列表
|
|
586
|
+
*/
|
|
587
|
+
async sweepLruProcesses(maxAlive) {
|
|
588
|
+
const swept = [];
|
|
589
|
+
if (maxAlive <= 0) return swept;
|
|
590
|
+
const aliveEntries = Array.from(this.activeSessions.entries()).filter(
|
|
591
|
+
([, e]) => e.process.exitCode === null && e.process.signalCode === null
|
|
592
|
+
);
|
|
593
|
+
if (aliveEntries.length <= maxAlive) return swept;
|
|
594
|
+
const idleSorted = aliveEntries.filter(([, e]) => e.session.status === "idle").sort((a, b) => a[1].session.lastActiveAt - b[1].session.lastActiveAt);
|
|
595
|
+
let aliveCount = aliveEntries.length;
|
|
596
|
+
for (const [sessionId, entry] of idleSorted) {
|
|
597
|
+
if (aliveCount <= maxAlive) break;
|
|
598
|
+
const idleMin = Math.round((Date.now() - entry.session.lastActiveAt) / 6e4);
|
|
599
|
+
console.log(`[ProcessProvider] LRU sweep: ${sessionId} (idle ${idleMin}m, alive=${aliveCount}/${maxAlive})`);
|
|
600
|
+
try {
|
|
601
|
+
entry.process.stdin?.end();
|
|
602
|
+
} catch {
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
await killProcessCrossPlatform(entry.process);
|
|
606
|
+
swept.push(sessionId);
|
|
607
|
+
aliveCount--;
|
|
608
|
+
} catch (err) {
|
|
609
|
+
console.error(`[ProcessProvider] LRU kill failed for ${sessionId}:`, err);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (aliveCount > maxAlive) {
|
|
613
|
+
console.warn(`[ProcessProvider] LRU sweep: ${aliveCount} alive after sweep > limit ${maxAlive}; remaining are running/waiting`);
|
|
614
|
+
}
|
|
615
|
+
return swept;
|
|
616
|
+
}
|
|
546
617
|
// ============================================
|
|
547
618
|
// 私有方法
|
|
548
619
|
// ============================================
|
|
@@ -1770,6 +1841,20 @@ var SessionManager = class {
|
|
|
1770
1841
|
isBufferTruncated(sessionId) {
|
|
1771
1842
|
return this.bufferTruncated.has(sessionId);
|
|
1772
1843
|
}
|
|
1844
|
+
/**
|
|
1845
|
+
* 缩减指定会话的事件缓冲区到最后 N 条,并标记 truncated
|
|
1846
|
+
*
|
|
1847
|
+
* 用于空闲进程被 sweep 后释放内存:缓冲区只为新订阅者重放服务,
|
|
1848
|
+
* 进程已死的会话可以通过 JSONL 文件补全完整历史,不需要保留全部内存事件。
|
|
1849
|
+
* 设置 bufferTruncated 后,客户端 subscribe 收到 session_history 时会从 JSONL 补齐。
|
|
1850
|
+
*/
|
|
1851
|
+
shrinkSessionBuffer(sessionId, keepLast = 100) {
|
|
1852
|
+
const buffer = this.sessionEventBuffers.get(sessionId);
|
|
1853
|
+
if (!buffer || buffer.length <= keepLast) return;
|
|
1854
|
+
buffer.splice(0, buffer.length - keepLast);
|
|
1855
|
+
this.bufferTruncated.add(sessionId);
|
|
1856
|
+
console.log(`[SessionManager] Session ${sessionId}: buffer shrunk to ${keepLast}, marked truncated`);
|
|
1857
|
+
}
|
|
1773
1858
|
/**
|
|
1774
1859
|
* 获取会话的项目路径(用于截断时从 JSONL 补全历史)
|
|
1775
1860
|
*/
|
|
@@ -4694,7 +4779,7 @@ var AuthManager = class extends import_events3.EventEmitter {
|
|
|
4694
4779
|
};
|
|
4695
4780
|
|
|
4696
4781
|
// src/server.ts
|
|
4697
|
-
var
|
|
4782
|
+
var import_promises8 = require("fs/promises");
|
|
4698
4783
|
|
|
4699
4784
|
// src/terminal/TerminalExecutor.ts
|
|
4700
4785
|
var import_node_child_process7 = require("child_process");
|
|
@@ -5197,8 +5282,762 @@ function kindOrder(k) {
|
|
|
5197
5282
|
return k === "device" ? 0 : k === "simulator" ? 1 : k === "mac" ? 2 : 3;
|
|
5198
5283
|
}
|
|
5199
5284
|
|
|
5200
|
-
// src/
|
|
5285
|
+
// src/commands/CommandDiscovery.ts
|
|
5286
|
+
var import_promises5 = require("fs/promises");
|
|
5287
|
+
var import_node_path7 = require("path");
|
|
5288
|
+
var import_node_crypto = require("crypto");
|
|
5289
|
+
var CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
5290
|
+
var MAX_README_BYTES = 256 * 1024;
|
|
5291
|
+
var SUBPACKAGE_DIRS = ["packages", "apps", "crates", "services"];
|
|
5292
|
+
var MAX_SCAN_PER_DIR = 30;
|
|
5293
|
+
var CommandDiscovery = class {
|
|
5294
|
+
cache = /* @__PURE__ */ new Map();
|
|
5295
|
+
async scan(projectPath, refresh = false) {
|
|
5296
|
+
if (!refresh) {
|
|
5297
|
+
const hit = this.cache.get(projectPath);
|
|
5298
|
+
if (hit && hit.expiresAt > Date.now()) return hit.commands;
|
|
5299
|
+
}
|
|
5300
|
+
const collector = [];
|
|
5301
|
+
await Promise.all([
|
|
5302
|
+
this.scanPackageJson(projectPath, "", collector),
|
|
5303
|
+
this.scanMakefile(projectPath, "", collector),
|
|
5304
|
+
this.scanJustfile(projectPath, "", collector),
|
|
5305
|
+
this.scanCargo(projectPath, "", collector),
|
|
5306
|
+
this.scanCompose(projectPath, "", collector),
|
|
5307
|
+
this.scanReadme(projectPath, "README.md", "readme", collector),
|
|
5308
|
+
this.scanReadme(projectPath, "CLAUDE.md", "claude.md", collector)
|
|
5309
|
+
]);
|
|
5310
|
+
for (const sub of SUBPACKAGE_DIRS) {
|
|
5311
|
+
const subRoot = (0, import_node_path7.join)(projectPath, sub);
|
|
5312
|
+
let entries;
|
|
5313
|
+
try {
|
|
5314
|
+
entries = await (0, import_promises5.readdir)(subRoot);
|
|
5315
|
+
} catch {
|
|
5316
|
+
continue;
|
|
5317
|
+
}
|
|
5318
|
+
let scanned = 0;
|
|
5319
|
+
for (const name of entries) {
|
|
5320
|
+
if (name.startsWith(".") || scanned >= MAX_SCAN_PER_DIR) continue;
|
|
5321
|
+
const childAbs = (0, import_node_path7.join)(subRoot, name);
|
|
5322
|
+
try {
|
|
5323
|
+
const s = await (0, import_promises5.stat)(childAbs);
|
|
5324
|
+
if (!s.isDirectory()) continue;
|
|
5325
|
+
} catch {
|
|
5326
|
+
continue;
|
|
5327
|
+
}
|
|
5328
|
+
scanned++;
|
|
5329
|
+
const rel = `${sub}/${name}`;
|
|
5330
|
+
await Promise.all([
|
|
5331
|
+
this.scanPackageJson(projectPath, rel, collector),
|
|
5332
|
+
this.scanCargo(projectPath, rel, collector)
|
|
5333
|
+
]);
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5337
|
+
const deduped = [];
|
|
5338
|
+
for (const c of collector) {
|
|
5339
|
+
if (seen.has(c.id)) continue;
|
|
5340
|
+
seen.add(c.id);
|
|
5341
|
+
deduped.push(c);
|
|
5342
|
+
}
|
|
5343
|
+
deduped.sort((a, b) => {
|
|
5344
|
+
const ca = categoryWeight(a.category) - categoryWeight(b.category);
|
|
5345
|
+
if (ca !== 0) return ca;
|
|
5346
|
+
const sa = sourceWeight(a.source) - sourceWeight(b.source);
|
|
5347
|
+
if (sa !== 0) return sa;
|
|
5348
|
+
return a.title.localeCompare(b.title);
|
|
5349
|
+
});
|
|
5350
|
+
this.cache.set(projectPath, { commands: deduped, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
5351
|
+
return deduped;
|
|
5352
|
+
}
|
|
5353
|
+
invalidate(projectPath) {
|
|
5354
|
+
if (projectPath) this.cache.delete(projectPath);
|
|
5355
|
+
else this.cache.clear();
|
|
5356
|
+
}
|
|
5357
|
+
// ============================================
|
|
5358
|
+
// 各来源扫描器
|
|
5359
|
+
// ============================================
|
|
5360
|
+
async scanPackageJson(rootPath, subDir, out) {
|
|
5361
|
+
const file = subDir ? `${subDir}/package.json` : "package.json";
|
|
5362
|
+
const abs = (0, import_node_path7.join)(rootPath, file);
|
|
5363
|
+
let raw;
|
|
5364
|
+
try {
|
|
5365
|
+
raw = await (0, import_promises5.readFile)(abs, "utf8");
|
|
5366
|
+
} catch {
|
|
5367
|
+
return;
|
|
5368
|
+
}
|
|
5369
|
+
let pkg;
|
|
5370
|
+
try {
|
|
5371
|
+
pkg = JSON.parse(raw);
|
|
5372
|
+
} catch {
|
|
5373
|
+
return;
|
|
5374
|
+
}
|
|
5375
|
+
if (!pkg.scripts) return;
|
|
5376
|
+
for (const [name, script] of Object.entries(pkg.scripts)) {
|
|
5377
|
+
if (typeof script !== "string") continue;
|
|
5378
|
+
const command = subDir ? `npm --workspace=${subDir} run ${name}` : `npm run ${name}`;
|
|
5379
|
+
const title = subDir ? `${pkg.name ?? subDir.split("/").pop()}: ${name}` : name;
|
|
5380
|
+
out.push(makeCommand({
|
|
5381
|
+
title,
|
|
5382
|
+
command,
|
|
5383
|
+
cwd: "",
|
|
5384
|
+
source: "package.json",
|
|
5385
|
+
sourceFile: file,
|
|
5386
|
+
description: script,
|
|
5387
|
+
category: classifyByName(name) ?? classifyByCommand(script)
|
|
5388
|
+
}));
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
async scanMakefile(rootPath, subDir, out) {
|
|
5392
|
+
const file = subDir ? `${subDir}/Makefile` : "Makefile";
|
|
5393
|
+
const abs = (0, import_node_path7.join)(rootPath, file);
|
|
5394
|
+
let raw;
|
|
5395
|
+
try {
|
|
5396
|
+
raw = await (0, import_promises5.readFile)(abs, "utf8");
|
|
5397
|
+
} catch {
|
|
5398
|
+
return;
|
|
5399
|
+
}
|
|
5400
|
+
const lines = raw.split("\n");
|
|
5401
|
+
let lastComment;
|
|
5402
|
+
const targetRegex = /^([a-zA-Z][a-zA-Z0-9_-]*)\s*:(?!=)/;
|
|
5403
|
+
for (const line of lines) {
|
|
5404
|
+
const trim = line.trim();
|
|
5405
|
+
if (trim.startsWith("#")) {
|
|
5406
|
+
lastComment = trim.replace(/^#+\s?/, "").trim() || void 0;
|
|
5407
|
+
continue;
|
|
5408
|
+
}
|
|
5409
|
+
if (trim === "") {
|
|
5410
|
+
lastComment = void 0;
|
|
5411
|
+
continue;
|
|
5412
|
+
}
|
|
5413
|
+
const match = targetRegex.exec(line);
|
|
5414
|
+
if (!match) {
|
|
5415
|
+
lastComment = void 0;
|
|
5416
|
+
continue;
|
|
5417
|
+
}
|
|
5418
|
+
const target = match[1];
|
|
5419
|
+
if (target === ".PHONY" || target === "default" && trim.startsWith("default:")) continue;
|
|
5420
|
+
out.push(makeCommand({
|
|
5421
|
+
title: target,
|
|
5422
|
+
command: `make ${target}`,
|
|
5423
|
+
cwd: subDir,
|
|
5424
|
+
source: "makefile",
|
|
5425
|
+
sourceFile: file,
|
|
5426
|
+
description: lastComment,
|
|
5427
|
+
category: classifyByName(target)
|
|
5428
|
+
}));
|
|
5429
|
+
lastComment = void 0;
|
|
5430
|
+
}
|
|
5431
|
+
}
|
|
5432
|
+
async scanJustfile(rootPath, subDir, out) {
|
|
5433
|
+
const file = subDir ? `${subDir}/justfile` : "justfile";
|
|
5434
|
+
const abs = (0, import_node_path7.join)(rootPath, file);
|
|
5435
|
+
let raw;
|
|
5436
|
+
try {
|
|
5437
|
+
raw = await (0, import_promises5.readFile)(abs, "utf8");
|
|
5438
|
+
} catch {
|
|
5439
|
+
return;
|
|
5440
|
+
}
|
|
5441
|
+
const lines = raw.split("\n");
|
|
5442
|
+
let lastComment;
|
|
5443
|
+
const recipeRegex = /^([a-zA-Z][a-zA-Z0-9_-]*)\s*(?:[a-zA-Z0-9_=" ]*)?\s*:/;
|
|
5444
|
+
for (const line of lines) {
|
|
5445
|
+
const trim = line.trim();
|
|
5446
|
+
if (trim.startsWith("#")) {
|
|
5447
|
+
lastComment = trim.replace(/^#+\s?/, "").trim() || void 0;
|
|
5448
|
+
continue;
|
|
5449
|
+
}
|
|
5450
|
+
if (trim === "") {
|
|
5451
|
+
lastComment = void 0;
|
|
5452
|
+
continue;
|
|
5453
|
+
}
|
|
5454
|
+
if (line.startsWith(" ") || line.startsWith(" ")) continue;
|
|
5455
|
+
const match = recipeRegex.exec(line);
|
|
5456
|
+
if (!match) {
|
|
5457
|
+
lastComment = void 0;
|
|
5458
|
+
continue;
|
|
5459
|
+
}
|
|
5460
|
+
const recipe = match[1];
|
|
5461
|
+
out.push(makeCommand({
|
|
5462
|
+
title: recipe,
|
|
5463
|
+
command: `just ${recipe}`,
|
|
5464
|
+
cwd: subDir,
|
|
5465
|
+
source: "justfile",
|
|
5466
|
+
sourceFile: file,
|
|
5467
|
+
description: lastComment,
|
|
5468
|
+
category: classifyByName(recipe)
|
|
5469
|
+
}));
|
|
5470
|
+
lastComment = void 0;
|
|
5471
|
+
}
|
|
5472
|
+
}
|
|
5473
|
+
async scanCargo(rootPath, subDir, out) {
|
|
5474
|
+
const file = subDir ? `${subDir}/Cargo.toml` : "Cargo.toml";
|
|
5475
|
+
const abs = (0, import_node_path7.join)(rootPath, file);
|
|
5476
|
+
try {
|
|
5477
|
+
await (0, import_promises5.stat)(abs);
|
|
5478
|
+
} catch {
|
|
5479
|
+
return;
|
|
5480
|
+
}
|
|
5481
|
+
const presets = [
|
|
5482
|
+
{ title: "cargo build", command: "cargo build", category: "build", description: "Compile in debug mode" },
|
|
5483
|
+
{ title: "cargo build --release", command: "cargo build --release", category: "build", description: "Compile in release mode" },
|
|
5484
|
+
{ title: "cargo run", command: "cargo run", category: "dev", description: "Build and run" },
|
|
5485
|
+
{ title: "cargo test", command: "cargo test", category: "test", description: "Run all tests" },
|
|
5486
|
+
{ title: "cargo check", command: "cargo check", category: "lint", description: "Type-check without producing binary" },
|
|
5487
|
+
{ title: "cargo clippy", command: "cargo clippy", category: "lint", description: "Lint with clippy" },
|
|
5488
|
+
{ title: "cargo fmt", command: "cargo fmt", category: "lint", description: "Format source" }
|
|
5489
|
+
];
|
|
5490
|
+
for (const p of presets) {
|
|
5491
|
+
out.push(makeCommand({
|
|
5492
|
+
title: subDir ? `${subDir.split("/").pop()}: ${p.title}` : p.title,
|
|
5493
|
+
command: p.command,
|
|
5494
|
+
cwd: subDir,
|
|
5495
|
+
source: "cargo",
|
|
5496
|
+
sourceFile: file,
|
|
5497
|
+
description: p.description,
|
|
5498
|
+
category: p.category
|
|
5499
|
+
}));
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
async scanCompose(rootPath, subDir, out) {
|
|
5503
|
+
for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
|
|
5504
|
+
const file = subDir ? `${subDir}/${name}` : name;
|
|
5505
|
+
try {
|
|
5506
|
+
await (0, import_promises5.stat)((0, import_node_path7.join)(rootPath, file));
|
|
5507
|
+
} catch {
|
|
5508
|
+
continue;
|
|
5509
|
+
}
|
|
5510
|
+
const presets = [
|
|
5511
|
+
{ title: "docker compose up", cmd: "docker compose up", cat: "dev", desc: "Start services" },
|
|
5512
|
+
{ title: "docker compose up -d", cmd: "docker compose up -d", cat: "dev", desc: "Start services in background" },
|
|
5513
|
+
{ title: "docker compose down", cmd: "docker compose down", cat: "other", desc: "Stop and remove services" },
|
|
5514
|
+
{ title: "docker compose build", cmd: "docker compose build", cat: "build", desc: "Build service images" },
|
|
5515
|
+
{ title: "docker compose logs -f", cmd: "docker compose logs -f", cat: "other", desc: "Tail service logs" }
|
|
5516
|
+
];
|
|
5517
|
+
for (const p of presets) {
|
|
5518
|
+
out.push(makeCommand({
|
|
5519
|
+
title: p.title,
|
|
5520
|
+
command: p.cmd,
|
|
5521
|
+
cwd: subDir,
|
|
5522
|
+
source: "compose",
|
|
5523
|
+
sourceFile: file,
|
|
5524
|
+
description: p.desc,
|
|
5525
|
+
category: p.cat
|
|
5526
|
+
}));
|
|
5527
|
+
}
|
|
5528
|
+
return;
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
async scanReadme(rootPath, fileName, source, out) {
|
|
5532
|
+
const abs = (0, import_node_path7.join)(rootPath, fileName);
|
|
5533
|
+
let raw;
|
|
5534
|
+
try {
|
|
5535
|
+
const s = await (0, import_promises5.stat)(abs);
|
|
5536
|
+
if (s.size > MAX_README_BYTES) return;
|
|
5537
|
+
raw = await (0, import_promises5.readFile)(abs, "utf8");
|
|
5538
|
+
} catch {
|
|
5539
|
+
return;
|
|
5540
|
+
}
|
|
5541
|
+
const fenceRegex = /```(?:bash|sh|shell|zsh)\s*\n([\s\S]*?)```/gi;
|
|
5542
|
+
let match;
|
|
5543
|
+
while ((match = fenceRegex.exec(raw)) !== null) {
|
|
5544
|
+
const block = match[1];
|
|
5545
|
+
const blockLines = block.split("\n");
|
|
5546
|
+
let blockHeading;
|
|
5547
|
+
const beforeText = raw.slice(0, match.index).split("\n").reverse();
|
|
5548
|
+
for (const prev of beforeText) {
|
|
5549
|
+
const t2 = prev.trim();
|
|
5550
|
+
if (t2 === "") continue;
|
|
5551
|
+
const head = /^#{1,6}\s+(.+)$/.exec(t2);
|
|
5552
|
+
if (head) blockHeading = head[1].replace(/[#*`]/g, "").trim();
|
|
5553
|
+
break;
|
|
5554
|
+
}
|
|
5555
|
+
const merged = [];
|
|
5556
|
+
let pending = "";
|
|
5557
|
+
for (const rawLine of blockLines) {
|
|
5558
|
+
if (rawLine.trimEnd().endsWith("\\")) {
|
|
5559
|
+
pending += rawLine.trimEnd().slice(0, -1) + " ";
|
|
5560
|
+
continue;
|
|
5561
|
+
}
|
|
5562
|
+
merged.push(pending + rawLine);
|
|
5563
|
+
pending = "";
|
|
5564
|
+
}
|
|
5565
|
+
if (pending) merged.push(pending);
|
|
5566
|
+
for (const rawLine of merged) {
|
|
5567
|
+
const cmd = sanitizeBashLine(rawLine);
|
|
5568
|
+
if (!cmd) continue;
|
|
5569
|
+
const { command: cleanCmd, inlineComment } = splitInlineComment(cmd);
|
|
5570
|
+
const title = synthesizeTitle(cleanCmd);
|
|
5571
|
+
out.push(makeCommand({
|
|
5572
|
+
title,
|
|
5573
|
+
command: cleanCmd,
|
|
5574
|
+
cwd: "",
|
|
5575
|
+
source,
|
|
5576
|
+
sourceFile: fileName,
|
|
5577
|
+
description: inlineComment ?? blockHeading,
|
|
5578
|
+
category: classifyByCommand(cleanCmd)
|
|
5579
|
+
}));
|
|
5580
|
+
}
|
|
5581
|
+
}
|
|
5582
|
+
}
|
|
5583
|
+
};
|
|
5584
|
+
function makeCommand(input) {
|
|
5585
|
+
const id = (0, import_node_crypto.createHash)("sha1").update(`${input.source}|${input.sourceFile}|${input.command}|${input.cwd}`).digest("hex").slice(0, 12);
|
|
5586
|
+
return {
|
|
5587
|
+
id,
|
|
5588
|
+
title: input.title,
|
|
5589
|
+
command: input.command,
|
|
5590
|
+
cwd: input.cwd,
|
|
5591
|
+
source: input.source,
|
|
5592
|
+
sourceFile: input.sourceFile,
|
|
5593
|
+
description: input.description,
|
|
5594
|
+
category: input.category ?? classifyByCommand(input.command) ?? "other"
|
|
5595
|
+
};
|
|
5596
|
+
}
|
|
5597
|
+
function sanitizeBashLine(line) {
|
|
5598
|
+
let l = line.trim();
|
|
5599
|
+
if (!l) return null;
|
|
5600
|
+
if (l.startsWith("#")) return null;
|
|
5601
|
+
l = l.replace(/^[$>]\s*/, "");
|
|
5602
|
+
if (!l) return null;
|
|
5603
|
+
if (/^[A-Z_]+=/.test(l) && !/\s/.test(l)) return null;
|
|
5604
|
+
if (l === "EOF" || l === "EOT") return null;
|
|
5605
|
+
if (l.startsWith("//") || l.startsWith("//#")) return null;
|
|
5606
|
+
if (l.length > 400) return null;
|
|
5607
|
+
if (/<.+>/.test(l) && /your[-_]/.test(l.toLowerCase())) return null;
|
|
5608
|
+
return l;
|
|
5609
|
+
}
|
|
5610
|
+
function synthesizeTitle(cmd) {
|
|
5611
|
+
let work = cmd;
|
|
5612
|
+
while (/^[A-Z_][A-Z0-9_]*=/.test(work)) {
|
|
5613
|
+
const m = /^[A-Z_][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+/.exec(work);
|
|
5614
|
+
if (!m) break;
|
|
5615
|
+
work = work.slice(m[0].length);
|
|
5616
|
+
}
|
|
5617
|
+
const cdMatch = /^cd\s+\S+\s*&&\s*(.+)$/.exec(work);
|
|
5618
|
+
if (cdMatch) work = cdMatch[1];
|
|
5619
|
+
const tokens = work.split(/\s+/).filter(Boolean);
|
|
5620
|
+
const head = tokens.slice(0, 3).join(" ");
|
|
5621
|
+
return head.length > 60 ? head.slice(0, 60) + "\u2026" : head;
|
|
5622
|
+
}
|
|
5623
|
+
function splitInlineComment(line) {
|
|
5624
|
+
let inSingle = false;
|
|
5625
|
+
let inDouble = false;
|
|
5626
|
+
for (let i = 0; i < line.length; i++) {
|
|
5627
|
+
const ch = line[i];
|
|
5628
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
5629
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
5630
|
+
else if (ch === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
5631
|
+
const cmd = line.slice(0, i).trim();
|
|
5632
|
+
const comment = line.slice(i + 1).trim();
|
|
5633
|
+
return { command: cmd, inlineComment: comment.length > 0 ? comment : void 0 };
|
|
5634
|
+
}
|
|
5635
|
+
}
|
|
5636
|
+
return { command: line };
|
|
5637
|
+
}
|
|
5638
|
+
function classifyByName(name) {
|
|
5639
|
+
const lower = name.toLowerCase();
|
|
5640
|
+
if (/(^|[:_-])(build|compile|bundle|prebuild)([:_-]|$)/.test(lower)) return "build";
|
|
5641
|
+
if (/(^|[:_-])(test|spec|jest|vitest|e2e)([:_-]|$)/.test(lower)) return "test";
|
|
5642
|
+
if (/(^|[:_-])(dev|start|serve|watch|run)([:_-]|$)/.test(lower)) return "dev";
|
|
5643
|
+
if (/(^|[:_-])(lint|format|fmt|check|typecheck)([:_-]|$)/.test(lower)) return "lint";
|
|
5644
|
+
if (/(^|[:_-])(install|setup|init|bootstrap)([:_-]|$)/.test(lower)) return "install";
|
|
5645
|
+
if (/(^|[:_-])(deploy|publish|release|ship)([:_-]|$)/.test(lower)) return "deploy";
|
|
5646
|
+
return void 0;
|
|
5647
|
+
}
|
|
5648
|
+
function classifyByCommand(cmd) {
|
|
5649
|
+
const lower = cmd.toLowerCase();
|
|
5650
|
+
if (/\b(build|compile|bundle|prebuild|tsup|webpack|esbuild|vite build|next build)\b/.test(lower)) return "build";
|
|
5651
|
+
if (/\b(test|jest|vitest|mocha|pytest|cargo test|go test)\b/.test(lower)) return "test";
|
|
5652
|
+
if (/\b(dev|start|serve|watch|nodemon|tsx watch|next dev|expo start)\b/.test(lower)) return "dev";
|
|
5653
|
+
if (/\b(lint|eslint|tsc|tslint|fmt|format|prettier|clippy)\b/.test(lower)) return "lint";
|
|
5654
|
+
if (/\b(install|setup|bootstrap)\b/.test(lower) && !/\binstall\s+/.test(lower)) return "install";
|
|
5655
|
+
if (/^npm install\b|^pnpm install\b|^yarn install\b|^yarn\s*$|^pnpm\s*$/.test(lower)) return "install";
|
|
5656
|
+
if (/\b(deploy|publish|release)\b/.test(lower)) return "deploy";
|
|
5657
|
+
return "other";
|
|
5658
|
+
}
|
|
5659
|
+
function categoryWeight(c) {
|
|
5660
|
+
return {
|
|
5661
|
+
dev: 0,
|
|
5662
|
+
build: 1,
|
|
5663
|
+
test: 2,
|
|
5664
|
+
lint: 3,
|
|
5665
|
+
install: 4,
|
|
5666
|
+
deploy: 5,
|
|
5667
|
+
other: 6
|
|
5668
|
+
}[c];
|
|
5669
|
+
}
|
|
5670
|
+
function sourceWeight(s) {
|
|
5671
|
+
return {
|
|
5672
|
+
"package.json": 0,
|
|
5673
|
+
makefile: 1,
|
|
5674
|
+
justfile: 2,
|
|
5675
|
+
taskfile: 3,
|
|
5676
|
+
cargo: 4,
|
|
5677
|
+
compose: 5,
|
|
5678
|
+
readme: 6,
|
|
5679
|
+
"claude.md": 7
|
|
5680
|
+
}[s];
|
|
5681
|
+
}
|
|
5682
|
+
|
|
5683
|
+
// src/git/GitExecutor.ts
|
|
5201
5684
|
var import_node_child_process9 = require("child_process");
|
|
5685
|
+
var import_node_util2 = require("util");
|
|
5686
|
+
var import_uuid7 = require("uuid");
|
|
5687
|
+
var execAsync2 = (0, import_node_util2.promisify)(import_node_child_process9.exec);
|
|
5688
|
+
var STATUS_TIMEOUT_MS = 15e3;
|
|
5689
|
+
var COMMIT_TIMEOUT_MS = 6e4;
|
|
5690
|
+
var PUSH_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
5691
|
+
var GitExecutor = class {
|
|
5692
|
+
eventCallbacks = [];
|
|
5693
|
+
onEvent(callback) {
|
|
5694
|
+
this.eventCallbacks.push(callback);
|
|
5695
|
+
return () => {
|
|
5696
|
+
const idx = this.eventCallbacks.indexOf(callback);
|
|
5697
|
+
if (idx !== -1) this.eventCallbacks.splice(idx, 1);
|
|
5698
|
+
};
|
|
5699
|
+
}
|
|
5700
|
+
emit(event) {
|
|
5701
|
+
for (const cb of this.eventCallbacks) {
|
|
5702
|
+
try {
|
|
5703
|
+
cb(event);
|
|
5704
|
+
} catch (err) {
|
|
5705
|
+
console.error("[GitExecutor] Event callback error:", err);
|
|
5706
|
+
}
|
|
5707
|
+
}
|
|
5708
|
+
}
|
|
5709
|
+
async detectStatus(projectPath) {
|
|
5710
|
+
const opts = { cwd: projectPath, timeout: STATUS_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 };
|
|
5711
|
+
try {
|
|
5712
|
+
await execAsync2("git rev-parse --is-inside-work-tree", opts);
|
|
5713
|
+
} catch {
|
|
5714
|
+
return { isRepo: false, hasRemote: false, changes: [] };
|
|
5715
|
+
}
|
|
5716
|
+
let root;
|
|
5717
|
+
try {
|
|
5718
|
+
const { stdout } = await execAsync2("git rev-parse --show-toplevel", opts);
|
|
5719
|
+
root = stdout.trim();
|
|
5720
|
+
} catch {
|
|
5721
|
+
}
|
|
5722
|
+
let branch;
|
|
5723
|
+
try {
|
|
5724
|
+
const { stdout } = await execAsync2("git symbolic-ref --short HEAD", opts);
|
|
5725
|
+
branch = stdout.trim();
|
|
5726
|
+
} catch {
|
|
5727
|
+
branch = void 0;
|
|
5728
|
+
}
|
|
5729
|
+
let upstream;
|
|
5730
|
+
try {
|
|
5731
|
+
const { stdout } = await execAsync2("git rev-parse --abbrev-ref --symbolic-full-name @{u}", opts);
|
|
5732
|
+
upstream = stdout.trim();
|
|
5733
|
+
} catch {
|
|
5734
|
+
}
|
|
5735
|
+
let hasRemote = false;
|
|
5736
|
+
try {
|
|
5737
|
+
const { stdout } = await execAsync2("git remote", opts);
|
|
5738
|
+
hasRemote = stdout.trim().length > 0;
|
|
5739
|
+
} catch {
|
|
5740
|
+
}
|
|
5741
|
+
let ahead;
|
|
5742
|
+
let behind;
|
|
5743
|
+
if (upstream) {
|
|
5744
|
+
try {
|
|
5745
|
+
const { stdout } = await execAsync2("git rev-list --left-right --count HEAD...@{u}", opts);
|
|
5746
|
+
const [a, b] = stdout.trim().split(/\s+/);
|
|
5747
|
+
ahead = Number(a);
|
|
5748
|
+
behind = Number(b);
|
|
5749
|
+
} catch {
|
|
5750
|
+
}
|
|
5751
|
+
}
|
|
5752
|
+
const changes = await this.parsePorcelain(projectPath);
|
|
5753
|
+
return {
|
|
5754
|
+
isRepo: true,
|
|
5755
|
+
root,
|
|
5756
|
+
branch,
|
|
5757
|
+
upstream,
|
|
5758
|
+
hasRemote,
|
|
5759
|
+
changes,
|
|
5760
|
+
ahead,
|
|
5761
|
+
behind
|
|
5762
|
+
};
|
|
5763
|
+
}
|
|
5764
|
+
async parsePorcelain(projectPath) {
|
|
5765
|
+
let stdout;
|
|
5766
|
+
try {
|
|
5767
|
+
const r = await execAsync2("git status --porcelain=v1 -z", {
|
|
5768
|
+
cwd: projectPath,
|
|
5769
|
+
timeout: STATUS_TIMEOUT_MS,
|
|
5770
|
+
maxBuffer: 8 * 1024 * 1024
|
|
5771
|
+
});
|
|
5772
|
+
stdout = r.stdout;
|
|
5773
|
+
} catch {
|
|
5774
|
+
return [];
|
|
5775
|
+
}
|
|
5776
|
+
const changes = [];
|
|
5777
|
+
const records = stdout.split("\0");
|
|
5778
|
+
for (let i = 0; i < records.length; i++) {
|
|
5779
|
+
const rec = records[i];
|
|
5780
|
+
if (!rec) continue;
|
|
5781
|
+
if (rec.length < 3) continue;
|
|
5782
|
+
const x = rec.charAt(0);
|
|
5783
|
+
const y = rec.charAt(1);
|
|
5784
|
+
const path2 = rec.slice(3);
|
|
5785
|
+
const isRename = x === "R" || x === "C";
|
|
5786
|
+
if (isRename) {
|
|
5787
|
+
i += 1;
|
|
5788
|
+
}
|
|
5789
|
+
const untracked = x === "?" && y === "?";
|
|
5790
|
+
const staged = !untracked && x !== " " && x !== "?";
|
|
5791
|
+
changes.push({
|
|
5792
|
+
path: path2,
|
|
5793
|
+
staged,
|
|
5794
|
+
untracked,
|
|
5795
|
+
code: `${x}${y}`
|
|
5796
|
+
});
|
|
5797
|
+
}
|
|
5798
|
+
return changes;
|
|
5799
|
+
}
|
|
5800
|
+
/**
|
|
5801
|
+
* 执行 commit(可选连带 push)。
|
|
5802
|
+
* - 若提供 files:先 git add 这些路径
|
|
5803
|
+
* - 若未提供 files:默认 git add -A(提交所有变更)
|
|
5804
|
+
*/
|
|
5805
|
+
async commit(sessionId, projectPath, message, files, alsoPush) {
|
|
5806
|
+
const opId = (0, import_uuid7.v4)();
|
|
5807
|
+
this.runSequence(sessionId, opId, "commit", projectPath, [
|
|
5808
|
+
files && files.length > 0 ? ["git", "add", "--", ...files] : ["git", "add", "-A"],
|
|
5809
|
+
["git", "commit", "-m", message]
|
|
5810
|
+
], COMMIT_TIMEOUT_MS).then(async (ok) => {
|
|
5811
|
+
if (ok && alsoPush) {
|
|
5812
|
+
await this.runSequence(sessionId, opId, "push", projectPath, [
|
|
5813
|
+
["git", "push"]
|
|
5814
|
+
], PUSH_TIMEOUT_MS);
|
|
5815
|
+
}
|
|
5816
|
+
}).catch((err) => {
|
|
5817
|
+
console.error("[GitExecutor] commit error:", err);
|
|
5818
|
+
});
|
|
5819
|
+
return opId;
|
|
5820
|
+
}
|
|
5821
|
+
async push(sessionId, projectPath) {
|
|
5822
|
+
const opId = (0, import_uuid7.v4)();
|
|
5823
|
+
this.runSequence(sessionId, opId, "push", projectPath, [
|
|
5824
|
+
["git", "push"]
|
|
5825
|
+
], PUSH_TIMEOUT_MS).catch((err) => {
|
|
5826
|
+
console.error("[GitExecutor] push error:", err);
|
|
5827
|
+
});
|
|
5828
|
+
return opId;
|
|
5829
|
+
}
|
|
5830
|
+
/**
|
|
5831
|
+
* 顺序执行一组命令,任一失败则停止。返回是否全部成功。
|
|
5832
|
+
* 每条命令的输出和最后一条命令的退出事件统一打到同一 phase。
|
|
5833
|
+
*/
|
|
5834
|
+
async runSequence(sessionId, opId, phase, projectPath, commands, timeoutMs) {
|
|
5835
|
+
let lastCode = 0;
|
|
5836
|
+
let lastSignal = null;
|
|
5837
|
+
for (const cmd of commands) {
|
|
5838
|
+
const { code, signal } = await this.runOne(sessionId, opId, phase, projectPath, cmd, timeoutMs);
|
|
5839
|
+
lastCode = code;
|
|
5840
|
+
lastSignal = signal;
|
|
5841
|
+
if (code !== 0) break;
|
|
5842
|
+
}
|
|
5843
|
+
this.emit({ type: "git_exit", sessionId, opId, phase, code: lastCode, signal: lastSignal });
|
|
5844
|
+
return lastCode === 0;
|
|
5845
|
+
}
|
|
5846
|
+
runOne(sessionId, opId, phase, projectPath, cmd, timeoutMs) {
|
|
5847
|
+
return new Promise((resolve) => {
|
|
5848
|
+
const display = cmd.map((p) => /\s/.test(p) ? `"${p}"` : p).join(" ");
|
|
5849
|
+
this.emit({
|
|
5850
|
+
type: "git_output",
|
|
5851
|
+
sessionId,
|
|
5852
|
+
opId,
|
|
5853
|
+
phase,
|
|
5854
|
+
stream: "stdout",
|
|
5855
|
+
data: `$ ${display}
|
|
5856
|
+
`
|
|
5857
|
+
});
|
|
5858
|
+
let proc;
|
|
5859
|
+
try {
|
|
5860
|
+
proc = (0, import_node_child_process9.spawn)(cmd[0], cmd.slice(1), {
|
|
5861
|
+
cwd: projectPath,
|
|
5862
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
5863
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
5864
|
+
});
|
|
5865
|
+
} catch (err) {
|
|
5866
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5867
|
+
this.emit({ type: "git_output", sessionId, opId, phase, stream: "stderr", data: `[spawn error] ${msg}
|
|
5868
|
+
` });
|
|
5869
|
+
resolve({ code: 1, signal: null });
|
|
5870
|
+
return;
|
|
5871
|
+
}
|
|
5872
|
+
proc.stdout?.on("data", (chunk) => {
|
|
5873
|
+
this.emit({ type: "git_output", sessionId, opId, phase, stream: "stdout", data: chunk.toString() });
|
|
5874
|
+
});
|
|
5875
|
+
proc.stderr?.on("data", (chunk) => {
|
|
5876
|
+
this.emit({ type: "git_output", sessionId, opId, phase, stream: "stderr", data: chunk.toString() });
|
|
5877
|
+
});
|
|
5878
|
+
proc.on("error", (err) => {
|
|
5879
|
+
this.emit({ type: "git_output", sessionId, opId, phase, stream: "stderr", data: `[error] ${err.message}
|
|
5880
|
+
` });
|
|
5881
|
+
});
|
|
5882
|
+
const timer = setTimeout(() => {
|
|
5883
|
+
try {
|
|
5884
|
+
proc.kill("SIGTERM");
|
|
5885
|
+
} catch {
|
|
5886
|
+
}
|
|
5887
|
+
}, timeoutMs);
|
|
5888
|
+
proc.on("exit", (code, signal) => {
|
|
5889
|
+
clearTimeout(timer);
|
|
5890
|
+
resolve({ code, signal });
|
|
5891
|
+
});
|
|
5892
|
+
});
|
|
5893
|
+
}
|
|
5894
|
+
};
|
|
5895
|
+
|
|
5896
|
+
// src/scheduling/ScheduledSessionManager.ts
|
|
5897
|
+
var import_promises6 = require("fs/promises");
|
|
5898
|
+
var import_node_os8 = require("os");
|
|
5899
|
+
var import_node_path8 = require("path");
|
|
5900
|
+
var import_uuid8 = require("uuid");
|
|
5901
|
+
var MAX_TIMEOUT_MS = 2147483647;
|
|
5902
|
+
var ScheduledSessionManager = class {
|
|
5903
|
+
tasks = /* @__PURE__ */ new Map();
|
|
5904
|
+
storeFile;
|
|
5905
|
+
onFire;
|
|
5906
|
+
onChange;
|
|
5907
|
+
onFired;
|
|
5908
|
+
persistTimer = null;
|
|
5909
|
+
constructor(opts) {
|
|
5910
|
+
this.storeFile = opts.storeFile ?? (0, import_node_path8.join)((0, import_node_os8.homedir)(), ".sessix", "scheduled-sessions.json");
|
|
5911
|
+
this.onFire = opts.onFire;
|
|
5912
|
+
this.onChange = opts.onChange;
|
|
5913
|
+
this.onFired = opts.onFired;
|
|
5914
|
+
}
|
|
5915
|
+
/** 启动时从磁盘恢复任务表,已过期的立刻触发 */
|
|
5916
|
+
async load() {
|
|
5917
|
+
let raw;
|
|
5918
|
+
try {
|
|
5919
|
+
raw = await (0, import_promises6.readFile)(this.storeFile, "utf8");
|
|
5920
|
+
} catch {
|
|
5921
|
+
return;
|
|
5922
|
+
}
|
|
5923
|
+
let parsed;
|
|
5924
|
+
try {
|
|
5925
|
+
parsed = JSON.parse(raw);
|
|
5926
|
+
} catch {
|
|
5927
|
+
return;
|
|
5928
|
+
}
|
|
5929
|
+
if (!Array.isArray(parsed)) return;
|
|
5930
|
+
for (const item of parsed) {
|
|
5931
|
+
if (!isValidTask(item)) continue;
|
|
5932
|
+
this.scheduleTimer(item);
|
|
5933
|
+
}
|
|
5934
|
+
}
|
|
5935
|
+
/** 注册一个定时任务(payload 由调用方校验) */
|
|
5936
|
+
schedule(scheduledAt, payload) {
|
|
5937
|
+
const task = {
|
|
5938
|
+
id: (0, import_uuid8.v4)(),
|
|
5939
|
+
scheduledAt,
|
|
5940
|
+
createdAt: Date.now(),
|
|
5941
|
+
payload
|
|
5942
|
+
};
|
|
5943
|
+
this.scheduleTimer(task);
|
|
5944
|
+
this.persist();
|
|
5945
|
+
this.notifyChange();
|
|
5946
|
+
return task;
|
|
5947
|
+
}
|
|
5948
|
+
/** 取消任务,返回是否成功 */
|
|
5949
|
+
cancel(id) {
|
|
5950
|
+
const entry = this.tasks.get(id);
|
|
5951
|
+
if (!entry) return false;
|
|
5952
|
+
clearTimeout(entry.timer);
|
|
5953
|
+
this.tasks.delete(id);
|
|
5954
|
+
this.persist();
|
|
5955
|
+
this.notifyChange();
|
|
5956
|
+
return true;
|
|
5957
|
+
}
|
|
5958
|
+
/** 列出所有未触发的任务(按时间升序) */
|
|
5959
|
+
list() {
|
|
5960
|
+
return [...this.tasks.values()].map((e) => e.task).sort((a, b) => a.scheduledAt - b.scheduledAt);
|
|
5961
|
+
}
|
|
5962
|
+
/** 优雅关闭(清空定时器,不删除磁盘任务) */
|
|
5963
|
+
destroy() {
|
|
5964
|
+
for (const { timer } of this.tasks.values()) clearTimeout(timer);
|
|
5965
|
+
this.tasks.clear();
|
|
5966
|
+
if (this.persistTimer) {
|
|
5967
|
+
clearTimeout(this.persistTimer);
|
|
5968
|
+
this.persistTimer = null;
|
|
5969
|
+
}
|
|
5970
|
+
}
|
|
5971
|
+
// ============================================
|
|
5972
|
+
// 内部
|
|
5973
|
+
// ============================================
|
|
5974
|
+
scheduleTimer(task) {
|
|
5975
|
+
const delay = Math.max(0, task.scheduledAt - Date.now());
|
|
5976
|
+
const armDelay = Math.min(delay, MAX_TIMEOUT_MS);
|
|
5977
|
+
const timer = setTimeout(() => {
|
|
5978
|
+
const remaining = task.scheduledAt - Date.now();
|
|
5979
|
+
if (remaining > 1e3) {
|
|
5980
|
+
this.scheduleTimer(task);
|
|
5981
|
+
return;
|
|
5982
|
+
}
|
|
5983
|
+
this.fire(task).catch((err) => {
|
|
5984
|
+
console.error("[ScheduledSessionManager] fire error:", err);
|
|
5985
|
+
});
|
|
5986
|
+
}, armDelay);
|
|
5987
|
+
this.tasks.set(task.id, { task, timer });
|
|
5988
|
+
}
|
|
5989
|
+
async fire(task) {
|
|
5990
|
+
const entry = this.tasks.get(task.id);
|
|
5991
|
+
if (!entry) return;
|
|
5992
|
+
clearTimeout(entry.timer);
|
|
5993
|
+
this.tasks.delete(task.id);
|
|
5994
|
+
this.persist();
|
|
5995
|
+
this.notifyChange();
|
|
5996
|
+
try {
|
|
5997
|
+
const result = await this.onFire(task);
|
|
5998
|
+
this.onFired?.({ id: task.id, sessionId: result.sessionId });
|
|
5999
|
+
} catch (err) {
|
|
6000
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6001
|
+
console.error(`[ScheduledSessionManager] fire failed for task ${task.id}: ${message}`);
|
|
6002
|
+
this.onFired?.({ id: task.id, error: message });
|
|
6003
|
+
}
|
|
6004
|
+
}
|
|
6005
|
+
notifyChange() {
|
|
6006
|
+
if (!this.onChange) return;
|
|
6007
|
+
this.onChange(this.list());
|
|
6008
|
+
}
|
|
6009
|
+
/** 防抖持久化(500ms) */
|
|
6010
|
+
persist() {
|
|
6011
|
+
if (this.persistTimer) clearTimeout(this.persistTimer);
|
|
6012
|
+
this.persistTimer = setTimeout(() => {
|
|
6013
|
+
this.persistTimer = null;
|
|
6014
|
+
const tasks = [...this.tasks.values()].map((e) => e.task);
|
|
6015
|
+
(0, import_promises6.mkdir)((0, import_node_path8.join)(this.storeFile, ".."), { recursive: true }).then(() => (0, import_promises6.writeFile)(this.storeFile, JSON.stringify(tasks, null, 2), "utf8")).catch((err) => {
|
|
6016
|
+
console.error("[ScheduledSessionManager] persist error:", err);
|
|
6017
|
+
});
|
|
6018
|
+
}, 500);
|
|
6019
|
+
}
|
|
6020
|
+
};
|
|
6021
|
+
function isValidTask(value) {
|
|
6022
|
+
if (!value || typeof value !== "object") return false;
|
|
6023
|
+
const v = value;
|
|
6024
|
+
if (typeof v.id !== "string" || typeof v.scheduledAt !== "number" || typeof v.createdAt !== "number") {
|
|
6025
|
+
return false;
|
|
6026
|
+
}
|
|
6027
|
+
const payload = v.payload;
|
|
6028
|
+
if (!payload || typeof payload !== "object") return false;
|
|
6029
|
+
const p = payload;
|
|
6030
|
+
if (p.kind === "create") {
|
|
6031
|
+
return typeof p.projectPath === "string" && typeof p.message === "string";
|
|
6032
|
+
}
|
|
6033
|
+
if (p.kind === "send") {
|
|
6034
|
+
return typeof p.sessionId === "string" && typeof p.message === "string";
|
|
6035
|
+
}
|
|
6036
|
+
return false;
|
|
6037
|
+
}
|
|
6038
|
+
|
|
6039
|
+
// src/utils/cliCapabilities.ts
|
|
6040
|
+
var import_node_child_process10 = require("child_process");
|
|
5202
6041
|
var DEFAULT_CAPABILITIES = {
|
|
5203
6042
|
effortLevels: ["low", "medium", "high", "xhigh", "max"]
|
|
5204
6043
|
};
|
|
@@ -5226,7 +6065,7 @@ async function parseCliCapabilities() {
|
|
|
5226
6065
|
}
|
|
5227
6066
|
function runCli(path2, args) {
|
|
5228
6067
|
return new Promise((resolve) => {
|
|
5229
|
-
(0,
|
|
6068
|
+
(0, import_node_child_process10.execFile)(path2, args, { timeout: 5e3 }, (err, stdout) => {
|
|
5230
6069
|
if (err) {
|
|
5231
6070
|
console.warn(`[CliCapabilities] Failed to run ${path2} ${args.join(" ")}:`, err.message);
|
|
5232
6071
|
resolve(null);
|
|
@@ -5240,11 +6079,11 @@ function runCli(path2, args) {
|
|
|
5240
6079
|
// src/server.ts
|
|
5241
6080
|
var WS_PORT = 3745;
|
|
5242
6081
|
var HTTP_PORT = 3746;
|
|
5243
|
-
var
|
|
6082
|
+
var execAsync3 = (0, import_node_util3.promisify)(import_node_child_process11.exec);
|
|
5244
6083
|
async function killPortProcess(port) {
|
|
5245
6084
|
try {
|
|
5246
6085
|
if (isWindows) {
|
|
5247
|
-
const { stdout } = await
|
|
6086
|
+
const { stdout } = await execAsync3(
|
|
5248
6087
|
`netstat -ano | findstr :${port} | findstr LISTENING`
|
|
5249
6088
|
);
|
|
5250
6089
|
const pids = /* @__PURE__ */ new Set();
|
|
@@ -5254,14 +6093,14 @@ async function killPortProcess(port) {
|
|
|
5254
6093
|
if (pid && /^\d+$/.test(pid) && pid !== "0") pids.add(pid);
|
|
5255
6094
|
}
|
|
5256
6095
|
for (const pid of pids) {
|
|
5257
|
-
await
|
|
6096
|
+
await execAsync3(`taskkill /PID ${pid} /F`).catch(() => {
|
|
5258
6097
|
});
|
|
5259
6098
|
}
|
|
5260
6099
|
} else {
|
|
5261
|
-
const { stdout } = await
|
|
6100
|
+
const { stdout } = await execAsync3(`lsof -ti :${port}`);
|
|
5262
6101
|
const pids = stdout.trim().split("\n").filter((p) => p && /^\d+$/.test(p));
|
|
5263
6102
|
if (pids.length > 0) {
|
|
5264
|
-
await
|
|
6103
|
+
await execAsync3(`kill -9 ${pids.join(" ")}`);
|
|
5265
6104
|
}
|
|
5266
6105
|
}
|
|
5267
6106
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
@@ -5282,8 +6121,8 @@ async function createWithRetry(label, port, factory) {
|
|
|
5282
6121
|
}
|
|
5283
6122
|
}
|
|
5284
6123
|
async function start(opts = {}) {
|
|
5285
|
-
const configDir = (0,
|
|
5286
|
-
const tokenFile = (0,
|
|
6124
|
+
const configDir = (0, import_node_path9.join)((0, import_node_os9.homedir)(), ".sessix");
|
|
6125
|
+
const tokenFile = (0, import_node_path9.join)(configDir, "token");
|
|
5287
6126
|
let token;
|
|
5288
6127
|
if (opts.token !== void 0) {
|
|
5289
6128
|
token = opts.token;
|
|
@@ -5293,11 +6132,11 @@ async function start(opts = {}) {
|
|
|
5293
6132
|
token = envToken;
|
|
5294
6133
|
} else {
|
|
5295
6134
|
try {
|
|
5296
|
-
token = (await (0,
|
|
6135
|
+
token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
|
|
5297
6136
|
} catch {
|
|
5298
|
-
token = (0,
|
|
5299
|
-
await (0,
|
|
5300
|
-
await (0,
|
|
6137
|
+
token = (0, import_uuid9.v4)();
|
|
6138
|
+
await (0, import_promises7.mkdir)(configDir, { recursive: true });
|
|
6139
|
+
await (0, import_promises7.writeFile)(tokenFile, token, "utf8");
|
|
5301
6140
|
}
|
|
5302
6141
|
}
|
|
5303
6142
|
}
|
|
@@ -5305,6 +6144,9 @@ async function start(opts = {}) {
|
|
|
5305
6144
|
const sessionManager = new SessionManager(providerFactory);
|
|
5306
6145
|
const terminalExecutor = new TerminalExecutor();
|
|
5307
6146
|
const xcodeBuildExecutor = new XcodeBuildExecutor();
|
|
6147
|
+
const commandDiscovery = new CommandDiscovery();
|
|
6148
|
+
const gitExecutor = new GitExecutor();
|
|
6149
|
+
let scheduledManager = null;
|
|
5308
6150
|
const approvalProxy = await createWithRetry(
|
|
5309
6151
|
"ApprovalProxy",
|
|
5310
6152
|
HTTP_PORT,
|
|
@@ -5343,7 +6185,7 @@ async function start(opts = {}) {
|
|
|
5343
6185
|
let mdnsService = null;
|
|
5344
6186
|
const pairingManager = new PairingManager({
|
|
5345
6187
|
token,
|
|
5346
|
-
serverName: (0,
|
|
6188
|
+
serverName: (0, import_node_os9.hostname)(),
|
|
5347
6189
|
version: "0.2.0",
|
|
5348
6190
|
onStateChange: (state) => mdnsService?.updatePairingState(state)
|
|
5349
6191
|
});
|
|
@@ -5372,6 +6214,49 @@ async function start(opts = {}) {
|
|
|
5372
6214
|
const broadcastUnreadSessions = () => {
|
|
5373
6215
|
wsBridge.broadcast({ type: "unread_sessions", sessionIds: Array.from(unreadSessionIds) });
|
|
5374
6216
|
};
|
|
6217
|
+
scheduledManager = new ScheduledSessionManager({
|
|
6218
|
+
onFire: async (task) => {
|
|
6219
|
+
const p = task.payload;
|
|
6220
|
+
if (p.kind === "create") {
|
|
6221
|
+
await (0, import_promises7.mkdir)(p.projectPath, { recursive: true });
|
|
6222
|
+
const session = await sessionManager.createSession(
|
|
6223
|
+
p.projectPath,
|
|
6224
|
+
p.message,
|
|
6225
|
+
p.resumeSessionId,
|
|
6226
|
+
p.newSessionId,
|
|
6227
|
+
p.model,
|
|
6228
|
+
p.permissionMode,
|
|
6229
|
+
p.effort,
|
|
6230
|
+
void 0,
|
|
6231
|
+
p.agentType
|
|
6232
|
+
);
|
|
6233
|
+
wsBridge.broadcast({ type: "session_list", sessions: sessionManager.getActiveSessions() });
|
|
6234
|
+
return { sessionId: session.id };
|
|
6235
|
+
}
|
|
6236
|
+
const active = sessionManager.getActiveSessions().find((s) => s.id === p.sessionId);
|
|
6237
|
+
if (active) {
|
|
6238
|
+
await sessionManager.sendMessage(p.sessionId, p.message, p.permissionMode);
|
|
6239
|
+
} else {
|
|
6240
|
+
await sessionManager.createSession(
|
|
6241
|
+
p.projectPath,
|
|
6242
|
+
p.message,
|
|
6243
|
+
p.sessionId,
|
|
6244
|
+
void 0,
|
|
6245
|
+
void 0,
|
|
6246
|
+
p.permissionMode
|
|
6247
|
+
);
|
|
6248
|
+
}
|
|
6249
|
+
wsBridge.broadcast({ type: "session_list", sessions: sessionManager.getActiveSessions() });
|
|
6250
|
+
return { sessionId: p.sessionId };
|
|
6251
|
+
},
|
|
6252
|
+
onChange: (tasks) => {
|
|
6253
|
+
wsBridge.broadcast({ type: "scheduled_session_list", tasks });
|
|
6254
|
+
},
|
|
6255
|
+
onFired: (event) => {
|
|
6256
|
+
wsBridge.broadcast({ type: "scheduled_session_fired", ...event });
|
|
6257
|
+
}
|
|
6258
|
+
});
|
|
6259
|
+
await scheduledManager.load();
|
|
5375
6260
|
wsBridge.onConnection(async (ws) => {
|
|
5376
6261
|
const result = await getProjects();
|
|
5377
6262
|
if (result.ok) {
|
|
@@ -5393,12 +6278,15 @@ async function start(opts = {}) {
|
|
|
5393
6278
|
if (cliCapabilities) {
|
|
5394
6279
|
wsBridge.send(ws, { type: "cli_info", effortLevels: cliCapabilities.effortLevels, version: cliCapabilities.version });
|
|
5395
6280
|
}
|
|
6281
|
+
if (scheduledManager) {
|
|
6282
|
+
wsBridge.send(ws, { type: "scheduled_session_list", tasks: scheduledManager.list() });
|
|
6283
|
+
}
|
|
5396
6284
|
});
|
|
5397
6285
|
wsBridge.onClientEvent(async (event, ws) => {
|
|
5398
6286
|
try {
|
|
5399
6287
|
switch (event.type) {
|
|
5400
6288
|
case "create_session": {
|
|
5401
|
-
await (0,
|
|
6289
|
+
await (0, import_promises7.mkdir)(event.projectPath, { recursive: true });
|
|
5402
6290
|
const resumeId = event.resumeSessionId ?? event.newSessionId;
|
|
5403
6291
|
if (resumeId) sessionFileWatcher.unwatch(resumeId);
|
|
5404
6292
|
await sessionManager.createSession(
|
|
@@ -5586,7 +6474,7 @@ async function start(opts = {}) {
|
|
|
5586
6474
|
if (!isStreaming) {
|
|
5587
6475
|
const filePath = getSessionFilePath(event.projectPath, event.sessionId);
|
|
5588
6476
|
try {
|
|
5589
|
-
const fileStat = await (0,
|
|
6477
|
+
const fileStat = await (0, import_promises8.stat)(filePath);
|
|
5590
6478
|
sessionFileWatcher.watch(event.sessionId, filePath, fileStat.size);
|
|
5591
6479
|
} catch {
|
|
5592
6480
|
}
|
|
@@ -5749,6 +6637,66 @@ async function start(opts = {}) {
|
|
|
5749
6637
|
xcodeBuildExecutor.killInstall(event.installId);
|
|
5750
6638
|
break;
|
|
5751
6639
|
}
|
|
6640
|
+
case "schedule_session": {
|
|
6641
|
+
if (!scheduledManager) break;
|
|
6642
|
+
const scheduledAt = Number(event.scheduledAt);
|
|
6643
|
+
if (!Number.isFinite(scheduledAt)) {
|
|
6644
|
+
wsBridge.send(ws, { type: "error", code: "INVALID_MESSAGE", message: "Invalid scheduledAt" });
|
|
6645
|
+
break;
|
|
6646
|
+
}
|
|
6647
|
+
scheduledManager.schedule(scheduledAt, event.payload);
|
|
6648
|
+
break;
|
|
6649
|
+
}
|
|
6650
|
+
case "cancel_scheduled_session": {
|
|
6651
|
+
scheduledManager?.cancel(event.id);
|
|
6652
|
+
break;
|
|
6653
|
+
}
|
|
6654
|
+
case "list_scheduled_sessions": {
|
|
6655
|
+
if (scheduledManager) {
|
|
6656
|
+
wsBridge.send(ws, { type: "scheduled_session_list", tasks: scheduledManager.list() });
|
|
6657
|
+
}
|
|
6658
|
+
break;
|
|
6659
|
+
}
|
|
6660
|
+
case "git_status": {
|
|
6661
|
+
const status = await gitExecutor.detectStatus(event.projectPath);
|
|
6662
|
+
wsBridge.send(ws, { type: "git_status_result", sessionId: event.sessionId, status });
|
|
6663
|
+
break;
|
|
6664
|
+
}
|
|
6665
|
+
case "git_commit": {
|
|
6666
|
+
await gitExecutor.commit(
|
|
6667
|
+
event.sessionId,
|
|
6668
|
+
event.projectPath,
|
|
6669
|
+
event.message,
|
|
6670
|
+
event.files,
|
|
6671
|
+
event.alsoPush
|
|
6672
|
+
);
|
|
6673
|
+
break;
|
|
6674
|
+
}
|
|
6675
|
+
case "git_push": {
|
|
6676
|
+
await gitExecutor.push(event.sessionId, event.projectPath);
|
|
6677
|
+
break;
|
|
6678
|
+
}
|
|
6679
|
+
case "list_project_commands": {
|
|
6680
|
+
try {
|
|
6681
|
+
const commands = await commandDiscovery.scan(event.projectPath, event.refresh ?? false);
|
|
6682
|
+
wsBridge.send(ws, {
|
|
6683
|
+
type: "commands_result",
|
|
6684
|
+
sessionId: event.sessionId,
|
|
6685
|
+
projectPath: event.projectPath,
|
|
6686
|
+
commands
|
|
6687
|
+
});
|
|
6688
|
+
} catch (err) {
|
|
6689
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6690
|
+
wsBridge.send(ws, {
|
|
6691
|
+
type: "commands_result",
|
|
6692
|
+
sessionId: event.sessionId,
|
|
6693
|
+
projectPath: event.projectPath,
|
|
6694
|
+
commands: [],
|
|
6695
|
+
error: message
|
|
6696
|
+
});
|
|
6697
|
+
}
|
|
6698
|
+
break;
|
|
6699
|
+
}
|
|
5752
6700
|
default: {
|
|
5753
6701
|
wsBridge.send(ws, {
|
|
5754
6702
|
type: "error",
|
|
@@ -5787,6 +6735,9 @@ async function start(opts = {}) {
|
|
|
5787
6735
|
xcodeBuildExecutor.onEvent((event) => {
|
|
5788
6736
|
wsBridge.broadcast(event);
|
|
5789
6737
|
});
|
|
6738
|
+
gitExecutor.onEvent((event) => {
|
|
6739
|
+
wsBridge.broadcast(event);
|
|
6740
|
+
});
|
|
5790
6741
|
wsBridge.onDisconnect(() => {
|
|
5791
6742
|
if (wsBridge.getConnectionCount() === 0 && approvalProxy.getPendingCount() > 0) {
|
|
5792
6743
|
approvalProxy.approveAll(t("server.phoneDisconnected"));
|
|
@@ -5876,6 +6827,42 @@ async function start(opts = {}) {
|
|
|
5876
6827
|
console.error(`[Server] ${t("server.hookInstallFailed")}`, err);
|
|
5877
6828
|
console.log(`[Server] ${t("server.hookContinue")}`);
|
|
5878
6829
|
}
|
|
6830
|
+
const idleTimeoutMs = Number(process.env.SESSIX_IDLE_TIMEOUT_MS ?? 30 * 60 * 1e3);
|
|
6831
|
+
const idleSweepIntervalMs = Number(process.env.SESSIX_IDLE_SWEEP_INTERVAL_MS ?? 5 * 60 * 1e3);
|
|
6832
|
+
const maxActiveProcesses = Number(process.env.SESSIX_MAX_ACTIVE_PROCESSES ?? 15);
|
|
6833
|
+
let idleSweepTimer = null;
|
|
6834
|
+
if (idleSweepIntervalMs > 0 && (idleTimeoutMs > 0 || maxActiveProcesses > 0)) {
|
|
6835
|
+
idleSweepTimer = setInterval(async () => {
|
|
6836
|
+
try {
|
|
6837
|
+
let totalSwept = 0;
|
|
6838
|
+
const broadcastShrink = (sessionId) => {
|
|
6839
|
+
sessionManager.shrinkSessionBuffer(sessionId, 100);
|
|
6840
|
+
};
|
|
6841
|
+
for (const agentType of ["claude-code", "codex"]) {
|
|
6842
|
+
const provider = providerFactory.getProvider(agentType);
|
|
6843
|
+
if (idleTimeoutMs > 0 && typeof provider.sweepIdleProcesses === "function") {
|
|
6844
|
+
const swept = await provider.sweepIdleProcesses(idleTimeoutMs);
|
|
6845
|
+
swept.forEach(broadcastShrink);
|
|
6846
|
+
totalSwept += swept.length;
|
|
6847
|
+
}
|
|
6848
|
+
if (maxActiveProcesses > 0 && typeof provider.sweepLruProcesses === "function") {
|
|
6849
|
+
const swept = await provider.sweepLruProcesses(maxActiveProcesses);
|
|
6850
|
+
swept.forEach(broadcastShrink);
|
|
6851
|
+
totalSwept += swept.length;
|
|
6852
|
+
}
|
|
6853
|
+
}
|
|
6854
|
+
if (totalSwept > 0) {
|
|
6855
|
+
console.log(`[Server] Idle GC: swept ${totalSwept} idle session(s)`);
|
|
6856
|
+
wsBridge.broadcast({
|
|
6857
|
+
type: "session_list",
|
|
6858
|
+
sessions: sessionManager.getActiveSessions()
|
|
6859
|
+
});
|
|
6860
|
+
}
|
|
6861
|
+
} catch (err) {
|
|
6862
|
+
console.error("[Server] Idle GC failed:", err);
|
|
6863
|
+
}
|
|
6864
|
+
}, idleSweepIntervalMs);
|
|
6865
|
+
}
|
|
5879
6866
|
const stop = async () => {
|
|
5880
6867
|
console.log(`[Server] ${t("server.shuttingDown")}`);
|
|
5881
6868
|
const errors = [];
|
|
@@ -5887,6 +6874,7 @@ async function start(opts = {}) {
|
|
|
5887
6874
|
errors.push(err);
|
|
5888
6875
|
}
|
|
5889
6876
|
};
|
|
6877
|
+
if (idleSweepTimer) clearInterval(idleSweepTimer);
|
|
5890
6878
|
await attempt(() => authManager.destroy(), "AuthManager");
|
|
5891
6879
|
await attempt(() => stopMdns(), "mDNS");
|
|
5892
6880
|
await attempt(() => pairingManager.destroy(), "PairingManager");
|
|
@@ -5897,6 +6885,7 @@ async function start(opts = {}) {
|
|
|
5897
6885
|
await attempt(() => xcodeBuildExecutor.destroy(), "XcodeBuildExecutor");
|
|
5898
6886
|
await attempt(() => notificationService.destroy(), "NotificationService");
|
|
5899
6887
|
await attempt(() => sessionFileWatcher.destroy(), "SessionFileWatcher");
|
|
6888
|
+
await attempt(() => scheduledManager?.destroy(), "ScheduledSessionManager");
|
|
5900
6889
|
if (errors.length > 0) {
|
|
5901
6890
|
console.error(`[Server] ${t("server.shutdownWithErrors", { count: errors.length })}`);
|
|
5902
6891
|
throw errors[0];
|
|
@@ -5923,9 +6912,9 @@ async function start(opts = {}) {
|
|
|
5923
6912
|
openPairing: (duration) => pairingManager.open(duration),
|
|
5924
6913
|
closePairing: () => pairingManager.close(),
|
|
5925
6914
|
regenerateToken: async () => {
|
|
5926
|
-
const newToken = (0,
|
|
5927
|
-
await (0,
|
|
5928
|
-
await (0,
|
|
6915
|
+
const newToken = (0, import_uuid9.v4)();
|
|
6916
|
+
await (0, import_promises7.mkdir)(configDir, { recursive: true });
|
|
6917
|
+
await (0, import_promises7.writeFile)(tokenFile, newToken, "utf8");
|
|
5929
6918
|
instance.token = newToken;
|
|
5930
6919
|
wsBridge.updateToken(newToken);
|
|
5931
6920
|
approvalProxy.updateToken(newToken);
|