cc-viewer 1.9.12 → 1.9.14

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 (96) hide show
  1. package/README.md +8 -0
  2. package/dist/assets/{App-Xqmx9TR5.js → App-BzV1ieDH.js} +2 -2
  3. package/dist/assets/{MdxEditorPanel-BwTbSU48.js → MdxEditorPanel-DI3B4gsM.js} +1 -1
  4. package/dist/assets/{Mobile-BOXHO9-C.js → Mobile-qBkAnXy3.js} +1 -1
  5. package/dist/assets/{ProxyStatsModal-BzfIUfG1.js → ProxyStatsModal-B8K0AtAq.js} +1 -1
  6. package/dist/assets/{_baseUniq-BvPjRimO.js → _baseUniq-CsF04anE.js} +1 -1
  7. package/dist/assets/{arc-BzZyqc6p.js → arc-BOacYe6N.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-B1oszHzA.js → architectureDiagram-Q4EWVU46-BSUEfjC_.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-Ndp4o_QB.js → blockDiagram-DXYQGD6D-CC37UGtW.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-DlDE-ALt.js → c4Diagram-AHTNJAMY-IzgRD4uO.js} +1 -1
  11. package/dist/assets/{channel-BhXx9y6q.js → channel-C6PxyQUn.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-CM-2e6q_.js → chunk-4BX2VUAB-cdQt_dcU.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-GWLkQKRO.js → chunk-4TB4RGXK-BpTDA_Pb.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-B6LOFqRZ.js → chunk-55IACEB6-CcitNo0P.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Ct3e09Ku.js → chunk-EDXVE4YY-BjKwZ9I7.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-DK-vXpZP.js → chunk-FMBD7UC4-vZDVX3tI.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-CWxZc_DU.js → chunk-OYMX7WX6-DGkYahGo.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-jp8ISHue.js → chunk-QZHKN3VN-C9065hSE.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-DtYet6WV.js → chunk-YZCP3GAM-D_pcTg6_.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-BUmU_G-i.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-BUmU_G-i.js +1 -0
  22. package/dist/assets/clone-CvoOne2K.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-lNC-slVX.js → cose-bilkent-S5V4N54A-BPPFMIGv.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-vm6U82qq.js → dagre-KV5264BT-fjzhKO5o.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-Dq3FXh-U.js → diagram-5BDNPKRD-5L6FzTry.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BxF85wcF.js → diagram-G4DWMVQ6-GOwcGpff.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-CFCTeMz0.js → diagram-MMDJMWI5-BDsFE25W.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Bui5Azdg.js → diagram-TYMM5635-BqjpbSGH.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-DrpUO1Kb.js → erDiagram-SMLLAGMA-DWO3Dk1v.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-Bb3amxiK.js → flowDiagram-DWJPFMVM-Br4nNBVd.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-BRwCFM3C.js → ganttDiagram-T4ZO3ILL-9sN-VOhw.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DVXbBwhs.js → gitGraphDiagram-UUTBAWPF-BsPcTAKr.js} +1 -1
  33. package/dist/assets/{graph-Dh22wYWs.js → graph-o31GzaB6.js} +1 -1
  34. package/dist/assets/{index-CfFFgxxf.js → index-B3b0lRUB.js} +1 -1
  35. package/dist/assets/{index-CWZz2pyj.js → index-CU0gt6m1.js} +2 -2
  36. package/dist/assets/{index-BwZB7UwN.js → index-CXMr3tiK.js} +1 -1
  37. package/dist/assets/{index-UlO7Q5dX.js → index-CuvDJTY7.js} +1 -1
  38. package/dist/assets/{index-Cfm4N4ao.js → index-DAjx9lT3.js} +1 -1
  39. package/dist/assets/{index-xZXOwglE.js → index-DyV81jSF.js} +1 -1
  40. package/dist/assets/{index-CQ1Nswa3.js → index-PDvAAbti.js} +1 -1
  41. package/dist/assets/{index-e8wejtj_.js → index-tZqiJJGq.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-cQPbF7Q6.js → infoDiagram-42DDH7IO-VUMzEo4o.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-cp-ir90Y.js → ishikawaDiagram-UXIWVN3A-BuzrU3sx.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-aoMcyNLC.js → journeyDiagram-VCZTEJTY-BZtR5BAF.js} +1 -1
  45. package/dist/assets/{jszip.min-C7BBsGL5.js → jszip.min-CtodH5MV.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-Cugr0o0f.js → kanban-definition-6JOO6SKY-Cj_97xXb.js} +1 -1
  47. package/dist/assets/{layout-CL0eiocE.js → layout-Du0I-tlh.js} +1 -1
  48. package/dist/assets/{linear-D6EJvwNs.js → linear-BJtxCASI.js} +1 -1
  49. package/dist/assets/{mermaid.core-CNYcCP19.js → mermaid.core-QlorX0Yw.js} +2 -2
  50. package/dist/assets/{min-dFBWKmZ_.js → min-BVnQfunZ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-4fbr7E2n.js → mindmap-definition-QFDTVHPH-Bh_KL4qU.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-UHWzU_JW.js → pieDiagram-DEJITSTG-Bvv0rC_q.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-e87bZkAb.js → quadrantDiagram-34T5L4WZ-Bw8cGCRU.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-l_97YNSi.js → requirementDiagram-MS252O5E-Cg9jQ71h.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-D3JFnltO.js → sankeyDiagram-XADWPNL6-BXJo6-CD.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-ChbSSNu8.css → seqResourceLoaders-CxUFE36z.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-nGCs11zu.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-5jDKcsRZ.js → sequenceDiagram-FGHM5R23-D7zpGxwq.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-D5_gBKn8.js → stateDiagram-FHFEXIEX-Der2CqR1.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP5uQ5EH.js → stateDiagram-v2-QKLJ7IA2-DetvG_Lc.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-7IVQRwK1.js → timeline-definition-GMOUNBTQ-0UenzEZ6.js} +1 -1
  62. package/dist/assets/{vendor-antd-CfuxfYfy.js → vendor-antd-I6I2F7Rv.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C4Jn4TTc.js → vendor-codemirror-Zkm6UHQg.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BreKt5Hd.js → vendor-mdxeditor-CC09MDyZ.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-CutTqXgh.js → vendor-qrcode-gZpmSeeh.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-Cc9ovCDf.js → vendor-virtuoso-BGwznQ5y.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-D6qzGquN.js → vennDiagram-DHZGUBPP-COzYyZ5h.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-DzAFqZmY.js → wardley-RL74JXVD-VZdOtN8t.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-CImP9kv8.js → wardleyDiagram-NUSXRM2D-CVkL75Qy.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-Co45rRaM.js → xychartDiagram-5P7HB3ND-6CHBAYyF.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/node_modules/@ccv/core/src/context-rules.js +6 -2
  73. package/package.json +1 -1
  74. package/server/lib/chat-queue.js +14 -1
  75. package/server/lib/config-backup.js +4 -2
  76. package/server/lib/cron.js +181 -0
  77. package/server/lib/file-access-policy.js +45 -1
  78. package/server/lib/im-deny.js +12 -9
  79. package/server/lib/json-store.js +27 -1
  80. package/server/lib/model-system-prompts.js +4 -1
  81. package/server/lib/project-prefs.js +4 -1
  82. package/server/lib/schedule-model.js +149 -0
  83. package/server/lib/scheduled-task-executor.js +97 -0
  84. package/server/lib/scheduled-task-runs-store.js +99 -0
  85. package/server/lib/scheduled-task-scheduler.js +254 -0
  86. package/server/lib/scheduled-task-store.js +511 -0
  87. package/server/lib/sdk-manager.js +283 -12
  88. package/server/pty-manager.js +13 -2
  89. package/server/routes/preferences.js +24 -1
  90. package/server/routes/scheduled-tasks.js +310 -0
  91. package/server/server.js +135 -0
  92. package/server/system-prompt-templates/presets/kimi-k3.md +4 -0
  93. package/dist/assets/classDiagram-6PBFFD2Q-C0dwAyuU.js +0 -1
  94. package/dist/assets/classDiagram-v2-HSJHXN6E-C0dwAyuU.js +0 -1
  95. package/dist/assets/clone-fiofFlrV.js +0 -1
  96. package/dist/assets/seqResourceLoaders-t8OZr44t.js +0 -2
@@ -0,0 +1,181 @@
1
+ // Minimal 5-field cron parser + next-run computation, local timezone.
2
+ //
3
+ // Scope (deliberately small — this backs the scheduled-task feature, not a
4
+ // general cron service):
5
+ // - fields: minute(0-59) hour(0-23) day-of-month(1-31) month(1-12) day-of-week(0-7, 7=Sunday)
6
+ // - syntax: `*`, `a`, `a-b`, `*/n`, `a-b/n`, comma lists of those. No names (JAN/MON).
7
+ // - vixie DOM/DOW OR semantics: when BOTH dom and dow are restricted, a day matches
8
+ // if either matches; otherwise both must match (an unrestricted side = full range).
9
+ // - `*/n` counts as RESTRICTED (not a star) for the DOM/DOW OR rule.
10
+ //
11
+ // Invariants nextCronRun MUST keep (review P1-5 — violating any of these causes a
12
+ // claim loop or a double fire):
13
+ // 1. The result is STRICTLY greater than `afterMs` (never equal), so callers that
14
+ // recompute `next` from the previous occurrence always move forward.
15
+ // 2. Spring-forward: a local wall time that does not exist (e.g. 02:30 on the DST
16
+ // jump day) is SKIPPED, not "rolled over" — we read back the constructed Date's
17
+ // fields and reject the candidate when they differ from what we asked for.
18
+ // 3. Fall-back: a repeated wall time resolves (via `new Date`) to its FIRST
19
+ // occurrence; it fires at most once. When `after` already sits at/after that
20
+ // first occurrence, the day is not double-counted.
21
+
22
+ const MIN = 60 * 1000;
23
+
24
+ const FIELD_DEFS = [
25
+ { name: 'minute', min: 0, max: 59 },
26
+ { name: 'hour', min: 0, max: 23 },
27
+ { name: 'dom', min: 1, max: 31 },
28
+ { name: 'month', min: 1, max: 12 },
29
+ { name: 'dow', min: 0, max: 7 }, // 7 normalized to 0 (Sunday)
30
+ ];
31
+
32
+ /** Parse one field into { values:Set<number>, restricted:boolean }. */
33
+ function _parseField(text, def) {
34
+ const values = new Set();
35
+ let restricted = false;
36
+ const add = (v) => {
37
+ if (v < def.min || v > def.max) throw new Error(`${def.name} out of range: ${v}`);
38
+ values.add(def.name === 'dow' && v === 7 ? 0 : v); // 7 → Sunday(0)
39
+ };
40
+ for (const part of String(text).split(',')) {
41
+ if (!part) throw new Error(`${def.name}: empty list item`);
42
+ const stepMatch = /^(.+)\/(\d+)$/.exec(part);
43
+ const stepBase = stepMatch ? stepMatch[1] : part;
44
+ const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
45
+ if (!Number.isInteger(step) || step < 1) throw new Error(`${def.name}: bad step ${stepMatch?.[2]}`);
46
+ if (stepBase === '*') {
47
+ restricted = restricted || !!stepMatch; // `*` alone = full range; `*/n` = restricted
48
+ for (let v = def.min; v <= def.max; v += step) add(v);
49
+ continue;
50
+ }
51
+ restricted = true;
52
+ const rangeMatch = /^(\d+)-(\d+)$/.exec(stepBase);
53
+ if (rangeMatch) {
54
+ const lo = parseInt(rangeMatch[1], 10);
55
+ const hi = parseInt(rangeMatch[2], 10);
56
+ if (lo > hi) throw new Error(`${def.name}: reversed range ${lo}-${hi}`);
57
+ for (let v = lo; v <= hi; v += step) add(v);
58
+ continue;
59
+ }
60
+ if (/^\d+$/.test(stepBase)) {
61
+ const single = parseInt(stepBase, 10);
62
+ if (stepMatch) {
63
+ // `a/n`: vixie treats as `a-max/n`.
64
+ for (let v = single; v <= def.max; v += step) add(v);
65
+ } else {
66
+ add(single);
67
+ }
68
+ continue;
69
+ }
70
+ throw new Error(`${def.name}: unsupported token "${stepBase}"`);
71
+ }
72
+ if (values.size === 0) throw new Error(`${def.name}: no values`);
73
+ return { values, restricted };
74
+ }
75
+
76
+ /**
77
+ * Parse a 5-field cron expression.
78
+ * Returns { ok:true, fields:[{values,restricted}×5], source } or { ok:false, error }.
79
+ */
80
+ export function parseCron(expr) {
81
+ if (typeof expr !== 'string') return { ok: false, error: 'not a string' };
82
+ const parts = expr.trim().split(/\s+/);
83
+ if (parts.length !== 5) return { ok: false, error: `expected 5 fields, got ${parts.length}` };
84
+ try {
85
+ const fields = parts.map((p, i) => _parseField(p, FIELD_DEFS[i]));
86
+ return { ok: true, fields, source: expr.trim() };
87
+ } catch (err) {
88
+ return { ok: false, error: err?.message || String(err) };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Does `date` match the parsed expression (local time)? dom/dow use the vixie OR rule.
94
+ * `fields` is the array from parseCron(...).fields.
95
+ */
96
+ export function cronMatchesFields(fields, date) {
97
+ const minute = date.getMinutes();
98
+ const hour = date.getHours();
99
+ const dom = date.getDate();
100
+ const month = date.getMonth() + 1;
101
+ const dow = date.getDay();
102
+ if (!fields[0].values.has(minute)) return false;
103
+ if (!fields[1].values.has(hour)) return false;
104
+ if (!fields[3].values.has(month)) return false;
105
+ const domF = fields[2];
106
+ const dowF = fields[4];
107
+ const domHit = domF.values.has(dom);
108
+ const dowHit = dowF.values.has(dow);
109
+ if (domF.restricted && dowF.restricted) return domHit || dowHit; // vixie OR
110
+ return domHit && dowHit;
111
+ }
112
+
113
+ /** Convenience: parse + match. Returns false on parse error. */
114
+ export function cronMatches(expr, date) {
115
+ const p = parseCron(expr);
116
+ if (!p.ok) return false;
117
+ return cronMatchesFields(p.fields, date);
118
+ }
119
+
120
+ /**
121
+ * Next fire time STRICTLY after `afterMs` (local time). Returns epoch ms, or null when
122
+ * no solution within ~5 years. Minute granularity.
123
+ *
124
+ * Strategy: iterate day-by-day from the day after `after`'s day when needed, but jump
125
+ * straight to the next matching time-of-day within a matching day. We never scan
126
+ * minute-by-minute, so even `* * * * *` over a 30-day sleep is ~30 iterations.
127
+ */
128
+ export function nextCronRun(expr, afterMs) {
129
+ const p = typeof expr === 'string' ? parseCron(expr) : expr;
130
+ if (!p || p.ok === false) return null;
131
+ const fields = p.fields;
132
+
133
+ // Start at the next minute boundary strictly after `afterMs`.
134
+ let cursor = Math.floor(afterMs / MIN) * MIN + MIN;
135
+ const limit = cursor + 5 * 366 * 24 * 60 * MIN; // ~5 years of minutes (upper bound)
136
+
137
+ // Ordered candidate times-of-day (minutes/hours) for quick within-day search.
138
+ const minutes = [...fields[0].values].sort((a, b) => a - b);
139
+ const hours = [...fields[1].values].sort((a, b) => a - b);
140
+
141
+ for (let guard = 0; guard < 5 * 366 && cursor < limit; guard++) {
142
+ const dayStart = new Date(cursor);
143
+ dayStart.setHours(0, 0, 0, 0);
144
+ // Does this calendar DAY match month + dom/dow? (Match the day fields only —
145
+ // probing with a specific time like noon would wrongly reject a day whose only
146
+ // matching time-of-day is, say, 09:00.) If not, jump a day.
147
+ const probe = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), 12, 0, 0, 0);
148
+ const monthHit = fields[3].values.has(probe.getMonth() + 1);
149
+ const domF = fields[2];
150
+ const dowF = fields[4];
151
+ const domHit = domF.values.has(probe.getDate());
152
+ const dowHit = dowF.values.has(probe.getDay());
153
+ const dayHit = monthHit && (domF.restricted && dowF.restricted ? (domHit || dowHit) : (domHit && dowHit));
154
+ if (!dayHit) {
155
+ // Advance by CALENDAR day (setDate+1), not +24h: a DST transition day is 23h
156
+ // or 25h long, and a fixed +24h can land back on the same day (fall-back,
157
+ // infinite loop) or skip one (spring-forward).
158
+ const next = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate() + 1, 0, 0, 0, 0);
159
+ cursor = next.getTime();
160
+ continue;
161
+ }
162
+ // Within a matching day, find the earliest matching hour:minute strictly > afterMs.
163
+ let found = null;
164
+ for (const h of hours) {
165
+ for (const m of minutes) {
166
+ const t = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), h, m, 0, 0);
167
+ // Spring-forward guard: a non-existent wall time gets normalized by Date
168
+ // (02:30 → 03:30). Read the fields back and reject the rollover.
169
+ if (t.getHours() !== h || t.getMinutes() !== m || t.getDate() !== dayStart.getDate()) continue;
170
+ const ms = t.getTime();
171
+ // Strictly greater than `afterMs` AND not before this iteration's floor.
172
+ if (ms > afterMs && ms >= cursor) { found = ms; break; }
173
+ }
174
+ if (found !== null) break;
175
+ }
176
+ if (found !== null) return found;
177
+ const nextDay = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate() + 1, 0, 0, 0, 0);
178
+ cursor = nextDay.getTime();
179
+ }
180
+ return null;
181
+ }
@@ -13,7 +13,7 @@
13
13
  import { realpathSync } from 'node:fs';
