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.
Files changed (3) hide show
  1. package/dist/index.js +1021 -32
  2. package/dist/server.js +1015 -26
  3. 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 import_uuid7 = require("uuid");
311
- var import_promises5 = require("fs/promises");
312
- var import_node_os8 = require("os");
313
- var import_node_path7 = require("path");
314
- var import_node_child_process10 = require("child_process");
315
- var import_node_util2 = require("util");
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 import_promises6 = require("fs/promises");
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/utils/cliCapabilities.ts
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, import_node_child_process9.execFile)(path2, args, { timeout: 5e3 }, (err, stdout) => {
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 execAsync2 = (0, import_node_util2.promisify)(import_node_child_process10.exec);
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 execAsync2(
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 execAsync2(`taskkill /PID ${pid} /F`).catch(() => {
6096
+ await execAsync3(`taskkill /PID ${pid} /F`).catch(() => {
5258
6097
  });
5259
6098
  }
5260
6099
  } else {
5261
- const { stdout } = await execAsync2(`lsof -ti :${port}`);
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 execAsync2(`kill -9 ${pids.join(" ")}`);
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, import_node_path7.join)((0, import_node_os8.homedir)(), ".sessix");
5286
- const tokenFile = (0, import_node_path7.join)(configDir, "token");
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, import_promises5.readFile)(tokenFile, "utf8")).trim();
6135
+ token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
5297
6136
  } catch {
5298
- token = (0, import_uuid7.v4)();
5299
- await (0, import_promises5.mkdir)(configDir, { recursive: true });
5300
- await (0, import_promises5.writeFile)(tokenFile, token, "utf8");
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, import_node_os8.hostname)(),
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, import_promises5.mkdir)(event.projectPath, { recursive: true });
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, import_promises6.stat)(filePath);
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, import_uuid7.v4)();
5927
- await (0, import_promises5.mkdir)(configDir, { recursive: true });
5928
- await (0, import_promises5.writeFile)(tokenFile, newToken, "utf8");
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);