sessix-server 0.4.1 → 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 +1321 -37
  2. package/dist/server.js +1315 -31
  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
  */
@@ -1855,6 +1940,54 @@ var SessionManager = class {
1855
1940
  return stats ? { ...session, stats } : session;
1856
1941
  });
1857
1942
  }
1943
+ /**
1944
+ * 接入 ApprovalProxy 的非阻塞 hook 通知,将其映射为 ServerEvent 转发。
1945
+ *
1946
+ * 仅转发为 shared 类型定义中的字段,不把 hook 原始 payload 透传出去(隐私 + 体积)。
1947
+ * 当前覆盖:
1948
+ * - PreCompact (`type: 'compact'`) → `session_compact`
1949
+ * - PermissionDenied (`type: 'permission_denied'`) → `permission_denied`
1950
+ * - Subagent (`type: 'subagent'`) → `subagent_event`(骨架预留,本任务不发)
1951
+ */
1952
+ attachApprovalProxy(approvalProxy) {
1953
+ approvalProxy.onNotify((notification) => {
1954
+ const serverEvent = this.mapHookNotificationToServerEvent(notification);
1955
+ if (serverEvent) this.emit(serverEvent);
1956
+ });
1957
+ }
1958
+ /** 将 hook 通知映射为 ServerEvent;不识别的 type 返回 null */
1959
+ mapHookNotificationToServerEvent(n) {
1960
+ switch (n.type) {
1961
+ case "compact": {
1962
+ const subtype = n.subtype === "completed" || n.subtype === "blocked" ? n.subtype : "started";
1963
+ return { type: "session_compact", sessionId: n.sessionId, subtype };
1964
+ }
1965
+ case "permission_denied": {
1966
+ const source = n.source === "hook" || n.source === "rule" ? n.source : "classifier";
1967
+ return {
1968
+ type: "permission_denied",
1969
+ sessionId: n.sessionId,
1970
+ toolName: n.toolName ?? "unknown",
1971
+ reason: n.reason ?? "",
1972
+ source
1973
+ };
1974
+ }
1975
+ case "subagent": {
1976
+ const phase = n.subtype === "completed" ? "completed" : "started";
1977
+ return {
1978
+ type: "subagent_event",
1979
+ sessionId: n.sessionId,
1980
+ parentToolUseId: typeof n.parentToolUseId === "string" ? n.parentToolUseId : "",
1981
+ subAgentId: typeof n.subAgentId === "string" ? n.subAgentId : "",
1982
+ phase,
1983
+ task: typeof n.task === "string" ? n.task : void 0
1984
+ };
1985
+ }
1986
+ default:
1987
+ console.warn(`[SessionManager] Unknown hook notification type: ${n.type}`);
1988
+ return null;
1989
+ }
1990
+ }
1858
1991
  /**
1859
1992
  * 注册事件回调(事件会被转发到 WsBridge)
1860
1993
  *
@@ -2540,6 +2673,8 @@ var ApprovalProxy = class _ApprovalProxy {
2540
2673
  approvalRequestCallbacks = [];
2541
2674
  /** 审批 resolve 回调(任何来源的 resolve 都会触发,用于 WS 广播清理 */
2542
2675
  approvalResolvedCallbacks = [];
2676
+ /** Hook 通知回调(PreCompact / PermissionDenied / 等非阻塞 hook) */
2677
+ notifyCallbacks = [];
2543
2678
  /** YOLO 模式状态:sessionId -> enabled */
2544
2679
  yoloSessions = /* @__PURE__ */ new Map();
2545
2680
  /** 内存缓存:已被"始终允许"的工具名(避免每次读 settings.json) */
@@ -2601,6 +2736,24 @@ var ApprovalProxy = class _ApprovalProxy {
2601
2736
  }
2602
2737
  }
2603
2738
  }
2739
+ /**
2740
+ * 注册非阻塞 hook 通知回调(如 PreCompact、PermissionDenied)
2741
+ *
2742
+ * 这些 hook 不需要返回决策给 Claude Code,仅作为 ServerEvent 转发到手机端。
2743
+ */
2744
+ onNotify(callback) {
2745
+ this.notifyCallbacks.push(callback);
2746
+ }
2747
+ /** 触发所有 notify 回调(内部调用) */
2748
+ fireNotify(notification) {
2749
+ for (const callback of this.notifyCallbacks) {
2750
+ try {
2751
+ callback(notification);
2752
+ } catch (err) {
2753
+ console.error("[ApprovalProxy] Notify callback error:", err);
2754
+ }
2755
+ }
2756
+ }
2604
2757
  /** 设置状态信息提供者(用于 /health 端点) */