14
14
  import { resolve, basename, sep, join } from 'node:path';
15
15
  import { homedir, platform, tmpdir } from 'node:os';
16
- import { getClaudeConfigDir, onLogDirChange } from '../../findcc.js';
16
+ import { getClaudeConfigDir, onLogDirChange, LOG_DIR } from '../../findcc.js';
17
17
  import { loadWorkspaces } from '../workspace-registry.js';
18
18
  import { getBackupRoot } from './config-backup.js';
19
19
 
@@ -96,8 +96,37 @@ const SENSITIVE_CLAUDE_FILES = new Set([
96
96
  // 也绝不跨网读(master.key 同时被下方 .key 文件名规则覆盖,这里双保险)。
97
97
  'credentials.json',
98
98
  'master.key',
99
+ // cc-viewer preferences.json 现在承载定时任务定义(含 prompt,可能带 secret)——默认路径下与
100
+ // credentials 同级拒绝;LOG_DIR 被 --log-dir 移走时由下方 _logDirSensitiveFiles 按 realpath 拦。
101
+ 'preferences.json',
99
102
  ]);
100
103
 
104
+ /**
105
+ * LOG_DIR 下的敏感数据文件名(定时任务运行历史 / 旧版任务存储 / preferences 本身)。
106
+ * 默认 LOG_DIR = ~/.claude/cc-viewer 在 allowlist 内,这些文件若只按文件名拦会误伤项目内同名
107
+ * fixture,所以按 realpath 精确等值 + LOG_DIR 前缀拦截;LOG_DIR 被 setLogDir 移走时缓存随
108
+ * onLogDirChange 失效(沿用既有 roots 缓存失效钩子)。
109
+ */
110
+ const LOGDIR_SENSITIVE_NAMES = new Set([
111
+ 'preferences.json',
112
+ 'scheduled-task-runs.json',
113
+ 'scheduled-tasks.json',
114
+ ]);
115
+ let _logDirSensitiveCache = null;
116
+ function _logDirSensitiveFiles() {
117
+ if (_logDirSensitiveCache) return _logDirSensitiveCache;
118
+ let rootReal;
119
+ try { rootReal = realpathSync(LOG_DIR); } catch { rootReal = LOG_DIR; }
120
+ const files = new Set();
121
+ for (const name of LOGDIR_SENSITIVE_NAMES) {
122
+ files.add(norm(join(LOG_DIR, name)));
123
+ files.add(norm(join(rootReal, name)));
124
+ }
125
+ _logDirSensitiveCache = { root: norm(withSep(LOG_DIR)), rootReal: norm(withSep(rootReal)), files };
126
+ return _logDirSensitiveCache;
127
+ }
128
+ onLogDirChange(() => { _logDirSensitiveCache = null; });
129
+
101
130
  // ─── allowlist roots 缓存 ──────────────────────────────────────────────────────
