cc-viewer 1.9.15 → 1.9.16

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 (45) hide show
  1. package/cli.js +1 -0
  2. package/concepts/ar/UltraPlan.md +9 -9
  3. package/concepts/da/UltraPlan.md +9 -9
  4. package/concepts/de/UltraPlan.md +9 -9
  5. package/concepts/en/UltraPlan.md +9 -9
  6. package/concepts/es/UltraPlan.md +9 -9
  7. package/concepts/fr/UltraPlan.md +9 -9
  8. package/concepts/it/UltraPlan.md +9 -9
  9. package/concepts/ja/UltraPlan.md +9 -9
  10. package/concepts/ko/UltraPlan.md +9 -9
  11. package/concepts/no/UltraPlan.md +9 -9
  12. package/concepts/pl/UltraPlan.md +9 -9
  13. package/concepts/pt-BR/UltraPlan.md +9 -9
  14. package/concepts/ru/UltraPlan.md +9 -9
  15. package/concepts/th/UltraPlan.md +9 -9
  16. package/concepts/tr/UltraPlan.md +9 -9
  17. package/concepts/uk/UltraPlan.md +9 -9
  18. package/concepts/zh/UltraPlan.md +9 -9
  19. package/concepts/zh-TW/UltraPlan.md +9 -9
  20. package/dist/assets/App-BvVdj099.css +1 -0
  21. package/dist/assets/App-CYdOioAz.js +2 -0
  22. package/dist/assets/{MdxEditorPanel-DRLFmdU5.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
  23. package/dist/assets/Mobile-CfNYOgRa.js +1 -0
  24. package/dist/assets/{ProxyStatsModal-Ba1E1lu4.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
  25. package/dist/assets/index-sgW8MlD3.js +2 -0
  26. package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
  27. package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +2 -0
  28. package/dist/index.html +1 -1
  29. package/package.json +1 -1
  30. package/server/lib/ask/perm-bridge.js +4 -1
  31. package/server/lib/auto-approve.js +160 -0
  32. package/server/lib/file-access-policy.js +24 -2
  33. package/server/lib/sdk-manager.js +96 -9
  34. package/server/routes/ask-perm.js +54 -3
  35. package/server/routes/files-content.js +245 -3
  36. package/server/routes/skills.js +19 -8
  37. package/server/server.js +81 -10
  38. package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
  39. package/ultraAgents/code-expert.json +1 -1
  40. package/dist/assets/App-BCDnm2Lh.js +0 -2
  41. package/dist/assets/App-CCnPkf2_.css +0 -1
  42. package/dist/assets/Mobile-C8VJqBwa.js +0 -1
  43. package/dist/assets/index-Sp3JYYKo.js +0 -2
  44. package/dist/assets/seqResourceLoaders-BUfjTaCV.css +0 -41
  45. package/dist/assets/seqResourceLoaders-CHmtPDFO.js +0 -2
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-Sp3JYYKo.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-sgW8MlD3.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-I6I2F7Rv.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-Zkm6UHQg.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CC09MDyZ.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.9.15",
3
+ "version": "1.9.16",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -164,7 +164,10 @@ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
164
164
 
165
165
  function postToViewer() {
166
166
  return new Promise((resolve, reject) => {
167
- const body = JSON.stringify({ toolName, input: toolInput });
167
+ // Forward the session cwd so the server can resolve the project-forked
168
+ // autoApproveSeconds for the right workspace (falls back to the server's
169
+ // current project key when absent — backward compatible).
170
+ const body = JSON.stringify({ toolName, input: toolInput, cwd: typeof payload?.cwd === 'string' ? payload.cwd : undefined });
168
171
  const req = httpClient.request({
169
172
  hostname: '127.0.0.1',
170
173
  port: Number(port),
@@ -0,0 +1,160 @@
1
+ // Server-side auto-approve countdown for plan/permission approvals.
2
+ //
3
+ // The auto-approve countdown used to live entirely in the web client: when the
4
+ // browser tab was closed, backgrounded (timer throttling), or disconnected, no
5
+ // approval ever fired and the pending request degraded to the 5-minute deny
6
+ // timeout. This module moves the deadline to the server: the countdown is armed
7
+ // from the moment the approval request arrives (wall-clock), and the server
8
+ // resolves the pending approval itself at deadline + AUTO_APPROVE_BUFFER_MS.
9
+ // The buffer gives a connected client whose own countdown fires at the exact
10
+ // deadline a head start, so a watched approval is still answered by the client
11
+ // and the server timer is only the fallback.
12
+ //
13
+ // Cancellation is durable while the approval is pending: a client cancel marks
14
+ // the entry (autoCancelled) and clears the server timer, the approval stays
15
+ // pending for a manual decision, and the cancelled state is replayed to newly
16
+ // connected clients so they never re-arm a countdown.
17
+ //
18
+ // Semantics of the settings (shared with the client):
19
+ // plan: prefs.approvalModal.planAutoApproveSeconds (default 0 = off)
20
+ // perm: prefs.autoApproveSeconds (default 3 when unset)
21
+ // 0/off, N>0 = N seconds, -1 = instant (resolve immediately, no buffer).
22
+ // AskUserQuestion approvals are never auto-approved.
23
+
24
+ import { readPrefsRaw } from './prefs-store.js';
25
+ import { getCurrentProjectKey, hasFork, resolveScoped } from './project-prefs.js';
26
+ import { reportSwallowed } from './error-report.js';
27
+
28
+ export const AUTO_APPROVE_BUFFER_MS = 1000;
29
+ export const DEFAULT_PERM_AUTO_APPROVE_SECONDS = 3;
30
+ export const AUTO_APPROVE_INSTANT = -1;
31
+ // A hand-edited preferences.json can hold an absurdly large value. Besides being
32
+ // pointless, a value above the 5-minute hard approval timeout would advertise an
33
+ // autoApproveAt the deny timeout always beats (a lying countdown); and beyond
34
+ // 2^31-1 ms Node's setTimeout clamps the delay to 1ms (instant approval). Cap the
35
+ // countdown at the approval timeout so the advertised deadline is always reachable.
36
+ export const MAX_AUTO_APPROVE_SECONDS = 300;
37
+
38
+ /**
39
+ * Extract the configured seconds for a kind ('plan' | 'perm') from an effective
40
+ * prefs object. Returns null when disabled (0 / invalid / negative other than -1 /
41
+ * absurdly large).
42
+ */
43
+ export function effectiveAutoApproveSeconds(kind, prefs) {
44
+ const raw = kind === 'plan'
45
+ ? prefs?.approvalModal?.planAutoApproveSeconds
46
+ : (prefs?.autoApproveSeconds ?? DEFAULT_PERM_AUTO_APPROVE_SECONDS);
47
+ const seconds = Number(raw);
48
+ if (!Number.isFinite(seconds)) return null;
49
+ if (seconds === AUTO_APPROVE_INSTANT) return AUTO_APPROVE_INSTANT;
50
+ if (seconds <= 0) return null;
51
+ if (seconds > MAX_AUTO_APPROVE_SECONDS) return null;
52
+ return seconds;
53
+ }
54
+
55
+ /**
56
+ * Read prefs (project-fork aware) and compute the auto-approve deadline for a
57
+ * pending approval. `projectKey` is the session cwd (SDK) or hook-reported cwd
58
+ * (PTY hook); falls back to the server's current project key.
59
+ *
60
+ * Returns:
61
+ * { enabled:false } — no auto-approve
62
+ * { enabled:true, instant, seconds, autoApproveAt } — absolute epoch ms deadline
63
+ */
64
+ export function computeAutoApprove(kind, projectKey, { now = Date.now(), readPrefs = readPrefsRaw, file } = {}) {
65
+ let prefs = {};
66
+ try {
67
+ prefs = readPrefs(file) || {};
68
+ } catch (err) {
69
+ reportSwallowed('auto-approve.read-prefs', err);
70
+ return { enabled: false };
71
+ }
72
+ const key = typeof projectKey === 'string' && projectKey ? projectKey : getCurrentProjectKey();
73
+ const effective = hasFork(prefs, key) ? resolveScoped(prefs, prefs.prefsByProject[key]) : prefs;
74
+ const seconds = effectiveAutoApproveSeconds(kind, effective);
75
+ if (seconds === null) return { enabled: false };
76
+ const instant = seconds === AUTO_APPROVE_INSTANT;
77
+ return {
78
+ enabled: true,
79
+ instant,
80
+ seconds,
81
+ autoApproveAt: instant ? now : now + seconds * 1000,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Delay for the server-side fallback timer: the user-visible deadline plus the
87
+ * request buffer, never negative. Instant fires on the same tick (0).
88
+ */
89
+ export function autoApproveFireDelayMs(computed, now = Date.now()) {
90
+ if (!computed?.enabled) return null;
91
+ if (computed.instant) return 0;
92
+ return Math.max(0, computed.autoApproveAt - now) + AUTO_APPROVE_BUFFER_MS;
93
+ }
94
+
95
+ /**
96
+ * Arm the server-side fallback timer. Returns the auto state stored on the
97
+ * pending entry: { autoApproveAt, seconds, cancelled, timer }.
98
+ * `onFire` is invoked once when the deadline (+buffer) passes; callers must
99
+ * re-check the entry is still pending and not cancelled inside onFire.
100
+ * Instant (-1) fires SYNCHRONOUSLY — the caller MUST register the pending entry
101
+ * (and emit the pending frame) before calling this.
102
+ */
103
+ export function createAutoApproveState(computed, onFire) {
104
+ const delay = autoApproveFireDelayMs(computed);
105
+ if (delay === null) return null; // disabled — never arm
106
+ const state = {
107
+ autoApproveAt: computed.autoApproveAt,
108
+ seconds: computed.seconds,
109
+ cancelled: false,
110
+ timer: null,
111
+ };
112
+ if (delay === 0) {
113
+ onFire();
114
+ return state;
115
+ }
116
+ state.timer = setTimeout(onFire, delay);
117
+ return state;
118
+ }
119
+
120
+ /**
121
+ * Cancel an armed auto-approve state: clears the timer and marks it cancelled.
122
+ * Accepts either the auto state itself or the whole pending entry ({ auto,
123
+ * autoCancelled }) — the entry form also stamps the durable entry-level flag so
124
+ * replays never re-arm. Returns true only on the first cancellation (so callers
125
+ * broadcast once).
126
+ */
127
+ export function cancelAutoApproveState(autoOrEntry) {
128
+ if (!autoOrEntry) return false;
129
+ // Entry form: { auto, autoCancelled }
130
+ if (Object.prototype.hasOwnProperty.call(autoOrEntry, 'autoCancelled')) {
131
+ const entry = autoOrEntry;
132
+ if (entry.autoCancelled) return false;
133
+ entry.autoCancelled = true;
134
+ if (entry.auto) {
135
+ entry.auto.cancelled = true;
136
+ if (entry.auto.timer) {
137
+ clearTimeout(entry.auto.timer);
138
+ entry.auto.timer = null;
139
+ }
140
+ }
141
+ return true;
142
+ }
143
+ const auto = autoOrEntry;
144
+ if (auto.cancelled) return false;
145
+ auto.cancelled = true;
146
+ if (auto.timer) {
147
+ clearTimeout(auto.timer);
148
+ auto.timer = null;
149
+ }
150
+ return true;
151
+ }
152
+
153
+ /** Fields merged into pending/replay frames for the client countdown UI. */
154
+ export function replayAutoApproveFields(auto, autoCancelled) {
155
+ return {
156
+ autoApproveAt: autoCancelled ? null : (auto?.autoApproveAt ?? null),
157
+ autoApproveSeconds: auto?.seconds ?? null,
158
+ autoCancelled: autoCancelled === true,
159
+ };
160
+ }
@@ -233,8 +233,30 @@ export function isReadAllowed(absPath) {
233
233
  if (SENSITIVE_CLAUDE_FILES.has(last)) {
234
234
  return { ok: false, reason: 'sensitive-claude-config' };
235
235
  }
236
- // 任意子目录下的 settings.json / settings.local.json 也拦
237
- if (/^settings(\.local)?\.json$/i.test(last)) {
236
+ // settings.json / settings.local.json at any depth, plus all backups/variants
237
+ // (settings.json.bk*, settings_*.json, etc.) — rolling backup files carry the
238
+ // same env/api-key content and must not slip past the exact-name match.
239
+ if (/^settings(\.local)?\.json($|\.)|^settings_.*\.json$/i.test(last)) {
240
+ return { ok: false, reason: 'sensitive-claude-config' };
241
+ }
242
+ // Prompt history, project transcripts, shell snapshots, file history, and
243
+ // paste cache contain user prompt content and (in the case of shell snapshots)
244
+ // live env vars. Block the whole subtree EXCEPT projects/<encoded>/memory/**,
245
+ // which is the documented per-project memory dir served by /api/project-memory.
246
+ const rel = norm(real.slice(norm(withSep(claudeReal)).length));
247
+ const firstSeg = rel.split(sep)[0];
248
+ if (firstSeg === 'todos' || firstSeg === 'shell-snapshots'
249
+ || firstSeg === 'file-history' || firstSeg === 'paste-cache' || firstSeg === 'session-env') {
250
+ return { ok: false, reason: 'sensitive-claude-config' };
251
+ }
252
+ if (firstSeg === 'projects') {
253
+ const parts = rel.split(sep);
254
+ const isMemoryDir = parts.length >= 3 && parts[2] === 'memory';
255
+ if (!isMemoryDir) {
256
+ return { ok: false, reason: 'sensitive-claude-config' };
257
+ }
258
+ }
259
+ if (last === 'history.jsonl') {
238
260
  return { ok: false, reason: 'sensitive-claude-config' };
239
261
  }
240
262
  }
@@ -36,6 +36,12 @@ 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
  import { projectKeyForCwd } from './system-prompt-snapshots.js';
39
+ import {
40
+ computeAutoApprove,
41
+ createAutoApproveState,
42
+ cancelAutoApproveState,
43
+ replayAutoApproveFields,
44
+ } from './auto-approve.js';
39
45
 
40
46
  let _query;
41
47
  try {
@@ -1489,11 +1495,24 @@ function _createSession(key, cwd) {
1489
1495
  }
1490
1496
  } catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
1491
1497
  }
1492
- const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin };
1498
+ // Server-side auto-approve countdown (armed from request arrival, wall-clock):
1499
+ // the pending frame carries the absolute deadline so clients render the remaining
1500
+ // time instead of arming their own full-length timer; the server resolves the
1501
+ // approval itself at deadline + 1s buffer when no client answered.
1502
+ // Unattended scheduled-task runs (ctx != null → origin 'scheduled-task') are exempt
1503
+ // from the server-side auto-approve fallback — with no human watching, an ephemeral
1504
+ // run must wait for a decision (5-min deny), symmetric to the npm-publish floor below.
1505
+ const planAuto = ctx ? { enabled: false } : computeAutoApprove('plan', _cwd);
1506
+ // Always stamp autoApproveSeconds (0 = server says OFF): the client's local pref
1507
+ // can disagree with the fork-resolved server value, and absence would make the
1508
+ // client fall back to its own countdown and defeat a fork-level off.
1509
+ const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
1510
+ autoApproveSeconds: planAuto.enabled ? planAuto.seconds : 0,
1511
+ ...(planAuto.enabled ? { autoApproveAt: planAuto.autoApproveAt } : {}) };
1493
1512
  if (_broadcastWs) {
1494
1513
  _broadcastWs(planPayload);
1495
1514
  }
1496
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx);
1515
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx, planAuto);
1497
1516
  if (result === null) {
1498
1517
  return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
1499
1518
  }
@@ -1523,7 +1542,7 @@ function _createSession(key, cwd) {
1523
1542
  // server/lib/ask/ask-constants.js.
1524
1543
  const askTimeoutMs = ASK_TIMEOUT_MS;
1525
1544
  const askStartedAt = Date.now();
1526
- const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), ...origin };
1545
+ const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin };
1527
1546
  if (_broadcastWs) {
1528
1547
  _broadcastWs(askPayload);
1529
1548
  }
@@ -1599,12 +1618,18 @@ function _createSession(key, cwd) {
1599
1618
  // unknown decision → fall through to normal approval flow
1600
1619
  } catch (err) { reportSwallowed('sdk-manager.waterfall.perm', err); }
1601
1620
  }
1602
- const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
1621
+ // npm publish is a hard safety floor: it must always wait for a HUMAN decision
1622
+ // (the 5-minute deny timeout applies), never the server-side auto-approve fallback.
1623
+ // Same exemption for unattended scheduled-task runs (ctx != null).
1624
+ const permAuto = (isPublish || ctx) ? { enabled: false } : computeAutoApprove('perm', _cwd);
1625
+ const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
1626
+ autoApproveSeconds: permAuto.enabled ? permAuto.seconds : 0,
1627
+ ...(permAuto.enabled ? { autoApproveAt: permAuto.autoApproveAt } : {}) };
1603
1628
  if (_broadcastWs) {
1604
1629
  _broadcastWs(permPayload);
1605
1630
  }
1606
1631
 
1607
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx);
1632
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx, permAuto);
1608
1633
  if (result === null) {
1609
1634
  return { behavior: 'deny', message: 'Timeout waiting for user approval' };
1610
1635
  }