2605
2758
  setStatusInfoProvider(provider) {
2606
2759
  this.statusInfoProvider = provider;
@@ -2785,6 +2938,8 @@ var ApprovalProxy = class _ApprovalProxy {
2785
2938
  const pathname = url.pathname;
2786
2939
  if (req.method === "POST" && pathname === "/hook/approval") {
2787
2940
  this.handleApprovalHook(req, res);
2941
+ } else if (req.method === "POST" && pathname === "/hook/notify") {
2942
+ this.handleHookNotify(req, res);
2788
2943
  } else if (req.method === "POST" && pathname === "/pair") {
2789
2944
  this.handlePair(req, res);
2790
2945
  } else if (req.method === "GET" && pathname === "/health") {
@@ -2850,6 +3005,51 @@ var ApprovalProxy = class _ApprovalProxy {
2850
3005
  this.sendJson(res, 200, { decision: "deny", reason: "Server failed to process request" });
2851
3006
  }
2852
3007
  }
3008
+ /**
3009
+ * 非阻塞 hook 通知端点
3010
+ *
3011
+ * 用于 PreCompact、PostCompact、PermissionDenied 等 fire-and-forget 的 hook:
3012
+ * 立即返回 {"ok":true},再异步分发到 notifyCallbacks(最终广播为 ServerEvent)。
3013
+ *
3014
+ * 鉴权策略:仅允许 loopback(本机 hook 脚本)访问,避免远端注入伪造事件。
3015
+ */
3016
+ async handleHookNotify(req, res) {
3017
+ if (!this.isLoopbackRequest(req)) {
3018
+ this.sendJson(res, 403, { ok: false, reason: "forbidden" });
3019
+ return;
3020
+ }
3021
+ try {
3022
+ const body = await this.parseJsonBody(req);
3023
+ const sessionId = String(body.sessionId ?? "").trim();
3024
+ const type = String(body.type ?? "").trim();
3025
+ if (!sessionId || !type) {
3026
+ this.sendJson(res, 400, { error: "sessionId and type are required" });
3027
+ return;
3028
+ }
3029
+ const notification = {
3030
+ sessionId,
3031
+ type
3032
+ };
3033
+ if (typeof body.subtype === "string") notification.subtype = body.subtype;
3034
+ if (typeof body.toolName === "string") notification.toolName = body.toolName;
3035
+ if (typeof body.reason === "string") notification.reason = body.reason;
3036
+ if (typeof body.source === "string") notification.source = body.source;
3037
+ if (typeof body.parentToolUseId === "string") notification.parentToolUseId = body.parentToolUseId;
3038
+ if (typeof body.subAgentId === "string") notification.subAgentId = body.subAgentId;
3039
+ if (body.phase === "started" || body.phase === "completed") notification.phase = body.phase;
3040
+ if (typeof body.task === "string") notification.task = body.task;
3041
+ this.sendJson(res, 200, { ok: true });
3042
+ setImmediate(() => this.fireNotify(notification));
3043
+ } catch (err) {
3044
+ console.error("[ApprovalProxy] Hook notify failed:", err);
3045
+ this.sendJson(res, 200, { ok: false });
3046
+ }
3047
+ }
3048
+ /** 判断请求是否来自本机 loopback(127.0.0.1 / ::1 / IPv4-mapped IPv6) */
3049
+ isLoopbackRequest(req) {
3050
+ const remoteAddress = req.socket.remoteAddress;
3051
+ return remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress === "::ffff:127.0.0.1";
3052
+ }
2853
3053
  /** 健康检查端点 */
2854
3054
  handleHealth(_req, res) {
2855
3055
  const info = this.statusInfoProvider?.() ?? { connections: 0, activeSessions: 0 };
@@ -2882,9 +3082,7 @@ var ApprovalProxy = class _ApprovalProxy {
2882
3082
  }
2883
3083
  /** 返回连接 token(仅本机访问) */
2884
3084
  handleToken(req, res) {
2885
- const remoteAddress = req.socket.remoteAddress;
2886
- const isLocal = remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress === "::ffff:127.0.0.1";
2887
- if (!isLocal) {
3085
+ if (!this.isLoopbackRequest(req)) {
2888
3086
  this.sendJson(res, 403, { error: t("approval.forbidden") });
2889
3087
  return;
2890
3088
  }
@@ -3112,9 +3310,15 @@ var import_node_os6 = require("os");
3112
3310
  var SESSIX_HOOKS_DIR = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".sessix", "hooks");
3113
3311
  var HOOK_SCRIPT_PATH = (0, import_node_path4.join)(SESSIX_HOOKS_DIR, "approval-hook.js");
3114
3312
  var PERMISSION_ACCEPT_PATH = (0, import_node_path4.join)(SESSIX_HOOKS_DIR, "permission-accept.js");
3313
+ var COMPACT_HOOK_PATH = (0, import_node_path4.join)(SESSIX_HOOKS_DIR, "compact-hook.js");
3314
+ var POST_COMPACT_HOOK_PATH = (0, import_node_path4.join)(SESSIX_HOOKS_DIR, "post-compact-hook.js");
3315
+ var PERMISSION_DENIED_HOOK_PATH = (0, import_node_path4.join)(SESSIX_HOOKS_DIR, "permission-denied-hook.js");
3115
3316
  var CLAUDE_SETTINGS_PATH = (0, import_node_path4.join)((0, import_node_os6.homedir)(), ".claude", "settings.json");
3116
3317
  var HOOK_COMMAND = "node ~/.sessix/hooks/approval-hook.js";
3117
3318
  var PERMISSION_ACCEPT_COMMAND = "node ~/.sessix/hooks/permission-accept.js";
3319
+ var COMPACT_HOOK_COMMAND = "node ~/.sessix/hooks/compact-hook.js";
3320
+ var POST_COMPACT_HOOK_COMMAND = "node ~/.sessix/hooks/post-compact-hook.js";
3321
+ var PERMISSION_DENIED_HOOK_COMMAND = "node ~/.sessix/hooks/permission-denied-hook.js";
3118
3322
  var LEGACY_HOOK_COMMANDS = [
3119
3323
  "~/.sessix/hooks/approval-hook.sh",
3120
3324
  "~/.sessix/hooks/permission-accept.sh"
@@ -3174,6 +3378,112 @@ if (!process.env.SESSIX_SESSION_ID) process.exit(0)
3174
3378
  process.stdout.write('{"decision":"allow"}\\n')
3175
3379
  process.exit(0)
3176
3380
  `;
3381
+ var COMPACT_HOOK_TEMPLATE = `#!/usr/bin/env node
3382
+ // Sessix PreCompact \u901A\u77E5 hook
3383
+ // \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
3384
+
3385
+ const sessionId = process.env.SESSIX_SESSION_ID
3386
+ if (!sessionId) {
3387
+ process.stdout.write('{}')
3388
+ process.exit(0)
3389
+ }
3390
+
3391
+ let raw = ''
3392
+ process.stdin.on('data', (chunk) => { raw += chunk })
3393
+ process.stdin.on('end', () => {
3394
+ // \u8BFB\u53D6\u5E76\u4E22\u5F03 stdin payload\uFF08PreCompact \u6807\u51C6\u8F93\u5165\uFF09\uFF0Cserver \u7AEF\u4E0D\u9700\u8981\u5B83
3395
+ try { JSON.parse(raw) } catch {}
3396
+ // fire-and-forget\uFF1A\u4E0D\u7B49\u5F85\u54CD\u5E94\uFF0C\u907F\u514D\u963B\u585E compact
3397
+ try {
3398
+ fetch('http://localhost:3746/hook/notify', {
3399
+ method: 'POST',
3400
+ headers: { 'Content-Type': 'application/json' },
3401
+ body: JSON.stringify({
3402
+ sessionId,
3403
+ type: 'compact',
3404
+ subtype: 'started',
3405
+ }),
3406
+ signal: AbortSignal.timeout(2000),
3407
+ }).catch(() => {})
3408
+ } catch {}
3409
+ // \u7ACB\u5373\u8FD4\u56DE\u7A7A JSON\uFF0C\u4E0D\u963B\u585E compact
3410
+ process.stdout.write('{}')
3411
+ process.exit(0)
3412
+ })
3413
+ `;
3414
+ var POST_COMPACT_HOOK_TEMPLATE = `#!/usr/bin/env node
3415
+ // Sessix PostCompact \u901A\u77E5 hook
3416
+ // \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
3417
+
3418
+ const sessionId = process.env.SESSIX_SESSION_ID
3419
+ if (!sessionId) {
3420
+ process.stdout.write('{}')
3421
+ process.exit(0)
3422
+ }
3423
+
3424
+ let raw = ''
3425
+ process.stdin.on('data', (chunk) => { raw += chunk })
3426
+ process.stdin.on('end', () => {
3427
+ // \u8BFB\u53D6\u5E76\u4E22\u5F03 stdin payload\uFF0Cserver \u7AEF\u4E0D\u9700\u8981\u5B83
3428
+ try { JSON.parse(raw) } catch {}
3429
+ // fire-and-forget\uFF1A\u4E0D\u7B49\u5F85\u54CD\u5E94
3430
+ try {
3431
+ fetch('http://localhost:3746/hook/notify', {
3432
+ method: 'POST',
3433
+ headers: { 'Content-Type': 'application/json' },
3434
+ body: JSON.stringify({
3435
+ sessionId,
3436
+ type: 'compact',
3437
+ subtype: 'completed',
3438
+ }),
3439
+ signal: AbortSignal.timeout(2000),
3440
+ }).catch(() => {})
3441
+ } catch {}
3442
+ process.stdout.write('{}')
3443
+ process.exit(0)
3444
+ })
3445
+ `;
3446
+ var PERMISSION_DENIED_HOOK_TEMPLATE = `#!/usr/bin/env node
3447
+ // Sessix PermissionDenied \u901A\u77E5 hook
3448
+ // \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
3449
+
3450
+ const sessionId = process.env.SESSIX_SESSION_ID
3451
+ if (!sessionId) {
3452
+ process.stdout.write('{}')
3453
+ process.exit(0)
3454
+ }
3455
+
3456
+ let raw = ''
3457
+ process.stdin.on('data', (chunk) => { raw += chunk })
3458
+ process.stdin.on('end', () => {
3459
+ let payload = {}
3460
+ try { payload = JSON.parse(raw) } catch {}
3461
+ const toolName = String(payload.tool_name || 'unknown')
3462
+ // Claude Code PermissionDenied \u7531 classifier \u89E6\u53D1\uFF0C\u6240\u4EE5 source \u9ED8\u8BA4 'classifier'
3463
+ const reason = String(
3464
+ payload.reason ||
3465
+ payload.permission_decision_reason ||
3466
+ payload.permissionDecisionReason ||
3467
+ ''
3468
+ )
3469
+ try {
3470
+ fetch('http://localhost:3746/hook/notify', {
3471
+ method: 'POST',
3472
+ headers: { 'Content-Type': 'application/json' },
3473
+ body: JSON.stringify({
3474
+ sessionId,
3475
+ type: 'permission_denied',
3476
+ toolName,
3477
+ reason,
3478
+ source: 'classifier',
3479
+ }),
3480
+ signal: AbortSignal.timeout(2000),
3481
+ }).catch(() => {})
3482
+ } catch {}
3483
+ process.stdout.write('{}')
3484
+ process.exit(0)
3485
+ })
3486
+ `;
3177
3487
  var HookInstaller = class {
3178
3488
  /**
3179
3489
  * 安装 hook
@@ -3187,8 +3497,14 @@ var HookInstaller = class {
3187
3497
  await (0, import_promises2.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
3188
3498
  await (0, import_promises2.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
3189
3499
  await (0, import_promises2.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
3500
+ await (0, import_promises2.writeFile)(COMPACT_HOOK_PATH, COMPACT_HOOK_TEMPLATE, "utf-8");
3501
+ await (0, import_promises2.writeFile)(POST_COMPACT_HOOK_PATH, POST_COMPACT_HOOK_TEMPLATE, "utf-8");
3502
+ await (0, import_promises2.writeFile)(PERMISSION_DENIED_HOOK_PATH, PERMISSION_DENIED_HOOK_TEMPLATE, "utf-8");
3190
3503
  await (0, import_promises2.chmod)(HOOK_SCRIPT_PATH, 493);
3191
3504
  await (0, import_promises2.chmod)(PERMISSION_ACCEPT_PATH, 493);
3505
+ await (0, import_promises2.chmod)(COMPACT_HOOK_PATH, 493);
3506
+ await (0, import_promises2.chmod)(POST_COMPACT_HOOK_PATH, 493);
3507
+ await (0, import_promises2.chmod)(PERMISSION_DENIED_HOOK_PATH, 493);
3192
3508
  await this.addHookToSettings();
3193
3509
  console.log("[HookInstaller] Hook installation complete");
3194
3510
  }
@@ -3214,6 +3530,9 @@ var HookInstaller = class {
3214
3530
  async isInstalled() {
3215
3531
  let approvalScriptContent = "";
3216
3532
  let permissionScriptExists = false;
3533
+ let compactScriptExists = false;
3534
+ let postCompactScriptExists = false;
3535
+ let permissionDeniedScriptExists = false;
3217
3536
  try {
3218
3537
  approvalScriptContent = await (0, import_promises2.readFile)(HOOK_SCRIPT_PATH, "utf-8");
3219
3538
  } catch {
@@ -3223,10 +3542,25 @@ var HookInstaller = class {
3223
3542
  permissionScriptExists = true;
3224
3543
  } catch {
3225
3544
  }
3545
+ try {
3546
+ await (0, import_promises2.access)(COMPACT_HOOK_PATH);
3547
+ compactScriptExists = true;
3548
+ } catch {
3549
+ }
3550
+ try {
3551
+ await (0, import_promises2.access)(POST_COMPACT_HOOK_PATH);
3552
+ postCompactScriptExists = true;
3553
+ } catch {
3554
+ }
3555
+ try {
3556
+ await (0, import_promises2.access)(PERMISSION_DENIED_HOOK_PATH);
3557
+ permissionDeniedScriptExists = true;
3558
+ } catch {
3559
+ }
3226
3560
  const isLatestVersion = approvalScriptContent.includes("permissionDecision");
3227
3561
  const settings = await this.readClaudeSettings();
3228
3562
  const configExists = this.hasHookConfig(settings);
3229
- return isLatestVersion && permissionScriptExists && configExists;
3563
+ return isLatestVersion && permissionScriptExists && compactScriptExists && postCompactScriptExists && permissionDeniedScriptExists && configExists;
3230
3564
  }
3231
3565
  // ============================================
3232
3566
  // 内部方法
@@ -3264,6 +3598,36 @@ var HookInstaller = class {
3264
3598
  });
3265
3599
  changed = true;
3266
3600
  }
3601
+ if (!this.hasPreCompactConfig(settings)) {
3602
+ if (!settings.hooks.PreCompact) {
3603
+ settings.hooks.PreCompact = [];
3604
+ }
3605
+ settings.hooks.PreCompact.push({
3606
+ matcher: "",
3607
+ hooks: [{ type: "command", command: COMPACT_HOOK_COMMAND }]
3608
+ });
3609
+ changed = true;
3610
+ }
3611
+ if (!this.hasPostCompactConfig(settings)) {
3612
+ if (!settings.hooks.PostCompact) {
3613
+ settings.hooks.PostCompact = [];
3614
+ }
3615
+ settings.hooks.PostCompact.push({
3616
+ matcher: "",
3617
+ hooks: [{ type: "command", command: POST_COMPACT_HOOK_COMMAND }]
3618
+ });
3619
+ changed = true;
3620
+ }
3621
+ if (!this.hasPermissionDeniedConfig(settings)) {
3622
+ if (!settings.hooks.PermissionDenied) {
3623
+ settings.hooks.PermissionDenied = [];
3624
+ }
3625
+ settings.hooks.PermissionDenied.push({
3626
+ matcher: "",
3627
+ hooks: [{ type: "command", command: PERMISSION_DENIED_HOOK_COMMAND }]
3628
+ });
3629
+ changed = true;
3630
+ }
3267
3631
  if (changed) {
3268
3632
  await this.writeClaudeSettings(settings);
3269
3633
  } else {
@@ -3278,6 +3642,9 @@ var HookInstaller = class {
3278
3642
  if (!settings.hooks) return;
3279
3643
  this.removeHookCommand(settings, "PreToolUse", HOOK_COMMAND);
3280
3644
  this.removeHookCommand(settings, "PermissionRequest", PERMISSION_ACCEPT_COMMAND);
3645
+ this.removeHookCommand(settings, "PreCompact", COMPACT_HOOK_COMMAND);
3646
+ this.removeHookCommand(settings, "PostCompact", POST_COMPACT_HOOK_COMMAND);
3647
+ this.removeHookCommand(settings, "PermissionDenied", PERMISSION_DENIED_HOOK_COMMAND);
3281
3648
  if (Object.keys(settings.hooks).length === 0) {
3282
3649
  delete settings.hooks;
3283
3650
  }
@@ -3315,7 +3682,7 @@ var HookInstaller = class {
3315
3682
  * 检查 settings 中是否已包含所有 Sessix hook 配置
3316
3683
  */
3317
3684
  hasHookConfig(settings) {
3318
- return this.hasPreToolUseConfig(settings) && this.hasPermissionRequestConfig(settings);
3685
+ return this.hasPreToolUseConfig(settings) && this.hasPermissionRequestConfig(settings) && this.hasPreCompactConfig(settings) && this.hasPostCompactConfig(settings) && this.hasPermissionDeniedConfig(settings);
3319
3686
  }
3320
3687
  /** 检查 PreToolUse 中是否有 approval-hook.js */
3321
3688
  hasPreToolUseConfig(settings) {
@@ -3325,6 +3692,18 @@ var HookInstaller = class {
3325
3692
  hasPermissionRequestConfig(settings) {
3326
3693
  return this.hasHookEntry(settings?.hooks?.PermissionRequest, PERMISSION_ACCEPT_COMMAND);
3327
3694
  }
3695
+ /** 检查 PreCompact 中是否有 compact-hook.js */
3696
+ hasPreCompactConfig(settings) {
3697
+ return this.hasHookEntry(settings?.hooks?.PreCompact, COMPACT_HOOK_COMMAND);
3698
+ }
3699
+ /** 检查 PostCompact 中是否有 post-compact-hook.js */
3700
+ hasPostCompactConfig(settings) {
3701
+ return this.hasHookEntry(settings?.hooks?.PostCompact, POST_COMPACT_HOOK_COMMAND);
3702
+ }
3703
+ /** 检查 PermissionDenied 中是否有 permission-denied-hook.js */
3704
+ hasPermissionDeniedConfig(settings) {
3705
+ return this.hasHookEntry(settings?.hooks?.PermissionDenied, PERMISSION_DENIED_HOOK_COMMAND);
3706
+ }
3328
3707
  /** 检查 hook 数组中是否包含指定命令 */
3329
3708
  hasHookEntry(hookArray, command) {
3330
3709
  if (!Array.isArray(hookArray)) return false;
@@ -4400,7 +4779,7 @@ var AuthManager = class extends import_events3.EventEmitter {
4400
4779
  };
4401
4780
 
4402
4781
  // src/server.ts
4403
- var import_promises6 = require("fs/promises");
4782
+ var import_promises8 = require("fs/promises");
4404
4783
 
4405
4784
  // src/terminal/TerminalExecutor.ts
4406
4785
  var import_node_child_process7 = require("child_process");
@@ -4903,8 +5282,762 @@ function kindOrder(k) {
4903
5282
  return k === "device" ? 0 : k === "simulator" ? 1 : k === "mac" ? 2 : 3;
4904
5283
  }
4905
5284
 
4906
- // 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
4907
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");
4908
6041
  var DEFAULT_CAPABILITIES = {
4909
6042
  effortLevels: ["low", "medium", "high", "xhigh", "max"]
4910
6043
  };
@@ -4932,7 +6065,7 @@ async function parseCliCapabilities() {
4932
6065
  }
4933
6066
  function runCli(path2, args) {
4934
6067
  return new Promise((resolve) => {
4935
- (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) => {
4936
6069
  if (err) {
4937
6070
  console.warn(`[CliCapabilities] Failed to run ${path2} ${args.join(" ")}:`, err.message);
4938
6071
  resolve(null);
@@ -4946,11 +6079,11 @@ function runCli(path2, args) {
4946
6079
  // src/server.ts
4947
6080
  var WS_PORT = 3745;
4948
6081
  var HTTP_PORT = 3746;
4949
- 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);
4950
6083
  async function killPortProcess(port) {
4951
6084
  try {
4952
6085
  if (isWindows) {
4953
- const { stdout } = await execAsync2(
6086
+ const { stdout } = await execAsync3(
4954
6087
  `netstat -ano | findstr :${port} | findstr LISTENING`
4955
6088
  );
4956
6089
  const pids = /* @__PURE__ */ new Set();
@@ -4960,14 +6093,14 @@ async function killPortProcess(port) {
4960
6093
  if (pid && /^\d+$/.test(pid) && pid !== "0") pids.add(pid);
4961
6094
  }
4962
6095
  for (const pid of pids) {
4963
- await execAsync2(`taskkill /PID ${pid} /F`).catch(() => {
6096
+ await execAsync3(`taskkill /PID ${pid} /F`).catch(() => {
4964
6097
  });
4965
6098
  }
4966
6099
  } else {
4967
- const { stdout } = await execAsync2(`lsof -ti :${port}`);
6100
+ const { stdout } = await execAsync3(`lsof -ti :${port}`);
4968
6101
  const pids = stdout.trim().split("\n").filter((p) => p && /^\d+$/.test(p));
4969
6102
  if (pids.length > 0) {
4970
- await execAsync2(`kill -9 ${pids.join(" ")}`);
6103
+ await execAsync3(`kill -9 ${pids.join(" ")}`);
4971
6104
  }
4972
6105
  }
4973
6106
  await new Promise((resolve) => setTimeout(resolve, 600));
@@ -4988,8 +6121,8 @@ async function createWithRetry(label, port, factory) {
4988
6121
  }
4989
6122
  }
4990
6123
  async function start(opts = {}) {
4991
- const configDir = (0, import_node_path7.join)((0, import_node_os8.homedir)(), ".sessix");
4992
- 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");
4993
6126
  let token;
4994
6127
  if (opts.token !== void 0) {
4995
6128
  token = opts.token;
@@ -4999,11 +6132,11 @@ async function start(opts = {}) {
4999
6132
  token = envToken;
5000
6133
  } else {
5001
6134
  try {
5002
- token = (await (0, import_promises5.readFile)(tokenFile, "utf8")).trim();
6135
+ token = (await (0, import_promises7.readFile)(tokenFile, "utf8")).trim();
5003
6136
  } catch {
5004
- token = (0, import_uuid7.v4)();
5005
- await (0, import_promises5.mkdir)(configDir, { recursive: true });
5006
- 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");
5007
6140
  }
5008
6141
  }
5009
6142
  }
@@ -5011,6 +6144,9 @@ async function start(opts = {}) {
5011
6144
  const sessionManager = new SessionManager(providerFactory);
5012
6145
  const terminalExecutor = new TerminalExecutor();
5013
6146
  const xcodeBuildExecutor = new XcodeBuildExecutor();
6147
+ const commandDiscovery = new CommandDiscovery();
6148
+ const gitExecutor = new GitExecutor();
6149
+ let scheduledManager = null;
5014
6150
  const approvalProxy = await createWithRetry(
5015
6151
  "ApprovalProxy",
5016
6152
  HTTP_PORT,
@@ -5049,11 +6185,12 @@ async function start(opts = {}) {
5049
6185
  let mdnsService = null;
5050
6186
  const pairingManager = new PairingManager({
5051
6187
  token,
5052
- serverName: (0, import_node_os8.hostname)(),
6188
+ serverName: (0, import_node_os9.hostname)(),
5053
6189
  version: "0.2.0",
5054
6190
  onStateChange: (state) => mdnsService?.updatePairingState(state)
5055
6191
  });
5056
6192
  approvalProxy.setPairingManager(pairingManager);
6193
+ sessionManager.attachApprovalProxy(approvalProxy);
5057
6194
  const authManager = new AuthManager();
5058
6195
  authManager.on("login_url", (url) => {
5059
6196
  wsBridge.broadcast({ type: "auth_login_url", url });
@@ -5077,6 +6214,49 @@ async function start(opts = {}) {
5077
6214
  const broadcastUnreadSessions = () => {
5078
6215
  wsBridge.broadcast({ type: "unread_sessions", sessionIds: Array.from(unreadSessionIds) });
5079
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();
5080
6260
  wsBridge.onConnection(async (ws) => {
5081
6261
  const result = await getProjects();
5082
6262
  if (result.ok) {
@@ -5098,12 +6278,15 @@ async function start(opts = {}) {
5098
6278
  if (cliCapabilities) {
5099
6279
  wsBridge.send(ws, { type: "cli_info", effortLevels: cliCapabilities.effortLevels, version: cliCapabilities.version });
5100
6280
  }
6281
+ if (scheduledManager) {
6282
+ wsBridge.send(ws, { type: "scheduled_session_list", tasks: scheduledManager.list() });
6283
+ }
5101
6284
  });
5102
6285
  wsBridge.onClientEvent(async (event, ws) => {
5103
6286
  try {
5104
6287
  switch (event.type) {
5105
6288
  case "create_session": {
5106
- await (0, import_promises5.mkdir)(event.projectPath, { recursive: true });
6289
+ await (0, import_promises7.mkdir)(event.projectPath, { recursive: true });
5107
6290
  const resumeId = event.resumeSessionId ?? event.newSessionId;
5108
6291
  if (resumeId) sessionFileWatcher.unwatch(resumeId);
5109
6292
  await sessionManager.createSession(
@@ -5291,7 +6474,7 @@ async function start(opts = {}) {
5291
6474
  if (!isStreaming) {
5292
6475
  const filePath = getSessionFilePath(event.projectPath, event.sessionId);
5293
6476
  try {
5294
- const fileStat = await (0, import_promises6.stat)(filePath);
6477
+ const fileStat = await (0, import_promises8.stat)(filePath);
5295
6478
  sessionFileWatcher.watch(event.sessionId, filePath, fileStat.size);
5296
6479
  } catch {
5297
6480
  }
@@ -5454,6 +6637,66 @@ async function start(opts = {}) {
5454
6637
  xcodeBuildExecutor.killInstall(event.installId);
5455
6638
  break;
5456
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
+ }
5457
6700
  default: {
5458
6701
  wsBridge.send(ws, {
5459
6702
  type: "error",
@@ -5492,6 +6735,9 @@ async function start(opts = {}) {
5492
6735
  xcodeBuildExecutor.onEvent((event) => {
5493
6736
  wsBridge.broadcast(event);
5494
6737
  });
6738
+ gitExecutor.onEvent((event) => {
6739
+ wsBridge.broadcast(event);
6740
+ });
5495
6741
  wsBridge.onDisconnect(() => {
5496
6742
  if (wsBridge.getConnectionCount() === 0 && approvalProxy.getPendingCount() > 0) {
5497
6743
  approvalProxy.approveAll(t("server.phoneDisconnected"));
@@ -5581,6 +6827,42 @@ async function start(opts = {}) {
5581
6827
  console.error(`[Server] ${t("server.hookInstallFailed")}`, err);
5582
6828
  console.log(`[Server] ${t("server.hookContinue")}`);
5583
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
+ }
5584
6866
  const stop = async () => {
5585
6867
  console.log(`[Server] ${t("server.shuttingDown")}`);
5586
6868
  const errors = [];
@@ -5592,6 +6874,7 @@ async function start(opts = {}) {
5592
6874
  errors.push(err);
5593
6875
  }
5594
6876
  };
6877
+ if (idleSweepTimer) clearInterval(idleSweepTimer);
5595
6878
  await attempt(() => authManager.destroy(), "AuthManager");
5596
6879
  await attempt(() => stopMdns(), "mDNS");
5597
6880
  await attempt(() => pairingManager.destroy(), "PairingManager");
@@ -5602,6 +6885,7 @@ async function start(opts = {}) {
5602
6885
  await attempt(() => xcodeBuildExecutor.destroy(), "XcodeBuildExecutor");
5603
6886
  await attempt(() => notificationService.destroy(), "NotificationService");
5604
6887
  await attempt(() => sessionFileWatcher.destroy(), "SessionFileWatcher");
6888
+ await attempt(() => scheduledManager?.destroy(), "ScheduledSessionManager");
5605
6889
  if (errors.length > 0) {
5606
6890
  console.error(`[Server] ${t("server.shutdownWithErrors", { count: errors.length })}`);
5607
6891
  throw errors[0];
@@ -5628,9 +6912,9 @@ async function start(opts = {}) {
5628
6912
  openPairing: (duration) => pairingManager.open(duration),
5629
6913
  closePairing: () => pairingManager.close(),
5630
6914
  regenerateToken: async () => {
5631
- const newToken = (0, import_uuid7.v4)();
5632
- await (0, import_promises5.mkdir)(configDir, { recursive: true });
5633
- 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");
5634
6918
  instance.token = newToken;
5635
6919
  wsBridge.updateToken(newToken);
5636
6920
  approvalProxy.updateToken(newToken);