cc-viewer 1.9.11 → 1.9.13

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 (94) hide show
  1. package/README.md +8 -0
  2. package/dist/assets/{App-D3swMyLc.js → App-BzOM1_y-.js} +2 -2
  3. package/dist/assets/{MdxEditorPanel-DeyoQsxw.js → MdxEditorPanel-Db1nmcJV.js} +1 -1
  4. package/dist/assets/{Mobile-DvI_Kby2.js → Mobile-rjfABa-U.js} +1 -1
  5. package/dist/assets/{ProxyStatsModal-CoI275by.js → ProxyStatsModal-Ch1BH0Kp.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-BRW8dfBU.js → index-CAiC6cw-.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-CxUFE36z.css +41 -0
  57. package/dist/assets/seqResourceLoaders-Ds8-_QA3.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/package.json +1 -1
  73. package/server/lib/chat-queue.js +14 -1
  74. package/server/lib/config-backup.js +4 -2
  75. package/server/lib/cron.js +181 -0
  76. package/server/lib/file-access-policy.js +45 -1
  77. package/server/lib/im-deny.js +12 -9
  78. package/server/lib/json-store.js +27 -1
  79. package/server/lib/project-prefs.js +4 -1
  80. package/server/lib/schedule-model.js +149 -0
  81. package/server/lib/scheduled-task-executor.js +97 -0
  82. package/server/lib/scheduled-task-runs-store.js +99 -0
  83. package/server/lib/scheduled-task-scheduler.js +254 -0
  84. package/server/lib/scheduled-task-store.js +511 -0
  85. package/server/lib/sdk-manager.js +283 -12
  86. package/server/pty-manager.js +13 -2
  87. package/server/routes/preferences.js +24 -1
  88. package/server/routes/scheduled-tasks.js +310 -0
  89. package/server/server.js +135 -0
  90. package/dist/assets/classDiagram-6PBFFD2Q-C0dwAyuU.js +0 -1
  91. package/dist/assets/classDiagram-v2-HSJHXN6E-C0dwAyuU.js +0 -1
  92. package/dist/assets/clone-fiofFlrV.js +0 -1
  93. package/dist/assets/seqResourceLoaders-8kfGbCYo.js +0 -2
  94. package/dist/assets/seqResourceLoaders-C2YGihCH.css +0 -41
@@ -32,7 +32,7 @@ import { evaluateImDeny } from './im-deny.js';
32
32
  import { APPROVAL_TOOLS, isOverTrustDir, isPublishCommand } from './approval-policy.js';
33
33
  import { reportSwallowed } from '../lib/error-report.js';
34
34
  import { resolve as _pathResolve, dirname as _pathDirname, sep as _pathSep } from 'node:path';
35
- import { createHash } from 'node:crypto';
35
+ import { createHash, randomBytes } from 'node:crypto';
36
36
  import { registerSdkSessionLister } from './sdk-session-index.js';
37
37
  import { readPrefsRaw } from './prefs-store.js';
38
38
 
@@ -49,6 +49,12 @@ try {
49
49
  // MUST stay module-level (outside any Session closure): tests set it BEFORE initSdkSession.
50
50
  export function __setQueryForTests(fn) { _query = fn; }
51
51
 
52
+ // Test seams for the ephemeral-spawn wiring: observe the module-level turn-end callback
53
+ // and the ephemeral-host set without a full initSdkSession.
54
+ export function __setOnTurnEndForTests(fn) { _onTurnEnd = fn; }
55
+ export function __ephemeralHostCountForTests() { return _ephemeralHosts.size; }
56
+ export function __ephemeralAbortCountForTests() { return _ephemeralAborts.size; }
57
+
52
58
  // ── Session registry ─────────────────────────────────────────────────────────
53
59
  // Keyed by path.resolve(cwd) — normalize only, NO realpath (house exact-string
54
60
  // convention, routes/resume.js, so `/tmp` vs `/private/tmp` stay distinct). The
@@ -56,6 +62,14 @@ export function __setQueryForTests(fn) { _query = fn; }
56
62
  // selects which session the SDK composer and WS handlers act on.
57
63
  const _sessions = new Map();
58
64
  let _activeKey = null;
65
+ // Ephemeral-turn hosts (scheduled-task spawn): sessions whose approval plumbing is
66
+ // currently borrowed by a standalone run. resolveApproval/cancelApproval/
67
+ // getPendingApprovals scan the UNION of _sessions and this set, so an ephemeral run's
68
+ // approvals stay resolvable even if the user closes the host workspace mid-run.
69
+ const _ephemeralHosts = new Set();
70
+ // AbortControllers of in-flight ephemeral turns, so stopSession() can abort them on
71
+ // shutdown (Electron stop/start, process exit) instead of leaking a hung child.
72
+ const _ephemeralAborts = new Set();
59
73
 
60
74
  function _normalizeCwd(cwd) {
61
75
  if (typeof cwd !== 'string' || !cwd) return '';
@@ -624,6 +638,167 @@ function _createSession(key, cwd) {
624
638
  _drainResidentIterator(query, inputQueue);
625
639
  }
626
640
 
641
+ /**
642
+ * Run ONE standalone prompt in an ephemeral query that shares THIS session's approval
643
+ * plumbing (canUseTool host) but never touches resident state: not registered in
644
+ * `_sessions`, doesn't change `_activeKey`, doesn't go through `_processMessage`
645
+ * (so `_sessionId`/`_queryBusy`/lifecycle frames/watchdog are all untouched), and the
646
+ * result is judged directly from the query's own `result` message.
647
+ *
648
+ * Used by scheduled-task "spawn" mode: each run is a fresh, isolated headless session
649
+ * whose transcript routes to the task's project via cloned env headers (the resident
650
+ * session's env object is mutated in place by _ensureResidentQuery, so we must NOT
651
+ * reuse its reference — see _cloneEphemeralEnv).
652
+ *
653
+ * Returns { ok:true, sessionId, subtype, durationMs }
654
+ * or { ok:false, reason, error?, sessionId? }.
655
+ */
656
+ async function _runEphemeralTurn(prompt, { timeoutMs = 30 * 60 * 1000, signal, permissionMode } = {}) {
657
+ if (!_query) return { ok: false, reason: 'sdk_unavailable' };
658
+ const ctx = { sessionId: null, approvalIds: [] };
659
+ const abort = new AbortController();
660
+ _ephemeralAborts.add(abort);
661
+ // Pre-aborted signal: bail BEFORE registering listeners / spawning (the abort listener
662
+ // would never fire for an already-aborted signal, wedging the run until the 30min
663
+ // timeout — review P3-7).
664
+ if (signal && signal.aborted) { _ephemeralAborts.delete(abort); return { ok: false, reason: 'aborted' }; }
665
+ const onOuterAbort = () => abort.abort();
666
+ if (signal) { try { signal.addEventListener('abort', onOuterAbort, { once: true }); } catch { /* best-effort */ } }
667
+
668
+ // Assemble options by reusing this host session's config, but with a CLONED env so
669
+ // the resident query's in-place header append can't leak the host's project/instance
670
+ // routing into this isolated child (and vice versa).
671
+ const env = _cloneEphemeralEnv(_childEnv);
672
+ if (!env) {
673
+ _ephemeralAborts.delete(abort);
674
+ if (signal) { try { signal.removeEventListener('abort', onOuterAbort); } catch { /* best-effort */ } }
675
+ return { ok: false, reason: 'not_initialized' };
676
+ }
677
+ const ephInstanceKey = `ccv-${randomBytes(16).toString('hex')}`; // matches interceptor's /^ccv-[0-9a-f]+$/
678
+ // Permission mode (review: security P1-3): a spawn task runs unattended, so it must NOT
679
+ // silently inherit a bypassPermissions host. Default to 'default' unless the task
680
+ // explicitly opted into inheriting the host's mode.
681
+ const ephPermissionMode = (typeof permissionMode === 'string' && permissionMode) ? permissionMode : 'default';
682
+ const options = {
683
+ cwd: _cwd,
684
+ permissionMode: ephPermissionMode,
685
+ canUseTool: (tool, input, opts) => _handleCanUseTool(tool, input, opts, ctx),
686
+ ...ephPermissionMode === 'bypassPermissions' && { allowDangerouslySkipPermissions: true },
687
+ };
688
+ options.env = env;
689
+ // Route this child's transcript to ITS project (the task's cwd) under a synthetic
690
+ // instance, exactly like the resident path tags x-ccv-project-dir/x-ccv-instance.
691
+ try {
692
+ const headerCwd = String(_cwd).replace(/[\r\n\x00-\x1f\x7f]/g, '');
693
+ if (headerCwd) {
694
+ const injected = `x-ccv-project-dir: ${headerCwd}\nx-ccv-instance: ${ephInstanceKey}`;
695
+ options.env.ANTHROPIC_CUSTOM_HEADERS = options.env.ANTHROPIC_CUSTOM_HEADERS
696
+ ? `${options.env.ANTHROPIC_CUSTOM_HEADERS}\n${injected}` : injected;
697
+ }
698
+ } catch (err) { reportSwallowed('sdk-manager.ephemeral-headers', err); }
699
+ if (_settings) options.settings = _settings;
700
+ if (_launchModel) options.model = _launchModel;
701
+ if (_claudeExecutable) options.pathToClaudeCodeExecutable = _claudeExecutable;
702
+ // Fresh session: no resume; inject the startup system prompt like a new launch.
703
+ try {
704
+ const launchArgs = process.env.CCV_SKIP_THINKING_DISPLAY === '1' ? _userArgs : withDefaultThinkingDisplay(_userArgs);
705
+ const lc = resolveLaunchSystemPrompt({
706
+ spawnDir: _cwd, extraArgs: launchArgs, env: env || process.env,
707
+ launchSettings: _settings, resume: null, persistPending: true,
708
+ });
709
+ options.extraArgs = launchArgsToExtraArgs([...launchArgs, ...lc.sysPrompt.args]);
710
+ } catch (err) {
711
+ reportSwallowed('sdk-manager.ephemeral-sysprompt', err);
712
+ try { options.extraArgs = launchArgsToExtraArgs(_userArgs); } catch { /* best-effort */ }
713
+ }
714
+
715
+ // _query() can throw synchronously (bad options); that must still release the abort
716
+ // handle registered above instead of leaking it into _ephemeralAborts (review P3).
717
+ let q;
718
+ try {
719
+ q = _query({ prompt: String(prompt == null ? '' : prompt), options });
720
+ } catch (err) {
721
+ _ephemeralAborts.delete(abort);
722
+ if (signal) { try { signal.removeEventListener('abort', onOuterAbort); } catch { /* best-effort */ } }
723
+ return { ok: false, reason: 'internal', error: String(err?.message || err) };
724
+ }
725
+ const startedAt = Date.now();
726
+
727
+ const cleanupApprovals = (reason) => {
728
+ // Resolve + dismiss any approvals THIS run left pending so they don't ghost in the
729
+ // host's map for 24h (AskUserQuestion) and replay as phantom modals on reconnect.
730
+ for (const id of ctx.approvalIds) {
731
+ const pending = _pendingApprovals.get(id);
732
+ try { pending?.resolve(null); } catch { /* best-effort */ }
733
+ try { _pendingApprovals.delete(id); } catch { /* best-effort */ }
734
+ if (pending) { try { _broadcastApprovalDismiss(pending.kind, id, reason); } catch { /* best-effort */ } }
735
+ }
736
+ ctx.approvalIds.length = 0;
737
+ };
738
+
739
+ const run = (async () => {
740
+ let resultMsg = null;
741
+ try {
742
+ for await (const msg of q) {
743
+ if (msg?.type === 'system' && msg.subtype === 'init' && msg.session_id) ctx.sessionId = msg.session_id;
744
+ if (msg?.type === 'result') { resultMsg = msg; if (msg.session_id) ctx.sessionId = msg.session_id; }
745
+ }
746
+ } catch (err) {
747
+ if (abort.signal.aborted) return { ok: false, reason: 'aborted', sessionId: ctx.sessionId };
748
+ return { ok: false, reason: 'internal', error: String(err?.message || err), sessionId: ctx.sessionId };
749
+ }
750
+ if (!resultMsg) return { ok: false, reason: 'internal', error: 'no result message', sessionId: ctx.sessionId };
751
+ const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
752
+ if (!isError) {
753
+ return { ok: true, sessionId: ctx.sessionId || resultMsg.session_id || null, subtype: resultMsg.subtype, durationMs: Date.now() - startedAt };
754
+ }
755
+ const detail = Array.isArray(resultMsg.errors) && resultMsg.errors.length
756
+ ? resultMsg.errors.join('; ')
757
+ : (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
758
+ return { ok: false, reason: 'turn-error', error: String(detail), sessionId: ctx.sessionId || resultMsg.session_id || null, durationMs: Date.now() - startedAt };
759
+ })();
760
+
761
+ let timer = null;
762
+ let timedOut = false;
763
+ const timeout = new Promise((resolve) => {
764
+ timer = setTimeout(() => {
765
+ timedOut = true;
766
+ try { abort.abort(); } catch {}
767
+ try { q.close?.(); } catch {}
768
+ resolve({ ok: false, reason: 'timeout', sessionId: ctx.sessionId });
769
+ }, timeoutMs);
770
+ if (typeof timer.unref === 'function') timer.unref();
771
+ });
772
+ const aborted = new Promise((resolve) => {
773
+ try {
774
+ abort.signal.addEventListener('abort', () => {
775
+ // A timeout-driven abort reports as 'timeout', not 'aborted' — the timeout
776
+ // branch wins the race semantics even though both fire on the same signal.
777
+ if (timedOut) return;
778
+ try { q.close?.(); } catch {}
779
+ resolve({ ok: false, reason: 'aborted', sessionId: ctx.sessionId });
780
+ }, { once: true });
781
+ } catch { /* best-effort */ }
782
+ });
783
+
784
+ try {
785
+ const r = await Promise.race([run, timeout, aborted]);
786
+ // Emit a turn-end so the panel's new session card leaves the "running" state
787
+ // (review P1-2): module-level _onTurnEnd doesn't touch host state.
788
+ if (ctx.sessionId && _onTurnEnd) {
789
+ try { _onTurnEnd({ sessionId: ctx.sessionId, ts: Date.now(), cwd: _cwd }); }
790
+ catch (err) { reportSwallowed('sdk-manager.ephemeral-turn-end', err); }
791
+ }
792
+ if (ctx.approvalIds.length) cleanupApprovals(r.ok ? 'completed' : (r.reason || 'ended'));
793
+ return r;
794
+ } finally {
795
+ if (timer) clearTimeout(timer);
796
+ _ephemeralAborts.delete(abort);
797
+ try { abort.abort(); } catch { /* release listeners */ }
798
+ if (signal) { try { signal.removeEventListener('abort', onOuterAbort); } catch { /* best-effort */ } }
799
+ }
800
+ }
801
+
627
802
  async function _drainResidentIterator(query, inputQueue) {
628
803
  try {
629
804
  for await (const msg of query) {
@@ -1090,8 +1265,19 @@ function _createSession(key, cwd) {
1090
1265
  * short-circuits the ExitPlanMode/AskUserQuestion interactive branches;
1091
1266
  * 4. the three regular branches (plan / ask / perm).
1092
1267
  */
1093
- async function _handleCanUseTool(toolName, input, options) {
1268
+ async function _handleCanUseTool(toolName, input, options, ctx = null) {
1094
1269
  const id = options?.toolUseID || `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1270
+ // Ephemeral-turn override (scheduled-task spawn): the approval payloads below carry
1271
+ // the ephemeral run's own sessionId (tracked in ctx as the init/result messages
1272
+ // stream in), NOT this host session's `_sessionId` — otherwise the pending modal is
1273
+ // attributed to the host's conversation. Frontend dismiss matches on `id` only, so
1274
+ // the host's sessionId stays untouched on the dismiss path.
1275
+ const effSessionId = () => (ctx && ctx.sessionId) || _sessionId || null;
1276
+ // Origin marker (security P2-1): an ephemeral/scheduled-task run's approval modal must
1277
+ // be identifiable as coming from an unattended task, not the user's live conversation.
1278
+ const origin = ctx ? { origin: 'scheduled-task' } : {};
1279
+ // Approval-id tracking happens inside _waitForApproval (only reached when a tool truly
1280
+ // parks for user input) so auto-allowed tools don't grow ctx.approvalIds unboundedly.
1095
1281
 
1096
1282
  // 1. IM worker hard deny — must run before any auto-allow (perm-bridge.js parity).
1097
1283
  if (process.env.CCV_IM_DENY === '1') {
@@ -1124,11 +1310,11 @@ function _createSession(key, cwd) {
1124
1310
  }
1125
1311
  } catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
1126
1312
  }
1127
- const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: _sessionId || null };
1313
+ const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
1128
1314
  if (_broadcastWs) {
1129
1315
  _broadcastWs(planPayload);
1130
1316
  }
1131
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal);
1317
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx);
1132
1318
  if (result === null) {
1133
1319
  return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
1134
1320
  }
@@ -1158,11 +1344,11 @@ function _createSession(key, cwd) {
1158
1344
  // server/lib/ask/ask-constants.js.
1159
1345
  const askTimeoutMs = ASK_TIMEOUT_MS;
1160
1346
  const askStartedAt = Date.now();
1161
- const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: _sessionId || null };
1347
+ const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), ...origin };
1162
1348
  if (_broadcastWs) {
1163
1349
  _broadcastWs(askPayload);
1164
1350
  }
1165
- const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload, options?.signal);
1351
+ const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload, options?.signal, ctx);
1166
1352
  if (answers === null) {
1167
1353
  return { behavior: 'deny', message: 'Timeout waiting for user answer' };
1168
1354
  }
@@ -1234,12 +1420,12 @@ function _createSession(key, cwd) {
1234
1420
  // unknown decision → fall through to normal approval flow
1235
1421
  } catch (err) { reportSwallowed('sdk-manager.waterfall.perm', err); }
1236
1422
  }
1237
- const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: _sessionId || null };
1423
+ const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
1238
1424
  if (_broadcastWs) {
1239
1425
  _broadcastWs(permPayload);
1240
1426
  }
1241
1427
 
1242
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal);
1428
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx);
1243
1429
  if (result === null) {
1244
1430
  return { behavior: 'deny', message: 'Timeout waiting for user approval' };
1245
1431
  }
@@ -1280,7 +1466,11 @@ function _createSession(key, cwd) {
1280
1466
  } catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
1281
1467
  }
1282
1468
 
1283
- function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null) {
1469
+ function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null) {
1470
+ // Record the id on the ephemeral run's ctx ONLY when it actually parks (the three
1471
+ // callers below sit after every auto-allow/hard-deny early-return, so an auto-allowed
1472
+ // tool never lands here) — keeps ctx.approvalIds bounded (review P3: no unbounded growth).
1473
+ if (ctx && Array.isArray(ctx.approvalIds)) ctx.approvalIds.push(id);
1284
1474
  return new Promise((resolve) => {
1285
1475
  const timer = setTimeout(() => {
1286
1476
  _pendingApprovals.delete(id);
@@ -1649,6 +1839,7 @@ function _createSession(key, cwd) {
1649
1839
  },
1650
1840
  switchToSession: _switchToSession,
1651
1841
  getSessionId: () => _sessionId,
1842
+ runEphemeralTurn: _runEphemeralTurn,
1652
1843
  close: () => {
1653
1844
  for (const { id, kind } of _cancelAllApprovals()) {
1654
1845
  _broadcastApprovalDismiss(kind, id, 'session-closed');
@@ -1667,6 +1858,78 @@ export function isSdkAvailable() {
1667
1858
  return typeof _query === 'function';
1668
1859
  }
1669
1860
 
1861
+ /**
1862
+ * Clone the host launch env for an ephemeral child, stripping any x-ccv-project-dir /
1863
+ * x-ccv-instance lines the resident query already appended IN PLACE (cli.js hands the
1864
+ * same childEnv object to initSdkSession AND setSdkLaunchConfig; _ensureResidentQuery
1865
+ * mutates it). Without this the isolated child would report the HOST's routing headers
1866
+ * and its transcript would be written under the wrong project/instance.
1867
+ * Returns null when there is no env to clone (SDK children default to process.env only
1868
+ * when `env` is omitted entirely — passing `{}` would spawn with an EMPTY environment).
1869
+ */
1870
+ function _cloneEphemeralEnv(env) {
1871
+ if (!env || typeof env !== 'object') return null;
1872
+ const out = { ...env };
1873
+ if (typeof out.ANTHROPIC_CUSTOM_HEADERS === 'string') {
1874
+ out.ANTHROPIC_CUSTOM_HEADERS = out.ANTHROPIC_CUSTOM_HEADERS
1875
+ .split('\n')
1876
+ .filter(l => !/^x-ccv-(project-dir|instance):/i.test(l.trim()))
1877
+ .join('\n');
1878
+ }
1879
+ return out;
1880
+ }
1881
+
1882
+ /**
1883
+ * Spawn a standalone, single-prompt SDK session for a scheduled task ("spawn" mode).
1884
+ * The run is isolated from the user's resident conversation: it never enters
1885
+ * `_sessions`, never changes `_activeKey`, and its result is judged from the query's
1886
+ * own `result` message. Approval capability is borrowed from a host session for the
1887
+ * task's cwd (the existing resident session when one is registered, else a temporary
1888
+ * isolated host that is created just to carry approvals and torn down right after).
1889
+ *
1890
+ * SDK-mode only: without a launch-config template (setSdkLaunchConfig) the child would
1891
+ * bypass ccv's loopback proxy and produce no transcript, so we refuse.
1892
+ *
1893
+ * Returns { ok:true, sessionId, subtype, durationMs }
1894
+ * or { ok:false, reason:'sdk_unavailable'|'not_initialized'|'timeout'|'aborted'|'turn-error'|'internal', error?, sessionId? }.
1895
+ */
1896
+ export async function spawnEphemeralSession(prompt, { cwd, project, timeoutMs = 30 * 60 * 1000, signal, permissionMode } = {}) {
1897
+ if (!_query) return { ok: false, reason: 'sdk_unavailable' };
1898
+ const cfg = _launchConfigTemplate;
1899
+ if (!cfg) return { ok: false, reason: 'not_initialized' };
1900
+ if (!cwd) return { ok: false, reason: 'not_initialized' };
1901
+
1902
+ const key = _normalizeCwd(cwd);
1903
+ let host = _sessions.get(key) || null;
1904
+ let created = false;
1905
+ if (!host) {
1906
+ // No resident session for this cwd in this boot: create an isolated host purely to
1907
+ // carry approvals (its canUseTool plumbing). It is NOT registered in _sessions.
1908
+ host = _createSession(`__ephemeral_host__:${key}`, cwd);
1909
+ try { host.init({ ...cfg }); }
1910
+ catch (err) { reportSwallowed('sdk-manager.ephemeral-init', err); return { ok: false, reason: 'not_initialized', error: String(err?.message || err) }; }
1911
+ created = true;
1912
+ }
1913
+ _ephemeralHosts.add(host);
1914
+ try {
1915
+ const r = await host.runEphemeralTurn(prompt, { timeoutMs, signal, permissionMode });
1916
+ return { ...r, sessionId: r.sessionId || null };
1917
+ } finally {
1918
+ _ephemeralHosts.delete(host);
1919
+ if (created) { try { host.teardown(); } catch (err) { reportSwallowed('sdk-manager.ephemeral-teardown', err); } }
1920
+ }
1921
+ }
1922
+
1923
+ /** Test seam: is a cwd's session currently hosting an ephemeral run? Checks the ephemeral
1924
+ * host set directly (an isolated host for an unregistered cwd never enters _sessions). */
1925
+ export function __isEphemeralHostForTests(cwd) {
1926
+ const key = _normalizeCwd(cwd);
1927
+ for (const host of _ephemeralHosts) {
1928
+ if (host.key === key || host.key === `__ephemeral_host__:${key}`) return true;
1929
+ }
1930
+ return false;
1931
+ }
1932
+
1670
1933
  /**
1671
1934
  * Initialize (or re-initialize) a workspace's SDK session.
1672
1935
  * Does NOT start a query — waits for the first user message via sendUserMessage().
@@ -1760,7 +2023,9 @@ export async function sendUserMessage(text, anchor) {
1760
2023
  * server.js when a WS message arrives. Does not switch active.
1761
2024
  */
1762
2025
  export function resolveApproval(id, value) {
1763
- for (const sess of _sessions.values()) {
2026
+ // Scan the union of resident sessions and ephemeral hosts: an ephemeral run's
2027
+ // approvals must stay resolvable even if the user closed the host workspace mid-run.
2028
+ for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
1764
2029
  if (sess.resolveApproval(id, value)) return true;
1765
2030
  }
1766
2031
  return false;
@@ -1771,7 +2036,7 @@ export function resolveApproval(id, value) {
1771
2036
  * WS handler). Does not switch active.
1772
2037
  */
1773
2038
  export function cancelApproval(id, reason) {
1774
- for (const sess of _sessions.values()) {
2039
+ for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
1775
2040
  if (sess.cancelApproval(id, reason)) return true;
1776
2041
  }
1777
2042
  return false;
@@ -1786,7 +2051,7 @@ export function getPendingApprovals(anchor) {
1786
2051
  const target = anchor ? _sessionForAnchor(anchor) : null;
1787
2052
  if (target) return target.getPendingApprovals();
1788
2053
  const out = [];
1789
- for (const sess of _sessions.values()) out.push(...sess.getPendingApprovals());
2054
+ for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) out.push(...sess.getPendingApprovals());
1790
2055
  return out;
1791
2056
  }
1792
2057
 
@@ -1842,6 +2107,12 @@ export function getQueueSnapshot(anchor) {
1842
2107
  * session and clears the registry + the active pointer (process-exit cleanup path).
1843
2108
  */
1844
2109
  export function stopSession() {
2110
+ // Abort any in-flight ephemeral turns FIRST (their host may be about to be torn down),
2111
+ // then tear down resident sessions and ephemeral hosts.
2112
+ for (const abort of _ephemeralAborts) { try { abort.abort(); } catch { /* best-effort */ } }
2113
+ _ephemeralAborts.clear();
2114
+ for (const host of _ephemeralHosts) { try { host.teardown(); } catch (err) { reportSwallowed('sdk-manager.ephemeral-stop', err); } }
2115
+ _ephemeralHosts.clear();
1845
2116
  for (const sess of _sessions.values()) {
1846
2117
  try { sess.teardown(); }
1847
2118
  catch (err) { reportSwallowed('sdk-manager.stop', err); }
@@ -501,6 +501,10 @@ async function _spawnClaudeImpl(key, proxyPort, cwd, extraArgs = [], claudePath
501
501
  `Edit(${home}/.claude/settings.json)`, `Write(${home}/.claude/settings.json)`,
502
502
  `Edit(${home}/.claude/settings.local.json)`, `Write(${home}/.claude/settings.local.json)`,
503
503
  `Edit(${home}/.claude/cc-viewer/preferences.json)`, `Write(${home}/.claude/cc-viewer/preferences.json)`,
504
+ // preferences.json 现承载定时任务定义(含 prompt);运行历史/旧版任务文件同理拒读拒写。
505
+ `Read(${home}/.claude/cc-viewer/preferences.json)`,
506
+ `Read(${home}/.claude/cc-viewer/scheduled-task-runs.json)`, `Edit(${home}/.claude/cc-viewer/scheduled-task-runs.json)`, `Write(${home}/.claude/cc-viewer/scheduled-task-runs.json)`,
507
+ `Read(${home}/.claude/cc-viewer/scheduled-tasks.json)`, `Edit(${home}/.claude/cc-viewer/scheduled-tasks.json)`, `Write(${home}/.claude/cc-viewer/scheduled-tasks.json)`,
504
508
  `Read(${home}/.claude/cc-viewer/credentials.json)`, `Edit(${home}/.claude/cc-viewer/credentials.json)`, `Write(${home}/.claude/cc-viewer/credentials.json)`,
505
509
  `Read(${home}/.claude/cc-viewer/master.key)`, `Edit(${home}/.claude/cc-viewer/master.key)`, `Write(${home}/.claude/cc-viewer/master.key)`,
506
510
  // Backup copies of the vault (cc-viewer-config-backups/<ts>/) hold the same decryption
@@ -1323,9 +1327,16 @@ export function writeToPtyFor(data, { sessionId, project, instanceKey } = {}) {
1323
1327
  return false;
1324
1328
  }
1325
1329
 
1326
- /** writeToPtySequential variant routed by an explicit anchor. */
1330
+ /** writeToPtySequential variant routed by an explicit anchor. When `opts.strict` is set
1331
+ * and the anchor names an explicit instanceKey that no longer resolves to a LIVE record,
1332
+ * the write fails (onComplete(false)) WITHOUT falling back to the project/active record —
1333
+ * scheduled-task injection must never land in the wrong conversation (review P0-2). */
1327
1334
  export function writeToPtySequentialFor(chunks, onComplete, opts = {}, anchor = {}) {
1328
- const key = _resolveKeyByAnchor(anchor);
1335
+ let key = _resolveKeyByAnchor(anchor);
1336
+ if (opts && opts.strict && typeof anchor.instanceKey === 'string' && anchor.instanceKey) {
1337
+ const rec = ptys.get(anchor.instanceKey);
1338
+ if (!rec || !rec.ptyProcess) key = null; // strict: a dead explicit instance never falls back
1339
+ }
1329
1340
  const s = key ? ptys.get(key) : null;
1330
1341
  if (!s || !s.ptyProcess || !chunks || chunks.length === 0) {
1331
1342
  if (onComplete) onComplete(false);
@@ -1,5 +1,5 @@
1
1
  // Preferences, Claude settings, and proxy-profile routes (moved verbatim from server.js).
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { LOG_DIR, setLogDir, getClaudeConfigDir, discoverClaudeExecutables, resolveExplicitClaudePath, CLAUDE_EXECUTABLE_PREF_KEY } from '../../findcc.js';
@@ -14,6 +14,7 @@ import { readClaudeProjectModel } from '../lib/context-watcher.js';
14
14
  import { inspectShellHook } from '../lib/shell-hook-inspect.js';
15
15
  import { sendEventToClients } from '../lib/log-watcher.js';
16
16
  import { listPlatforms } from '../lib/im/im-config.js';
17
+ import { runsFilePath } from '../lib/scheduled-task-runs-store.js';
17
18
  import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
18
19
  import { mutateJsonSync } from '../lib/json-store.js';
19
20
  import { persistProfilesApiKeys } from '../lib/credential-access.js';
@@ -50,6 +51,9 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
50
51
  // 全局 auth 与每个项目的 authByProject 覆盖都要剥离(后者同样含明文密码)。
51
52
  delete prefs.auth;
52
53
  delete prefs.authByProject;
54
+ // 定时任务(含 prompt/调度状态)属敏感数据:只能经 /api/scheduled-tasks 读写(admin+same-origin),
55
+ // 绝不随 prefs 下发/回写 —— 否则任何已授权客户端都能拿到 prompt 或借 applyPrefsPatch 注入任务。
56
+ delete prefs.scheduledTasks;
53
57
  if (!isAdmin || !isSameOriginBrowserRequest(req, parsedUrl)) {
54
58
  delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
55
59
  }
@@ -70,6 +74,7 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
70
74
  // 客户端的读路径,与 admin 列表口保持对称——手改 preferences.json 注入的脏 fork 也不会泄露。
71
75
  delete prefs.auth;
72
76
  delete prefs.authByProject;
77
+ delete prefs.scheduledTasks; // fork 防御性二次剥离:脏 fork 也不得把任务带给远程客户端
73
78
  stripImConfigs(prefs);
74
79
  }
75
80
  prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
@@ -143,6 +148,9 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
143
148
  // prefsByProject(项目独立配置 fork)只能经 admin-only 的 /api/project-prefs/* 修改;元字段
144
149
  // (_isLocal/_projectScoped/…)仅 GET 回包,绝不让它们经 /api/preferences 落盘污染。
145
150
  delete incoming.prefsByProject;
151
+ // 定时任务只能经 /api/scheduled-tasks (admin+same-origin) 写 —— 任何客户端(含 admin)
152
+ // 都不得借 prefs POST 注入/清空/覆盖任务(claim CAS、调度状态校验只在 store 层做)。
153
+ delete incoming.scheduledTasks;
146
154
  for (const k of Object.keys(incoming)) if (k[0] === '_') delete incoming[k];
147
155
  // IM bridge configs 同理:只能经 admin-only 的 /api/dingtalk/config、/api/im/* 修改,禁止借
148
156
  // /api/preferences 植入凭据。
@@ -154,11 +162,13 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
154
162
  // 因此只允许 admin(本机 loopback 或已鉴权远程 admin)触发——未鉴权远程/项目客户端直接剥掉。
155
163
  let targetFile = deps.getPrefsFile();
156
164
  let carried = null;
165
+ let oldLogDir = null;
157
166
  if (incoming.logDir && typeof incoming.logDir === 'string') {
158
167
  if (!isAdminReq(req, isLocal)) {
159
168
  delete incoming.logDir; // non-admin cannot move the data root (would open the LAN gate)
160
169
  } else {
161
170
  carried = readPrefsRaw(targetFile); // 旧文件全量(锁外读,迁移属罕见 admin 操作)
171
+ oldLogDir = LOG_DIR;
162
172
  setLogDir(incoming.logDir);
163
173
  targetFile = deps.getPrefsFile();
164
174
  }
@@ -215,10 +225,23 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
215
225
  delete prefs.auth;
216
226
  delete prefs.authByProject;
217
227
  delete prefs.prefsByProject; // fork blob 绝不回显(与 GET 一致)
228
+ delete prefs.scheduledTasks; // 任务(含 prompt)同样不回显——读写都走 /api/scheduled-tasks
218
229
  if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
219
230
  delete prefs[CLAUDE_EXECUTABLE_PREF_KEY];
220
231
  }
221
232
  stripImConfigs(prefs);
233
+ // logDir 切换时把旧根的定时任务运行历史一并搬到新根(任务定义已随 carried 进新 prefs;
234
+ // 历史不带过去会让既有任务的运行记录凭空消失)。best-effort:失败仅留痕,不影响主流程。
235
+ if (oldLogDir && oldLogDir !== LOG_DIR) {
236
+ try {
237
+ const oldRuns = runsFilePath(oldLogDir);
238
+ const newRuns = runsFilePath(LOG_DIR);
239
+ if (existsSync(oldRuns) && !existsSync(newRuns)) {
240
+ copyFileSync(oldRuns, newRuns);
241
+ try { unlinkSync(oldRuns); } catch { /* best-effort */ }
242
+ }
243
+ } catch (err) { reportSwallowed('preferences.logdir-runs-move', err); }
244
+ }
222
245
  prefs.logDir = LOG_DIR;
223
246
  res.writeHead(200, { 'Content-Type': 'application/json' });
224
247
  res.end(JSON.stringify(prefs));