@@ -1645,30 +1670,52 @@ function _createSession(key, cwd) {
1645
1670
  } catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
1646
1671
  }
1647
1672
 
1648
- function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null) {
1673
+ function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null, autoMeta = null) {
1649
1674
  // Record the id on the ephemeral run's ctx ONLY when it actually parks (the three
1650
1675
  // callers below sit after every auto-allow/hard-deny early-return, so an auto-allowed
1651
1676
  // tool never lands here) — keeps ctx.approvalIds bounded (review P3: no unbounded growth).
1652
1677
  if (ctx && Array.isArray(ctx.approvalIds)) ctx.approvalIds.push(id);
1653
1678
  return new Promise((resolve) => {
1654
1679
  const timer = setTimeout(() => {
1680
+ const cur = _pendingApprovals.get(id);
1681
+ if (cur?.auto) cancelAutoApproveState(cur.auto);
1655
1682
  _pendingApprovals.delete(id);
1656
1683
  // Close the modal on every client — a silent deny would leave it hanging forever.
1657
1684
  _broadcastApprovalDismiss(kind, id, 'timeout');
1658
1685
  resolve(null);
1659
1686
  }, timeoutMs);
1660
- _pendingApprovals.set(id, {
1687
+ const entry = {
1661
1688
  kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
1662
1689
  // ask-cancel does not collide with a plan/perm id
1663
1690
  replay, // exact broadcast payload announced to clients — replayed to a fresh WS connection
1664
1691
  startedAt: Date.now(),
1665
1692
  timeoutMs,
1693
+ // Durable auto-approve cancellation: a client cancel marks this and clears the
1694
+ // server fallback timer, the approval stays pending for a manual decision, and
1695
+ // the flag is replayed to fresh clients so they never re-arm the countdown.
1696
+ autoCancelled: false,
1697
+ auto: null,
1666
1698
  resolve: (value) => {
1667
1699
  clearTimeout(timer);
1700
+ const cur = _pendingApprovals.get(id);
1701
+ if (cur?.auto) cancelAutoApproveState(cur.auto);
1668
1702
  _pendingApprovals.delete(id);
1669
1703
  resolve(value);
1670
1704
  },
1671
- });
1705
+ };
1706
+ _pendingApprovals.set(id, entry);
1707
+ // Arm the server-side fallback AFTER the entry is in the map: instant (-1) fires
1708
+ // synchronously and resolves through the same first-wins wrapper.
1709
+ if (autoMeta?.enabled) {
1710
+ entry.auto = createAutoApproveState(autoMeta, () => {
1711
+ const p = _pendingApprovals.get(id);
1712
+ if (!p || p.autoCancelled) return;
1713
+ p.resolve(kind === 'plan' ? { approve: true, feedback: '' } : { decision: 'allow' });
1714
+ try {
1715
+ _broadcastWs?.({ type: kind === 'plan' ? 'sdk-plan-resolved' : 'perm-hook-resolved', id, reason: 'auto-approved', cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1716
+ } catch (err) { reportSwallowed('sdk-manager.auto-approve-broadcast', err); }
1717
+ });
1718
+ }
1672
1719
  // CLI-side abort of the in-flight control request (e.g. interrupt while the modal is
1673
1720
  // open): settle through the same first-wins path as a timeout so the promise resolves
1674
1721
  // to deny and the modal closes everywhere. { once: true } prevents listener leaks.
@@ -1711,11 +1758,37 @@ function _createSession(key, cwd) {
1711
1758
  for (const [id, p] of _pendingApprovals) {
1712
1759
  const remaining = (p.timeoutMs ?? 0) - (now - (p.startedAt ?? now));
1713
1760
  if (remaining <= 0) continue;
1714
- out.push({ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining });
1761
+ out.push({
1762
+ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining,
1763
+ ...replayAutoApproveFields(p.auto, p.autoCancelled),
1764
+ });
1715
1765
  }
1716
1766
  return out;
1717
1767
  }
1718
1768
 
1769
+ /**
1770
+ * Cancel the SERVER-SIDE auto-approve countdown for a pending plan/perm approval
1771
+ * (client clicked the countdown-cancel button). Unlike cancelApproval this does NOT
1772
+ * resolve the approval — it stays pending for a manual decision; only the fallback
1773
+ * timer is destroyed and the cancelled flag is replayed to fresh clients so they
1774
+ * never re-arm. Returns true when the id matched a pending approval of that kind
1775
+ * (even on a repeat cancel — the state change is durable while pending); false when
1776
+ * nothing matched (caller falls back to the legacy relay-only behavior).
1777
+ */
1778
+ function _autoCancelApproval(id, kind) {
1779
+ // plan/perm only — ask approvals never arm a server auto-approve timer.
1780
+ if (kind !== 'plan' && kind !== 'perm') return false;
1781
+ const pending = _pendingApprovals.get(id);
1782
+ if (!pending || pending.kind !== kind) return false;
1783
+ const first = cancelAutoApproveState(pending);
1784
+ if (first && _broadcastWs) {
1785
+ try {
1786
+ _broadcastWs({ type: kind === 'plan' ? 'sdk-plan-auto-cancel' : 'perm-hook-auto-cancel', id, cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1787
+ } catch (err) { reportSwallowed('sdk-manager.auto-cancel-broadcast', err); }
1788
+ }
1789
+ return true;
1790
+ }
1791
+
1719
1792
  /**
1720
1793
  * Cancel a pending canUseTool approval — used by ask-cancel WS handler
1721
1794
  * (user clicked Cancel button or typed-interrupt in input bar).
@@ -2021,6 +2094,7 @@ function _createSession(key, cwd) {
2021
2094
  getQueueSnapshot: _getQueueSnapshot,
2022
2095
  resolveApproval: _resolveApproval,
2023
2096
  cancelApproval: _cancelApproval,
2097
+ autoCancelApproval: _autoCancelApproval,
2024
2098
  getPendingApprovals: _getPendingApprovals,
2025
2099
  getSdkInitSnapshot: () => _lastInitSnapshot,
2026
2100
  getSessionStateSnapshot: () => {
@@ -2256,6 +2330,19 @@ export function cancelApproval(id, reason) {
2256
2330
  return false;
2257
2331
  }
2258
2332
 
2333
+ /**
2334
+ * Cancel the server-side auto-approve countdown for a plan/perm approval,
2335
+ * scanning EVERY session (auto-cancel WS handlers). The approval stays pending
2336
+ * for a manual decision. Returns false when no pending approval matched, so the
2337
+ * caller can fall back to the legacy relay-only broadcast.
2338
+ */
2339
+ export function autoCancelApproval(id, kind) {
2340
+ for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
2341
+ if (sess.autoCancelApproval(id, kind)) return true;
2342
+ }
2343
+ return false;
2344
+ }
2345
+
2259
2346
  /**
2260
2347
  * Snapshot of pending approvals for WS-reconnect replay (server.js connection
2261
2348
  * handler). WITHOUT an anchor: aggregated across ALL sessions; WITH an anchor:
@@ -7,6 +7,8 @@ import { _projectName } from '../interceptor.js';
7
7
  import { readPrefsRaw } from '../lib/prefs-store.js';
8
8
  import { getCurrentProjectKey, hasFork, resolveScoped } from '../lib/project-prefs.js';
9
9
  import { reportSwallowed } from '../lib/error-report.js';
10
+ import { computeAutoApprove, createAutoApproveState } from '../lib/auto-approve.js';
11
+ import { isPublishCommand } from '../lib/approval-policy.js';
10
12
 
11
13
  function pendingAsks(req, res, parsedUrl, isLocal, deps) {
12
14
  try {
@@ -394,7 +396,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
394
396
  return;
395
397
  }
396
398
  try {
397
- const { toolName, input } = JSON.parse(body);
399
+ const { toolName, input, cwd } = JSON.parse(body);
398
400
  if (!toolName) {
399
401
  res.writeHead(400, { 'Content-Type': 'application/json' });
400
402
  res.end(JSON.stringify({ error: 'Missing toolName' }));
@@ -407,6 +409,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
407
409
  const oldest = deps.pendingPermHooks.get(oldestId);
408
410
  if (oldest) {
409
411
  clearTimeout(oldest.timer);
412
+ if (oldest.auto?.timer) clearTimeout(oldest.auto.timer);
410
413
  try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
411
414
  deps.pendingPermHooks.delete(oldestId);
412
415
  }
@@ -431,6 +434,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
431
434
  const timer = setTimeout(() => {
432
435
  const entry = deps.pendingPermHooks.get(id);
433
436
  if (entry) {
437
+ if (entry.auto?.timer) clearTimeout(entry.auto.timer);
434
438
  deps.pendingPermHooks.delete(id);
435
439
  try {
436
440
  if (!entry.res.headersSent) {
@@ -447,11 +451,33 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
447
451
  }
448
452
  }, PERM_HOOK_TIMEOUT);
449
453
 
450
- deps.pendingPermHooks.set(id, { toolName, input, res, timer, createdAt: Date.now() });
454
+ // Server-side auto-approve countdown (same semantics as the SDK path in
455
+ // sdk-manager.js): armed from request arrival; the server answers the hook
456
+ // itself at deadline + 1s buffer when no client did. autoCancelled is set by
457
+ // the perm-hook-auto-cancel WS handler (the approval then stays pending for
458
+ // a manual decision) and replayed to fresh clients.
459
+ // npm publish is a hard safety floor: always wait for a human decision, never
460
+ // the server-side auto-approve fallback (SDK path parity: sdk-manager.js).
461
+ const autoMeta = isPublishCommand(toolName, input)
462
+ ? { enabled: false }
463
+ : computeAutoApprove('perm', typeof cwd === 'string' && cwd ? cwd : undefined, { file: deps.getPrefsFile?.() });
464
+
465
+ const hookEntry = {
466
+ toolName, input, res, timer, createdAt: Date.now(),
467
+ autoCancelled: false,
468
+ auto: null,
469
+ };
470
+ deps.pendingPermHooks.set(id, hookEntry);
451
471
 
452
472
  // Broadcast to all terminal WS clients
453
473
  if (deps.terminalWss) {
454
- const pmsg = JSON.stringify({ type: 'perm-hook-pending', id, toolName, input });
474
+ const pmsg = JSON.stringify({
475
+ type: 'perm-hook-pending', id, toolName, input,
476
+ // Always stamp autoApproveSeconds (0 = server says OFF) so a client whose
477
+ // global pref disagrees with the fork-resolved value never re-arms locally.
478
+ autoApproveSeconds: autoMeta.enabled ? autoMeta.seconds : 0,
479
+ ...(autoMeta.enabled ? { autoApproveAt: autoMeta.autoApproveAt } : {}),
480
+ });
455
481
  deps.terminalWss.clients.forEach((client) => {
456
482
  if (client.readyState === 1) {
457
483
  try { client.send(pmsg); } catch {}
@@ -459,11 +485,36 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
459
485
  });
460
486
  }
461
487
 
488
+ // Arm AFTER the entry is in the map AND the pending frame is out: instant (-1)
489
+ // fires synchronously and resolves through the entry, so clients observe the
490
+ // order pending → resolved (never a ghost panel for an already-answered hook).
491
+ if (autoMeta.enabled) {
492
+ hookEntry.auto = createAutoApproveState(autoMeta, () => {
493
+ const entry = deps.pendingPermHooks.get(id);
494
+ if (!entry || entry.autoCancelled) return;
495
+ clearTimeout(entry.timer);
496
+ deps.pendingPermHooks.delete(id);
497
+ try {
498
+ if (!entry.res.headersSent) {
499
+ entry.res.writeHead(200, { 'Content-Type': 'application/json' });
500
+ entry.res.end(JSON.stringify({ decision: 'allow' }));
501
+ }
502
+ } catch {}
503
+ if (deps.terminalWss) {
504
+ const rmsg = JSON.stringify({ type: 'perm-hook-resolved', id, reason: 'auto-approved' });
505
+ deps.terminalWss.clients.forEach((c) => {
506
+ if (c.readyState === 1) try { c.send(rmsg); } catch {}
507
+ });
508
+ }
509
+ });
510
+ }
511
+
462
512
  // Handle perm-bridge.js disconnection
463
513
  res.on('close', () => {
464
514
  const entry = deps.pendingPermHooks.get(id);
465
515
  if (entry) {
466
516
  clearTimeout(entry.timer);
517
+ if (entry.auto?.timer) clearTimeout(entry.auto.timer);
467
518
  deps.pendingPermHooks.delete(id);
468
519
  if (deps.terminalWss) {
469
520
  const tmsg = JSON.stringify({ type: 'perm-hook-timeout', id });