102
131
  let _rootsCache = null;
103
132
  let _workspacesVersion = 0;
@@ -221,6 +250,21 @@ export function isReadAllowed(absPath) {
221
250
  }
222
251
  } catch { /* backup root missing / not realpath-able → no extra deny */ }
223
252
 
253
+ // 2c) Deny LOG_DIR 内的任务/偏好数据文件(preferences.json 含 auth + 定时任务 prompt、
254
+ // scheduled-task-runs.json 含 prompt 摘要、scheduled-tasks.json 为旧版任务存储)与内核写
255
+ // 过程的 tmp/corrupt 残留。这些路径必须绕开所有 HTTP 剥离逻辑之外的裸文件读写。
256
+ {
257
+ const sens = _logDirSensitiveFiles();
258
+ const realNorm = norm(real);
259
+ if (sens.files.has(realNorm)) {
260
+ return { ok: false, reason: 'sensitive-ccv-data' };
261
+ }
262
+ // .tmp-<rand>(writeJsonAtomic 中间态)与 .corrupt-<ts>(tolerant 自愈备份)同属敏感残留。
263
+ if ((realNorm.startsWith(sens.root) || realNorm.startsWith(sens.rootReal)) && /\.(tmp|corrupt)-[^/\\]+$/.test(realNorm)) {
264
+ return { ok: false, reason: 'sensitive-ccv-data' };
265
+ }
266
+ }
267
+
224
268
  // 3) 项目内文件豁免 sensitive 文件名(允许 fixtures/test-cert.pem 等合法 fixture)
