min-agent 0.3.0 → 0.4.0

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 (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
@@ -0,0 +1,403 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ import { existsSync, realpathSync } from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { getEffectiveConfig } from "./config.js";
6
+ export const DEFAULT_SANDBOX_MODE = "off";
7
+ export const DEFAULT_NETWORK_POLICY = "allow";
8
+ const MODE_RANK = { off: 0, workspace: 1, strict: 2 };
9
+ const sandboxAls = new AsyncLocalStorage();
10
+ let processOverride = {};
11
+ export function parseSandboxMode(value) {
12
+ if (value === "off" || value === "workspace" || value === "strict")
13
+ return value;
14
+ return undefined;
15
+ }
16
+ export function parseNetworkPolicy(value) {
17
+ if (value === "allow" || value === "deny")
18
+ return value;
19
+ return undefined;
20
+ }
21
+ export function parseSandboxConfig(raw) {
22
+ if (raw == null)
23
+ return undefined;
24
+ if (typeof raw === "string") {
25
+ const mode = parseSandboxMode(raw);
26
+ return mode ? { mode } : undefined;
27
+ }
28
+ if (typeof raw !== "object")
29
+ return undefined;
30
+ const obj = raw;
31
+ const mode = parseSandboxMode(obj.mode);
32
+ const network = parseNetworkPolicy(obj.network);
33
+ const extraWriteRoots = Array.isArray(obj.extraWriteRoots)
34
+ ? obj.extraWriteRoots.filter((p) => typeof p === "string" && p.length > 0)
35
+ : undefined;
36
+ const extraReadRoots = Array.isArray(obj.extraReadRoots)
37
+ ? obj.extraReadRoots.filter((p) => typeof p === "string" && p.length > 0)
38
+ : undefined;
39
+ if (!mode && !network && !extraWriteRoots && !extraReadRoots)
40
+ return undefined;
41
+ return { mode, network, extraWriteRoots, extraReadRoots };
42
+ }
43
+ export function mergeSandboxConfig(global, project) {
44
+ if (!global && !project)
45
+ return undefined;
46
+ const extraWriteRoots = project?.extraWriteRoots ?? global?.extraWriteRoots;
47
+ const extraReadRoots = project?.extraReadRoots ?? global?.extraReadRoots;
48
+ return {
49
+ mode: project?.mode ?? global?.mode,
50
+ network: project?.network ?? global?.network,
51
+ ...(extraWriteRoots ? { extraWriteRoots } : {}),
52
+ ...(extraReadRoots ? { extraReadRoots } : {}),
53
+ };
54
+ }
55
+ export function setSandboxOverride(partial) {
56
+ processOverride = { ...processOverride, ...partial };
57
+ }
58
+ export function clearSandboxOverride() {
59
+ processOverride = {};
60
+ }
61
+ export function getSandboxOverride() {
62
+ return { ...processOverride };
63
+ }
64
+ export function sandboxLaunchSource() {
65
+ if (processOverride.mode != null || processOverride.network != null)
66
+ return "cli";
67
+ if (envMode() != null || envNetwork() != null)
68
+ return "env";
69
+ return null;
70
+ }
71
+ export function runWithSandboxPolicy(policy, fn) {
72
+ return sandboxAls.run(policy, fn);
73
+ }
74
+ export function workspaceRoot() {
75
+ try {
76
+ return realpathSync(process.cwd());
77
+ }
78
+ catch {
79
+ return path.resolve(process.cwd());
80
+ }
81
+ }
82
+ function envMode() {
83
+ return parseSandboxMode(process.env.MIN_AGENT_SANDBOX);
84
+ }
85
+ function envNetwork() {
86
+ return parseNetworkPolicy(process.env.MIN_AGENT_SANDBOX_NETWORK);
87
+ }
88
+ function basePolicyFromConfig() {
89
+ const parsed = parseSandboxConfig(getEffectiveConfig().sandbox);
90
+ return {
91
+ mode: processOverride.mode ?? envMode() ?? parsed?.mode ?? DEFAULT_SANDBOX_MODE,
92
+ network: processOverride.network ?? envNetwork() ?? parsed?.network ?? DEFAULT_NETWORK_POLICY,
93
+ extraWriteRoots: processOverride.extraWriteRoots ?? parsed?.extraWriteRoots ?? [],
94
+ extraReadRoots: processOverride.extraReadRoots ?? parsed?.extraReadRoots ?? [],
95
+ workspaceRoot: workspaceRoot(),
96
+ };
97
+ }
98
+ export function tightenSandbox(base, request) {
99
+ const mode = request.mode && MODE_RANK[request.mode] > MODE_RANK[base.mode] ? request.mode : base.mode;
100
+ const network = request.network === "deny" || base.network === "deny" ? "deny" : "allow";
101
+ return { ...base, mode, network };
102
+ }
103
+ export function getEffectiveSandboxPolicy() {
104
+ const base = basePolicyFromConfig();
105
+ const als = sandboxAls.getStore();
106
+ if (!als)
107
+ return base;
108
+ if (als.replace) {
109
+ return {
110
+ mode: als.mode ?? DEFAULT_SANDBOX_MODE,
111
+ network: als.network ?? DEFAULT_NETWORK_POLICY,
112
+ extraWriteRoots: als.extraWriteRoots ?? [],
113
+ extraReadRoots: als.extraReadRoots ?? [],
114
+ workspaceRoot: als.workspaceRoot ?? workspaceRoot(),
115
+ };
116
+ }
117
+ return tightenSandbox(base, als);
118
+ }
119
+ export function isInsideRoot(target, root) {
120
+ const normalize = (p) => {
121
+ const resolved = path.resolve(p);
122
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
123
+ };
124
+ const child = normalize(target);
125
+ const parent = normalize(root);
126
+ if (child === parent)
127
+ return true;
128
+ const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep;
129
+ return child.startsWith(prefix);
130
+ }
131
+ export function resolveCheckedPath(userPath, cwd = process.cwd()) {
132
+ const lexical = path.resolve(cwd, userPath);
133
+ const parts = [];
134
+ let cur = lexical;
135
+ for (;;) {
136
+ try {
137
+ const realBase = realpathSync(cur);
138
+ return { lexical, real: parts.length === 0 ? realBase : path.resolve(realBase, ...parts) };
139
+ }
140
+ catch {
141
+ const next = path.dirname(cur);
142
+ if (next === cur)
143
+ return { lexical, real: lexical };
144
+ parts.unshift(path.basename(cur));
145
+ cur = next;
146
+ }
147
+ }
148
+ }
149
+ function homePath(...segments) {
150
+ return path.join(os.homedir(), ...segments);
151
+ }
152
+ function sensitiveDirPrefixes() {
153
+ return [
154
+ homePath(".ssh"),
155
+ homePath(".gnupg"),
156
+ homePath(".aws"),
157
+ path.join(homePath(".min-agent"), "config.json"),
158
+ homePath(".netrc"),
159
+ homePath(".npmrc"),
160
+ homePath(".pypirc"),
161
+ homePath(".git-credentials"),
162
+ path.join(homePath(".config"), "gh"),
163
+ path.join(homePath(".config"), "git", "credentials"),
164
+ "/etc/shadow",
165
+ "/etc/sudoers",
166
+ "/etc/master.passwd",
167
+ ];
168
+ }
169
+ function isSensitivePath(real) {
170
+ return sensitiveDirPrefixes().some((root) => {
171
+ try {
172
+ const resolvedRoot = existsSync(root) ? realpathSync(root) : path.resolve(root);
173
+ return isInsideRoot(real, resolvedRoot) || path.resolve(real) === path.resolve(root);
174
+ }
175
+ catch {
176
+ return isInsideRoot(real, root);
177
+ }
178
+ });
179
+ }
180
+ function uniqueExistingAndLexical(paths) {
181
+ const out = [];
182
+ const seen = new Set();
183
+ for (const raw of paths) {
184
+ const lexical = path.resolve(raw);
185
+ const aliases = [lexical];
186
+ try {
187
+ aliases.push(realpathSync(lexical));
188
+ }
189
+ catch {
190
+ /* not created yet */
191
+ }
192
+ for (const item of aliases) {
193
+ const key = process.platform === "win32" ? item.toLowerCase() : item;
194
+ if (seen.has(key))
195
+ continue;
196
+ seen.add(key);
197
+ out.push(item);
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+ function defaultCacheWriteRoots() {
203
+ const home = os.homedir();
204
+ return [
205
+ path.join(home, ".npm"),
206
+ path.join(home, ".bun"),
207
+ path.join(home, ".cache"),
208
+ path.join(home, ".local"),
209
+ path.join(home, ".yarn"),
210
+ path.join(home, "Library", "Caches"),
211
+ path.join(home, "AppData", "Local"),
212
+ path.join(home, "AppData", "Roaming"),
213
+ ];
214
+ }
215
+ export function writeRoots(policy = getEffectiveSandboxPolicy()) {
216
+ return uniqueExistingAndLexical([
217
+ policy.workspaceRoot,
218
+ os.tmpdir(),
219
+ "/tmp",
220
+ "/var/tmp",
221
+ "/private/tmp",
222
+ "/private/var/tmp",
223
+ ...defaultCacheWriteRoots(),
224
+ ...policy.extraWriteRoots.map((p) => path.resolve(policy.workspaceRoot, p)),
225
+ ]);
226
+ }
227
+ export function readRoots(policy = getEffectiveSandboxPolicy()) {
228
+ return uniqueExistingAndLexical([
229
+ policy.workspaceRoot,
230
+ ...writeRoots(policy),
231
+ ...policy.extraReadRoots.map((p) => path.resolve(policy.workspaceRoot, p)),
232
+ ]);
233
+ }
234
+ function inAnyRoot(real, roots) {
235
+ return roots.some((root) => isInsideRoot(real, root));
236
+ }
237
+ export function checkPathAccess(userPath, access, cwd = process.cwd(), policy = getEffectiveSandboxPolicy()) {
238
+ const { real } = resolveCheckedPath(userPath, cwd);
239
+ if (isSensitivePath(real)) {
240
+ return { ok: false, message: "该路径受保护,无法访问。" };
241
+ }
242
+ if (policy.mode === "off")
243
+ return { ok: true, real };
244
+ if (access === "write") {
245
+ if (!inAnyRoot(real, writeRoots(policy))) {
246
+ return { ok: false, message: `无法写入工作区以外的路径: ${userPath}` };
247
+ }
248
+ return { ok: true, real };
249
+ }
250
+ if (policy.mode === "strict" && !inAnyRoot(real, readRoots(policy))) {
251
+ return { ok: false, message: `当前隔离级别不允许读取工作区以外的文件: ${userPath}` };
252
+ }
253
+ return { ok: true, real };
254
+ }
255
+ export function pathAccessError(userPath, access, cwd) {
256
+ const result = checkPathAccess(userPath, access, cwd);
257
+ return result.ok ? null : result.message;
258
+ }
259
+ export function isNetworkCommand(command) {
260
+ return (/\b(curl|wget|nc|ncat|netcat|ssh|scp|sftp|ftp|telnet|nmap|ping|nslookup|dig|traceroute|rsync)\b/i.test(command) ||
261
+ /\bgit\s+(clone|fetch|pull|push|ls-remote)\b/i.test(command) ||
262
+ /\b(npm|pnpm|yarn|bun)\s+(install|add|publish|update|upgrade)\b/i.test(command));
263
+ }
264
+ export function networkDeniedMessage() {
265
+ return "当前设置不允许访问网络。";
266
+ }
267
+ let backendCache;
268
+ export function detectSandboxBackend() {
269
+ if (backendCache)
270
+ return backendCache;
271
+ const forced = process.env.MIN_AGENT_SANDBOX_BACKEND;
272
+ if (forced === "seatbelt" || forced === "bwrap" || forced === "none") {
273
+ backendCache = forced;
274
+ return backendCache;
275
+ }
276
+ if (process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec")) {
277
+ backendCache = "seatbelt";
278
+ }
279
+ else if (process.platform === "linux" && (existsSync("/usr/bin/bwrap") || existsSync("/usr/local/bin/bwrap"))) {
280
+ backendCache = "bwrap";
281
+ }
282
+ else {
283
+ backendCache = "none";
284
+ }
285
+ return backendCache;
286
+ }
287
+ export function resetSandboxBackendCache() {
288
+ backendCache = undefined;
289
+ }
290
+ function seatbeltQuote(p) {
291
+ return `"${p.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
292
+ }
293
+ export function buildSeatbeltProfile(policy) {
294
+ const writes = writeRoots(policy)
295
+ .map((root) => `(subpath ${seatbeltQuote(root)})`)
296
+ .join("\n ");
297
+ const denyReads = sensitiveDirPrefixes()
298
+ .map((root) => `(subpath ${seatbeltQuote(path.resolve(root))})`)
299
+ .join("\n ");
300
+ const network = policy.network === "deny" ? "(deny network*)" : "";
301
+ return `(version 1)
302
+ (allow default)
303
+ (deny file-write*)
304
+ (allow file-write-data (literal "/dev/null"))
305
+ (allow file-write*
306
+ ${writes}
307
+ )
308
+ (deny file-read*
309
+ ${denyReads}
310
+ )
311
+ ${network}
312
+ `;
313
+ }
314
+ export function bwrapBin() {
315
+ if (existsSync("/usr/bin/bwrap"))
316
+ return "/usr/bin/bwrap";
317
+ return "/usr/local/bin/bwrap";
318
+ }
319
+ export function buildBwrapArgs(policy, cwd, command) {
320
+ const args = ["--die-with-parent", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"];
321
+ const bound = new Set();
322
+ for (const root of writeRoots(policy)) {
323
+ const key = process.platform === "win32" ? root.toLowerCase() : root;
324
+ if (bound.has(key))
325
+ continue;
326
+ bound.add(key);
327
+ if (existsSync(root))
328
+ args.push("--bind", root, root);
329
+ }
330
+ if (policy.network === "deny")
331
+ args.push("--unshare-net");
332
+ args.push("--chdir", cwd, "/bin/sh", "-c", command);
333
+ return args;
334
+ }
335
+ const NO_BACKEND_COMMAND_WARNING = "⚠ 当前平台不支持命令级隔离(需要 macOS 的 sandbox-exec 或 Linux 的 bwrap),bash 命令未在隔离环境中执行,工作区写入限制对它不生效(read/write/edit 等文件工具仍会校验路径)。";
336
+ const NO_BACKEND_NETWORK_WARNING = "⚠ 当前平台不支持系统级网络隔离,禁止联网仅按命令名拦截,脚本或解释器仍可能访问网络。";
337
+ const STRICT_NO_BACKEND_ERROR = "当前平台不支持命令级隔离(需要 macOS 的 sandbox-exec 或 Linux 的 bwrap),严格隔离模式下无法安全执行 bash 命令,已拒绝执行。可将隔离级别设为 workspace(文件工具仍会校验路径)或 off。";
338
+ /** Config-time caveat when the platform cannot enforce command-level isolation for the given mode. */
339
+ export function sandboxEnforcementCaveat(mode) {
340
+ if (mode === "off" || detectSandboxBackend() !== "none")
341
+ return null;
342
+ return mode === "strict"
343
+ ? "⚠ Command-level sandboxing isn't supported on this platform (requires macOS sandbox-exec or Linux bwrap); bash commands will be rejected in strict mode."
344
+ : "⚠ Command-level sandboxing isn't supported on this platform (requires macOS sandbox-exec or Linux bwrap); bash commands won't be restricted to the workspace (file tools still validate paths).";
345
+ }
346
+ export function planSandboxedSpawn(command, cwd, env, policy = getEffectiveSandboxPolicy()) {
347
+ if (policy.mode !== "off") {
348
+ const cwdCheck = checkPathAccess(cwd, "write", process.cwd(), policy);
349
+ if (!cwdCheck.ok)
350
+ return { error: `无法在工作区以外的目录执行命令: ${cwd}` };
351
+ }
352
+ const backend = detectSandboxBackend();
353
+ if (policy.network === "deny" && backend === "none" && isNetworkCommand(command)) {
354
+ return { error: networkDeniedMessage() };
355
+ }
356
+ const isWin = process.platform === "win32";
357
+ const actualCommand = isWin ? `chcp 65001 >nul && ${command}` : command;
358
+ const childEnv = { ...env, ...(isWin ? { PYTHONIOENCODING: "utf-8" } : {}) };
359
+ const shellPlan = () => ({ file: actualCommand, args: [], shell: true, cwd, env: childEnv });
360
+ if (policy.mode === "off") {
361
+ const plan = shellPlan();
362
+ if (policy.network === "deny" && backend === "none")
363
+ plan.warning = NO_BACKEND_NETWORK_WARNING;
364
+ return plan;
365
+ }
366
+ if (backend === "seatbelt") {
367
+ return {
368
+ file: "/usr/bin/sandbox-exec",
369
+ args: ["-p", buildSeatbeltProfile(policy), "/bin/sh", "-c", actualCommand],
370
+ shell: false,
371
+ cwd,
372
+ env: childEnv,
373
+ };
374
+ }
375
+ if (backend === "bwrap") {
376
+ return {
377
+ file: bwrapBin(),
378
+ args: buildBwrapArgs(policy, cwd, actualCommand),
379
+ shell: false,
380
+ cwd,
381
+ env: childEnv,
382
+ };
383
+ }
384
+ // No OS-level sandbox available: strict cannot be honored at all, so refuse;
385
+ // workspace still gets path checks from the file tools, so run with a caveat.
386
+ if (policy.mode === "strict")
387
+ return { error: STRICT_NO_BACKEND_ERROR };
388
+ const plan = shellPlan();
389
+ plan.warning =
390
+ policy.network === "deny"
391
+ ? `${NO_BACKEND_COMMAND_WARNING}\n${NO_BACKEND_NETWORK_WARNING}`
392
+ : NO_BACKEND_COMMAND_WARNING;
393
+ return plan;
394
+ }
395
+ export function sandboxModeLabel(mode) {
396
+ return mode === "off" ? "off" : mode === "strict" ? "strict" : "workspace";
397
+ }
398
+ export function sandboxNetworkLabel(network) {
399
+ return network === "deny" ? "network denied" : "network allowed";
400
+ }
401
+ export function sandboxStatusLabel(policy = getEffectiveSandboxPolicy()) {
402
+ return `${sandboxModeLabel(policy.mode)}, ${sandboxNetworkLabel(policy.network)}`;
403
+ }
@@ -0,0 +1,45 @@
1
+ export function createSaveThrottle(save, intervalMs = 2000, now = Date.now) {
2
+ let lastRun = 0;
3
+ let timer;
4
+ let pending = false;
5
+ const run = () => {
6
+ timer = undefined;
7
+ pending = false;
8
+ lastRun = now();
9
+ try {
10
+ save();
11
+ }
12
+ catch {
13
+ /* persistence is best-effort */
14
+ }
15
+ };
16
+ return {
17
+ request() {
18
+ if (timer) {
19
+ pending = true;
20
+ return;
21
+ }
22
+ const elapsed = now() - lastRun;
23
+ if (elapsed >= intervalMs) {
24
+ run();
25
+ return;
26
+ }
27
+ pending = true;
28
+ timer = setTimeout(run, intervalMs - elapsed);
29
+ },
30
+ flush() {
31
+ if (timer) {
32
+ clearTimeout(timer);
33
+ timer = undefined;
34
+ }
35
+ if (pending)
36
+ run();
37
+ },
38
+ cancel() {
39
+ if (timer)
40
+ clearTimeout(timer);
41
+ timer = undefined;
42
+ pending = false;
43
+ },
44
+ };
45
+ }