225
269
  const projectRoot = getProjectRoot();
226
270
  const isInProj = isInsideRoot(real, projectRoot);
@@ -15,18 +15,21 @@ const CRED_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.config/gcloud
15
15
  // 家目录下的 shell 启动文件:写拒(被改写可植入持久化)。
16
16
  const WRITE_HOME_FILES = ['.bashrc', '.zshrc', '.bash_profile', '.zprofile', '.zshenv', '.profile', '.npmrc', '.netrc'];
17
17
  // Global settings/hooks: write-deny (protects the deny mechanism itself). Resolved against ~.
18
- const WRITE_REL_PATHS = ['.claude/settings.json', '.claude/settings.local.json', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key'];
18
+ const WRITE_REL_PATHS = ['.claude/settings.json', '.claude/settings.local.json', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key', '.claude/cc-viewer/scheduled-task-runs.json', '.claude/cc-viewer/scheduled-tasks.json'];
19
19
  // Token/secret files under home: read-deny. Resolved against ~.
20
- const READ_REL_PATHS = ['.npmrc', '.netrc', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key'];
20
+ const READ_REL_PATHS = ['.npmrc', '.netrc', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key', '.claude/cc-viewer/scheduled-task-runs.json', '.claude/cc-viewer/scheduled-tasks.json'];
21
21
 
22
22
  // Vault file names (bare-name match, any directory): LOG_DIR can be moved by --log-dir /
23
23
  // POST /api/preferences {logDir}, so hardcoding ~/.claude/cc-viewer would lose the vault whenever
24
- // the root moves. Match by file name so it stays denied regardless of root. Case-insensitive and
25
- // EXACT (does NOT match .bak/.old/.txt suffix variants — those are covered by the Bash-layer /i
26
- // regex and the backup-dir rule); basename grabs the last segment across POSIX/win32 (pathOf's
27
- // resolve would make lastIndexOf('/') miss on win32 backslashes).
24
+ // the root moves. Match by file name so it stays denied regardless of root. Case-insensitive.
25
+ // The credential vault (credentials.json/master.key) is EXACT-anchored: suffix variants
26
+ // (.bak/.old/.txt) are deliberately allowed at this layer (covered by the Bash-layer /i regex
27
+ // and the backup-dir rule). preferences.json / scheduled-task*.json additionally tolerate a
28
+ // `.corrupt-*`/`.tmp-*` suffix — those residues hold the same secrets/prompts and are denied.
28
29
  const VAULT_FILE_RE = /^(credentials\.json|master\.key)$/i;
30
+ const TASK_DATA_FILE_RE = /^(preferences\.json|scheduled-task-runs\.json|scheduled-tasks\.json)(\.(corrupt|tmp)-.*)?$/i;
29
31
  function isVaultFile(abs) { return VAULT_FILE_RE.test(basename(abs)); }
32
+ function isTaskDataFile(abs) { return TASK_DATA_FILE_RE.test(basename(abs)); }
30
33
 
31
34
  // Bash 命令硬拦截规则。每条 { re, reason }。
32
35
  const BASH_DENY_RULES = [
@@ -56,7 +59,7 @@ const BASH_DENY_RULES = [
56
59
  // + 凭证 vault(credentials.json/master.key,含 cc-viewer-config-backups 备份目录) + 全局 settings。
57
60
  // credentials.json/master.key 用裸文件名匹配(不限 cc-viewer/ 前缀),并含 cred*/master* glob 形式,
58
61
  // 防止经备份目录、相对路径或 shell glob(cred*.json master*)绕过——IM 通道宁可拦多。
59
- { re: /(id_rsa|id_ed25519|id_ecdsa|authorized_keys|\.ssh\/|\.aws\/|\.gnupg\/|\.kube\/|\.docker\/|\.config\/(gcloud|gh)\/|\.netrc|\.npmrc|cc-viewer\/preferences\.json|credentials\.json|master\.key|cc-viewer-config-backups|\.claude\/settings(\.local)?\.json)\b|(cred\*|master\*)/i, reason: 'access to credential / secret files' },
62
+ { re: /(id_rsa|id_ed25519|id_ecdsa|authorized_keys|\.ssh\/|\.aws\/|\.gnupg\/|\.kube\/|\.docker\/|\.config\/(gcloud|gh)\/|\.netrc|\.npmrc|cc-viewer\/preferences\.json|scheduled-task-runs\.json|scheduled-tasks\.json|credentials\.json|master\.key|cc-viewer-config-backups|\.claude\/settings(\.local)?\.json)\b|(cred\*|master\*)/i, reason: 'access to credential / secret files' },
60
63
  ];
61
64
 
62
65
  function underAny(absPath, roots) {
@@ -94,7 +97,7 @@ export function evaluateImDeny(toolName, toolInput = {}, opts = {}) {
94
97
  if (toolName === 'Read') {
95
98
  const abs = pathOf(toolInput, home);
96
99
  if (!abs) return { deny: false };
97
- if (isVaultFile(abs)) return { deny: true, reason: 'read of the credential vault / master key' };
100
+ if (isVaultFile(abs) || isTaskDataFile(abs)) return { deny: true, reason: 'read of the credential vault / master key' };
98
101
  if (underAny(abs, credRoots)) return { deny: true, reason: 'read of a credential directory' };
99
102
  if (READ_REL_PATHS.some((rel) => abs === resolve(home, rel))) return { deny: true, reason: 'read of a secret/credential file' };
100
103
  return { deny: false };
@@ -103,7 +106,7 @@ export function evaluateImDeny(toolName, toolInput = {}, opts = {}) {
103
106
  if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
104
107
  const abs = pathOf(toolInput, home);
105
108
  if (!abs) return { deny: false };
106
- if (isVaultFile(abs)) return { deny: true, reason: 'write to the credential vault / master key' };
109
+ if (isVaultFile(abs) || isTaskDataFile(abs)) return { deny: true, reason: 'write to the credential vault / master key' };
107
110
  if (underAny(abs, credRoots)) return { deny: true, reason: 'write to a credential directory' };
108
111
  if (WRITE_HOME_FILES.some((f) => abs === resolve(home, f))) return { deny: true, reason: 'write to a shell startup / credential file' };
109
112
  if (WRITE_REL_PATHS.some((rel) => abs === resolve(home, rel))) return { deny: true, reason: 'write to protected global config (settings/hooks or IM secrets)' };
@@ -19,7 +19,7 @@
19
19
  // writers (auth.js, im-config.js) that cannot become async without
20
20
  // cascading signature changes through routes and server startup.
21
21
  // Both serialize same-process callers and mutex cross-process on the same `${file}.lock`.
22
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, openSync, closeSync, statSync } from 'node:fs';
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, openSync, closeSync, statSync, copyFileSync } from 'node:fs';
23
23
  import { dirname } from 'node:path';
24
24
  import { randomBytes } from 'node:crypto';
25
25
  import { renameSyncWithRetry } from './file-api.js';
@@ -47,6 +47,23 @@ export function readJsonSafe(file, fallback = {}) {
47
47
  }
48
48
  }
49
49
 
50
+ /**
51
+ * Structured read distinguishing missing / valid / corrupt without any fallback.
52
+ * Used by stores that must NOT silently recover from corruption (e.g. scheduled tasks in
53
+ * preferences.json): missing file → seed on first write; corrupt → surface, never overwrite.
54
+ * Read-only, no lock.
55
+ */
56
+ export function readJsonStrict(file) {
57
+ if (!existsSync(file)) return { state: 'missing', data: null };
58
+ try {
59
+ const obj = JSON.parse(readFileSync(file, 'utf-8'));
60
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return { state: 'corrupt', data: null };
61
+ return { state: 'ok', data: obj };
62
+ } catch {
63
+ return { state: 'corrupt', data: null };
64
+ }
65
+ }
66
+
50
67
  // Sentinel for "file exists but cannot be read/parsed". A MUTATE path can choose how to treat it:
51
68
  // - DEFAULT (tolerant): collapse to `fallback` and write, so a corrupt preferences.json stays
52
69
  // self-healing (the long-standing L66 behavior — a user re-saving recovers the file).
@@ -73,6 +90,15 @@ function _resolveForMutation(data, file, strictCorrupt) {
73
90
  reportSwallowed('json-store.corrupt-guard', err);
74
91
  throw err;
75
92
  }
93
+ // Tolerant self-heal keeps the long-standing "re-save recovers the file" behavior, but the
94
+ // corrupt bytes may carry data the caller's fallback would silently erase (e.g. scheduledTasks
95
+ // inside preferences.json). Preserve them once per file before overwriting so recovery is
96
+ // possible; best-effort — a failed backup must not block the heal. copyFileSync's third arg
97
+ // is an integer flag mask, not an options object — the destination inherits the source's
98
+ // 0600 mode, so no chmod is needed.
99
+ try {
100
+ copyFileSync(file, `${file}.corrupt-${Date.now()}`);
101
+ } catch (err) { reportSwallowed('json-store.corrupt-backup', err); }
76
102
  return undefined; // tolerant: signal "use the fallback"
77
103
  }
78
104
 
@@ -263,7 +263,10 @@ const MODEL_ID_ALIASES = {
263
263
  // 让 .some 调用炸掉(模型 id 来自外部输入,不能信任)。
264
264
  // Expand a model id into every equivalent lowercase spelling.
265
265
  function modelIdVariants(id) {
266
- return Object.hasOwn(MODEL_ID_ALIASES, id) ? MODEL_ID_ALIASES[id] : [id];
266
+ // A version/routing suffix identifies the same K3 family, not an unrelated
267
+ // model. Keep the boundary so k30 and k3pro remain unmatched.
268
+ const aliasId = /^k3(?:[.-]|$)/.test(id) ? 'k3' : id;
269
+ return Object.hasOwn(MODEL_ID_ALIASES, aliasId) ? MODEL_ID_ALIASES[aliasId] : [id];
267
270
  }
268
271
 
269
272
  // 供内置预设匹配复用的导出包装:入参任意大小写,先剥 `[1m]` 类方括号后缀
@@ -16,7 +16,7 @@ import { getProjectName } from './project-state.js';
16
16
  // SINGLE SOURCE of "what must never live inside a fork": secrets (auth password + IM creds),
17
17
  // machine-level paths (logDir/claudeConfigDir), and the internal forks map. snapshotForFork /
18
18
  // cleanPatch / the admin list read all funnel through stripSensitive so the rule can't drift.
19
- const STRIP_KEYS = ['auth', 'authByProject', 'prefsByProject', 'logDir', 'claudeConfigDir'];
19
+ const STRIP_KEYS = ['auth', 'authByProject', 'prefsByProject', 'logDir', 'claudeConfigDir', 'scheduledTasks'];
20
20
 
21
21
  /** Strip secrets + IM credentials + machine-level keys from a prefs-shaped object in place. */
22
22
  export function stripSensitive(obj) {
@@ -83,6 +83,9 @@ export function resolveScoped(globalPrefs, fork) {
83
83
  const { approvalModal: gAM, ...gRest } = globalPrefs || {};
84
84
  const { approvalModal: fAM, ...fRest } = fork || {};
85
85
  const merged = { ...gRest, ...fRest };
86
+ // 定时任务是全局状态(claim CAS 只在全局 prefs 上进行)——fork 永远不得携带/遮蔽它,
87
+ // 否则脏 fork 会把任务定义/claim 透给 scoped 客户端读侧。
88
+ delete merged.scheduledTasks;
86
89
  if (gAM || fAM) {
87
90
  // 防引用别名:fork 无 approvalModal 时也返回浅拷贝,避免调用方(preferencesGet 的
88
91
  // voicePack reconcile)回写到解析出的全局对象(当前每请求新解析虽无害,仍保持"绝不外泄内部引用")。
@@ -0,0 +1,149 @@
1
+ // Structured schedule model for cc-viewer scheduled tasks.
2
+ //
3
+ // The six schedule kinds the UI offers are normalized here and mapped to cron where
4
+ // applicable, so the cron engine (./cron.js) stays the single source of truth for
5
+ // "when is the next fire". Storage keeps the structured `schedule` (not the cron
6
+ // string) so the UI can round-trip the form and render a localized description.
7
+ //
8
+ // { kind:'hourly', minute: 0-59 }
9
+ // { kind:'daily', time: 'HH:MM' }
10
+ // { kind:'weekdays',time: 'HH:MM' } → Monday..Friday
11
+ // { kind:'weekly', day: 1-7 (ISO, 1=Mon..7=Sun), time: 'HH:MM' }
12
+ // { kind:'cron', expr: '5-field cron' }
13
+ // { kind:'once', at: epochMs }
14
+ //
15
+ // Boundary: L1-lib. Imports only ./cron.js (L1) + node builtins.
16
+
17
+ import { parseCron, nextCronRun } from './cron.js';
18
+
19
+ const KINDS = new Set(['hourly', 'daily', 'weekdays', 'weekly', 'cron', 'once']);
20
+ const TIME_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
21
+
22
+ function _err(code, detail) { return { ok: false, error: code, detail }; }
23
+
24
+ /** Validate + normalize a schedule input. Returns { ok:true, schedule } | { ok:false, error, detail }. */
25
+ export function normalizeSchedule(input, now = Date.now()) {
26
+ if (!input || typeof input !== 'object') return _err('invalid_schedule', 'not an object');
27
+ const kind = input.kind;
28
+ if (!KINDS.has(kind)) return _err('invalid_schedule', `unknown kind ${kind}`);
29
+
30
+ if (kind === 'hourly') {
31
+ const minute = Number(input.minute);
32
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) return _err('invalid_schedule', 'minute must be 0-59');
33
+ return { ok: true, schedule: { kind, minute } };
34
+ }
35
+
36
+ if (kind === 'daily' || kind === 'weekdays') {
37
+ const m = TIME_RE.exec(String(input.time || ''));
38
+ if (!m) return _err('invalid_schedule', 'time must be HH:MM');
39
+ return { ok: true, schedule: { kind, time: `${m[1].padStart(2, '0')}:${m[2]}` } };
40
+ }
41
+
42
+ if (kind === 'weekly') {
43
+ const day = Number(input.day);
44
+ if (!Number.isInteger(day) || day < 1 || day > 7) return _err('invalid_schedule', 'day must be 1-7 (ISO)');
45
+ const m = TIME_RE.exec(String(input.time || ''));
46
+ if (!m) return _err('invalid_schedule', 'time must be HH:MM');
47
+ return { ok: true, schedule: { kind, day, time: `${m[1].padStart(2, '0')}:${m[2]}` } };
48
+ }
49
+
50
+ if (kind === 'cron') {
51
+ const expr = String(input.expr || '').trim();
52
+ const p = parseCron(expr);
53
+ if (!p.ok) return _err('invalid_schedule', p.error);
54
+ if (nextCronRun(expr, now) === null) return _err('invalid_schedule', 'no fire time within 5 years');
55
+ return { ok: true, schedule: { kind, expr } };
56
+ }
57
+
58
+ // once
59
+ const at = Number(input.at);
60
+ if (!Number.isFinite(at)) return _err('invalid_schedule', 'at must be epoch ms');
61
+ if (at < now + 60_000) return _err('once_in_past', 'once.at must be at least 60s in the future');
62
+ return { ok: true, schedule: { kind, at } };
63
+ }
64
+
65
+ /** Map a preset schedule to its cron equivalent. `once` has no cron form → null. */
66
+ export function scheduleToCron(schedule) {
67
+ if (!schedule || typeof schedule !== 'object') return null;
68
+ switch (schedule.kind) {
69
+ case 'hourly': return `${schedule.minute} * * * *`;
70
+ case 'daily': {
71
+ const [h, m] = schedule.time.split(':');
72
+ return `${Number(m)} ${Number(h)} * * *`;
73
+ }
74
+ case 'weekdays': {
75
+ const [h, m] = schedule.time.split(':');
76
+ return `${Number(m)} ${Number(h)} * * 1-5`;
77
+ }
78
+ case 'weekly': {
79
+ const [h, m] = schedule.time.split(':');
80
+ return `${Number(m)} ${Number(h)} * * ${schedule.day % 7}`; // ISO 7(Sun) → cron 0
81
+ }
82
+ case 'cron': return schedule.expr;
83
+ default: return null; // once
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Next BASE fire time (before jitter) strictly after `afterMs`. Epoch ms or null.
89
+ * `once` returns its `at` when still in the future, else null.
90
+ */
91
+ export function nextBaseFor(schedule, afterMs) {
92
+ if (!schedule || typeof schedule !== 'object') return null;
93
+ if (schedule.kind === 'once') return schedule.at > afterMs ? schedule.at : null;
94
+ const expr = scheduleToCron(schedule);
95
+ return expr ? nextCronRun(expr, afterMs) : null;
96
+ }
97
+
98
+ /**
99
+ * Occurrences in (fromMs, nowMs]. Returns { latest, count } or null when none.
100
+ *
101
+ * Review P1-1: "latest" and "count" are decoupled from any enumeration cap. We first
102
+ * check whether ANY occurrence exists within the 7-day catch-up window; the enumeration
103
+ * then starts from the max(fromMs, windowStart) so `latest` is the true most-recent fire,
104
+ * not a truncation point. `count` is therefore exact for the catch-up decision.
105
+ */
106
+ export function occurrencesAtOrBefore(schedule, fromMs, nowMs, { windowMs = 7 * 24 * 3600 * 1000 } = {}) {
107
+ if (!schedule || typeof schedule !== 'object') return null;
108
+ if (schedule.kind === 'once') {
109
+ const hit = schedule.at > fromMs && schedule.at <= nowMs;
110
+ return hit ? { latest: schedule.at, count: 1 } : null;
111
+ }
112
+ const expr = scheduleToCron(schedule);
113
+ if (!expr) return null;
114
+
115
+ // Cheap existence probe: is there any fire in the last `windowMs` at all? If not,
116
+ // there's nothing worth enumerating (the caller marks the task missed/stale).
117
+ const windowStart = nowMs - windowMs;
118
+ if (nextCronRun(expr, windowStart) === null || nextCronRun(expr, windowStart) > nowMs) {
119
+ // Still may have fires between fromMs and windowStart — but those are all stale.
120
+ // Report them as stale via a probe at fromMs.
121
+ const any = nextCronRun(expr, fromMs);
122
+ if (any === null || any > nowMs) return null;
123
+ return { latest: any, count: 1, stale: true };
124
+ }
125
+
126
+ // Enumerate from the later of (fromMs, windowStart) forward to nowMs. `fromMs` is a
127
+ // scheduled BASE that may itself be due right now, and nextCronRun is strictly-greater,
128
+ // so seed the cursor one ms BEFORE the start to include a fire exactly at the start.
129
+ let cursor = Math.max(fromMs, windowStart) - 1;
130
+ let latest = null;
131
+ let count = 0;
132
+ // Bounded: at most windowMs/1min iterations for `* * * * *` (10080) — acceptable.
133
+ for (let guard = 0; guard < 11000; guard++) {
134
+ const t = nextCronRun(expr, cursor);
135
+ if (t === null || t > nowMs) break;
136
+ latest = t;
137
+ count += 1;
138
+ cursor = t;
139
+ }
140
+ return latest === null ? null : { latest, count };
141
+ }
142
+
143
+ /** Deterministic per-task jitter in [0, maxMs] (FNV-1a over the task id). */
144
+ export function jitterForTaskId(id, maxMs = 120_000) {
145
+ let h = 0x811c9dc5;
146
+ const s = String(id);
147
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
148
+ return (h >>> 0) % (maxMs + 1);
149
+ }
@@ -0,0 +1,97 @@
1
+ // Scheduled-task executor — the L1 glue that turns a claimed task into an actual
2
+ // execution. server.js injects the SDK/PTY/chatQueue accessors (L1 must NOT import
3
+ // the L2 managers directly — see verify-boundaries R5), so this module stays pure
4
+ // and unit-testable with fakes.
5
+ //
6
+ // Three execution paths:
7
+ // inject (SDK mode) → sdk.sendUserMessage, with a queue-full precheck (P1-4)
8
+ // inject (PTY mode) → chatQueue.enqueue, with a STRICT instanceKey-only precheck (P0-2)
9
+ // spawn → sdk.spawnEphemeralSession (SDK mode only)
10
+ //
11
+ // Result contract:
12
+ // inject → { status:'dispatched' } | { status:'failed', reason }
13
+ // spawn → { status:'completed', sessionId, durationMs } | { status:'error', reason, error? }
14
+
15
+ /**
16
+ * @param {object} task the claimed task (has .mode/.prompt/.cwd/.project/.schedule)
17
+ * @param {object} claim the claim record
18
+ * @param {object} io injected accessors:
19
+ * {
20
+ * isSdkMode: boolean,
21
+ * sdk: { isSdkAvailable, listSdkSessions, getQueueSnapshot, sendUserMessage, spawnEphemeralSession },
22
+ * pty: { listLivePtys, getPtyKindFor },
23
+ * chatQueue: { enqueue },
24
+ * samePath(a, b): boolean,
25
+ * }
26
+ */
27
+ export async function executeScheduledTask(task, claim, io) {
28
+ if (task.mode === 'spawn') return _spawn(task, claim, io);
29
+ return io.isSdkMode ? _injectSdk(task, io) : _injectPty(task, io);
30
+ }
31
+
32
+ async function _injectSdk(task, io) {
33
+ const sdk = io.sdk;
34
+ if (!sdk || !sdk.isSdkAvailable()) return { status: 'failed', reason: 'sdk_unavailable' };
35
+ // The resident session is a per-cwd singleton — find the one for the task's cwd.
36
+ const target = sdk.listSdkSessions().find(s => io.samePath(s.cwd, task.cwd));
37
+ if (!target) return { status: 'failed', reason: 'no_live_session' };
38
+
39
+ // Queue-full precheck (P1-4): _sendUserMessage resolves normally even when the queue
40
+ // rejects the message (broadcasts queue-rejected and returns), so a dispatched verdict
41
+ // would be a lie. Check the queue depth first. NOTE the contract: getQueueSnapshot()
42
+ // returns an ARRAY (sdk-manager _getQueueSnapshot → _messageQueue.map(...)), not an
43
+ // {items} object — normalize both shapes defensively.
44
+ try {
45
+ const snap = sdk.getQueueSnapshot ? sdk.getQueueSnapshot({ instanceKey: target.instanceKey }) : null;
46
+ const depth = Array.isArray(snap) ? snap.length : (Array.isArray(snap?.items) ? snap.items.length : 0);
47
+ if (depth >= 50) return { status: 'failed', reason: 'queue_rejected' };
48
+ } catch { /* precheck is best-effort; fall through to send */ }
49
+
50
+ try {
51
+ // Fire-and-forget: acceptance = dispatched. The turn's own outcome is presented by
52
+ // the conversation itself (resident result carries no success/failure to us).
53
+ await sdk.sendUserMessage(task.prompt, { cwd: task.cwd, project: task.project, instanceKey: target.instanceKey });
54
+ return { status: 'dispatched' };
55
+ } catch (err) {
56
+ return { status: 'failed', reason: 'internal', error: String(err?.message || err) };
57
+ }
58
+ }
59
+
60
+ async function _injectPty(task, io) {
61
+ const pty = io.pty;
62
+ if (!pty) return { status: 'failed', reason: 'no_live_session' };
63
+ // Candidate Claude PTYs for the task's cwd.
64
+ const live = pty.listLivePtys().filter(p => p.ptyKind === 'claude' && io.samePath(p.cwd, task.cwd));
65
+ if (!live.length) return { status: 'failed', reason: 'no_live_session' };
66
+ const target = live.find(p => p.isActive) || live[0];
67
+
68
+ // STRICT precheck (P0-2): getPtyKindFor with ONLY instanceKey — _resolveKey does NOT
69
+ // fall back to project/active when no cwd/project is given, so a dead instance yields
70
+ // null instead of silently resolving to a same-project sibling or the wrong project's
71
+ // active session.
72
+ const kind = pty.getPtyKindFor({ instanceKey: target.instanceKey });
73
+ if (kind !== 'claude') return { status: 'failed', reason: 'no_live_session' };
74
+
75
+ // enqueue sanitizes, queues while busy, and injects via the IM bridge's bracketed-paste
76
+ // path. strictAnchor tells the drain path to stay strict (see chat-queue _injectHead).
77
+ const item = io.chatQueue.enqueue(task.prompt, { project: task.project, instanceKey: target.instanceKey, strictAnchor: true });
78
+ if (!item) return { status: 'failed', reason: 'queue_rejected' };
79
+ return { status: 'dispatched' };
80
+ }
81
+
82
+ async function _spawn(task, claim, io) {
83
+ if (!io.isSdkMode) return { status: 'failed', reason: 'spawn_unavailable' };
84
+ const sdk = io.sdk;
85
+ if (!sdk || typeof sdk.spawnEphemeralSession !== 'function') return { status: 'failed', reason: 'spawn_unavailable' };
86
+ const startedAt = Date.now();
87
+ try {
88
+ // Permission mode (security P1-3): 'default' (the stored default) forces approval
89
+ // prompts; 'inherit' passes undefined so the child uses the host session's mode.
90
+ const permissionMode = task.permissionMode === 'inherit' ? undefined : 'default';
91
+ const r = await sdk.spawnEphemeralSession(task.prompt, { cwd: task.cwd, project: task.project, signal: claim?.signal, permissionMode });
92
+ if (r.ok) return { status: 'completed', sessionId: r.sessionId, durationMs: Date.now() - startedAt };
93
+ return { status: 'error', reason: r.reason || 'internal', error: r.error || null, sessionId: r.sessionId || null, durationMs: Date.now() - startedAt };
94
+ } catch (err) {
95
+ return { status: 'error', reason: 'internal', error: String(err?.message || err), durationMs: Date.now() - startedAt };
96
+ }
97
+ }