@code-yeongyu/senpi 2026.10.16-3 → 2026.10.16-4

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 (99) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/bundle/chunks/{chunk-I5ZJEXTM.js → chunk-2NO5ZOYE.js} +6 -6
  3. package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-X5VCHHXR.js → chunk-GY5O7NIQ.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-AVALXFDW.js → chunk-JD4CV3GH.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-FMYLLJBQ.js → chunk-KWC3MCLP.js} +1 -1
  8. package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
  9. package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
  10. package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-HJPX7PBL.js → chunk-XCRGEMY2.js} +125 -118
  12. package/dist/bundle/chunks/{cli-main-JVPFJGP5.js → cli-main-I6JF2GPS.js} +1 -1
  13. package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
  14. package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
  15. package/dist/bundle/chunks/{interactive-mode-BROZRY34.js → interactive-mode-4Q3TIXOZ.js} +1 -1
  16. package/dist/bundle/chunks/{multi-session-host-SMQR5SCJ.js → multi-session-host-XEZB2XVR.js} +5 -4
  17. package/dist/bundle/chunks/{package-manager-cli-2QFJY2AS.js → package-manager-cli-YWZHOZOY.js} +1 -1
  18. package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
  19. package/dist/bundle/chunks/{rpc-mode-Z45T3NZO.js → rpc-mode-SLWXVXXE.js} +1 -1
  20. package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
  21. package/dist/bundle/chunks/{session-control-endpoint-H7FFWF2A.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
  22. package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
  23. package/dist/bundle/chunks/session-worker.js +243 -235
  24. package/dist/bundle/cli.js +2 -2
  25. package/dist/bundle/index.js +1 -1
  26. package/dist/bundle/rpc-entry.js +1 -1
  27. package/dist/bundle/runtime-manifest.json +1 -1
  28. package/dist/core/agent-session-runtime.d.ts +15 -0
  29. package/dist/core/agent-session-runtime.js +53 -2
  30. package/dist/core/agent-session.d.ts +12 -0
  31. package/dist/core/agent-session.js +42 -1
  32. package/dist/core/context-files.d.ts +21 -0
  33. package/dist/core/context-files.js +175 -0
  34. package/dist/core/diagnostics.d.ts +1 -0
  35. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
  36. package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
  37. package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
  38. package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
  39. package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
  40. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
  41. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
  42. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
  43. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
  44. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
  45. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
  46. package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
  47. package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
  48. package/dist/core/extensions/builtin/permission-system/index.js +3 -1
  49. package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
  50. package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
  51. package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
  52. package/dist/core/extensions/builtin/permission-system/service.js +1 -1
  53. package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
  54. package/dist/core/package-manager.js +4 -32
  55. package/dist/core/project-trust.d.ts +8 -0
  56. package/dist/core/project-trust.js +12 -0
  57. package/dist/core/resource-loader.d.ts +5 -7
  58. package/dist/core/resource-loader.js +11 -84
  59. package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
  60. package/dist/core/retry-fallback/circuit-probes.js +3 -0
  61. package/dist/core/retry-fallback/circuit.d.ts +5 -0
  62. package/dist/core/retry-fallback/circuit.js +27 -4
  63. package/dist/core/retry-fallback/controller.d.ts +9 -4
  64. package/dist/core/retry-fallback/controller.js +21 -4
  65. package/dist/core/settings-manager.d.ts +15 -0
  66. package/dist/core/settings-manager.js +21 -1
  67. package/dist/core/skills-prompt.d.ts +18 -0
  68. package/dist/core/skills-prompt.js +131 -0
  69. package/dist/core/skills.d.ts +1 -9
  70. package/dist/core/skills.js +1 -59
  71. package/dist/core/trust-manager.d.ts +13 -4
  72. package/dist/core/trust-manager.js +52 -21
  73. package/dist/main.js +23 -4
  74. package/dist/modes/interactive/interactive-mode.js +10 -3
  75. package/dist/modes/rpc/abort-grace.d.ts +9 -0
  76. package/dist/modes/rpc/abort-grace.js +11 -0
  77. package/dist/modes/rpc/effect-commands.d.ts +0 -2
  78. package/dist/modes/rpc/effect-commands.js +2 -3
  79. package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
  80. package/dist/modes/rpc/host-handover-parked.js +1 -0
  81. package/dist/modes/rpc/host-handover-reopen.js +11 -2
  82. package/dist/modes/rpc/rpc-client.d.ts +28 -2
  83. package/dist/modes/rpc/rpc-client.js +24 -11
  84. package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
  85. package/dist/modes/rpc/rpc-request-deadline.js +15 -0
  86. package/dist/modes/rpc/session-command-router.js +7 -4
  87. package/dist/modes/rpc/session-registry-attach.js +6 -1
  88. package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
  89. package/dist/modes/rpc/session-registry-claims.js +68 -0
  90. package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
  91. package/dist/modes/rpc/session-registry-opening.js +57 -0
  92. package/dist/modes/rpc/session-registry.d.ts +1 -0
  93. package/dist/modes/rpc/session-registry.js +48 -60
  94. package/docs/rpc.md +6 -2
  95. package/docs/settings.md +13 -0
  96. package/package.json +6 -6
  97. package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
  98. package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
  99. package/dist/bundle/chunks/session-picker-2O2BNBVE.js +0 -2
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Claude Code's own verdict that the account's subscription is spent: its anchored usage-limit prose
3
+ * or its structured terminal reason, under no status other than 429 (senpi#3462, senpi#3466).
4
+ */
5
+ export declare function isConfirmedAccountUsageLimit(text: string): boolean;
6
+ /**
7
+ * How long a spent account stays blocked: until the reset its limit names, or the reset its spent
8
+ * window reported, never past the window the limit belongs to. Undefined when the error is not
9
+ * Claude Code's account usage limit or names no reset, so the caller keeps its short default
10
+ * (senpi#3462).
11
+ */
12
+ export declare function usageLimitBlockMs(text: string, accountName: string, now: number): number | undefined;
13
+ //# sourceMappingURL=usage-limit-block.d.ts.map
@@ -0,0 +1,89 @@
1
+ import { usageLimitResetMs } from "../../../credential-pool/reset-time.js";
2
+ import { subscriptionLimits } from "../../../subscription-limits/limit-store.js";
3
+ // The subscription-limit store keys accounts by this provider id (auth-attempt.ts).
4
+ const PROVIDER = "anthropic-subscription";
5
+ const HOUR_MS = 60 * 60 * 1_000;
6
+ /**
7
+ * Claude Code's own account usage-limit errors, anchored at the start of the message: "You've hit
8
+ * your session limit · resets 3pm", "You've reached your 5-hour limit", "Claude AI usage limit
9
+ * reached". Only a window word Claude Code uses may stand before "limit", so a context or rate
10
+ * limit, a warning ("approaching your usage limit") or ordinary prose never takes a long block.
11
+ */
12
+ const CLAUDE_USAGE_LIMIT = /^(?:You['\u2019]ve|You have) (?:hit|reached) your (?:(session|weekly|daily|monthly|\d+-hour|usage) )?limit(?=$|\s*[\u00b7\u2219.,{(]|\s+resets?\b)|^Claude AI usage limit reached\b/i;
13
+ /**
14
+ * Claude Code's structured terminal reason for a spent subscription. `rapid_refill_breaker` is a short burst
15
+ * breaker, not a spent account, so it keeps the short, session-scoped block (senpi#3466).
16
+ */
17
+ const TERMINAL_LIMIT_REASON = /\bblocking_limit\b/;
18
+ /** A status the error names; a usage limit is a 429, so any other status is not one. */
19
+ const NAMED_STATUS = /\b(?:HTTP|status(?: code)?)\s*(\d{3})\b/i;
20
+ /** The longest a block may last for each window word: a reset read past it is a misread. */
21
+ const WINDOW_MS = {
22
+ session: 5 * HOUR_MS,
23
+ daily: 24 * HOUR_MS,
24
+ weekly: 7 * 24 * HOUR_MS,
25
+ monthly: 31 * 24 * HOUR_MS,
26
+ };
27
+ /** A limit that names no window is capped at the shortest subscription window. */
28
+ const UNNAMED_WINDOW_MS = 5 * HOUR_MS;
29
+ function namedWindowMs(word) {
30
+ if (word === undefined)
31
+ return undefined;
32
+ const lower = word.toLowerCase();
33
+ const hours = /^(\d+)-hour$/.exec(lower);
34
+ if (hours)
35
+ return Number(hours[1]) * HOUR_MS;
36
+ return WINDOW_MS[lower];
37
+ }
38
+ /** The latest-resetting fully used window the SDK last reported for this account. */
39
+ function spentWindow(accountName, now) {
40
+ const limits = subscriptionLimits
41
+ .snapshot()
42
+ .find((entry) => entry.provider === PROVIDER && entry.account === accountName);
43
+ let spent;
44
+ for (const window of limits?.windows ?? []) {
45
+ if (window.usedPercent < 100 || window.resetsAt === undefined)
46
+ continue;
47
+ const resetsAt = Date.parse(window.resetsAt);
48
+ if (!Number.isFinite(resetsAt) || resetsAt <= now)
49
+ continue;
50
+ if (spent === undefined || resetsAt - now > spent.resetMs) {
51
+ spent = {
52
+ resetMs: resetsAt - now,
53
+ windowMs: window.windowMinutes === undefined ? undefined : window.windowMinutes * 60_000,
54
+ };
55
+ }
56
+ }
57
+ return spent;
58
+ }
59
+ /**
60
+ * Claude Code's own verdict that the account's subscription is spent: its anchored usage-limit prose
61
+ * or its structured terminal reason, under no status other than 429 (senpi#3462, senpi#3466).
62
+ */
63
+ export function isConfirmedAccountUsageLimit(text) {
64
+ const trimmed = text.trim();
65
+ if (!CLAUDE_USAGE_LIMIT.test(trimmed) && !TERMINAL_LIMIT_REASON.test(trimmed))
66
+ return false;
67
+ const status = NAMED_STATUS.exec(trimmed)?.[1];
68
+ return status === undefined || status === "429";
69
+ }
70
+ /**
71
+ * How long a spent account stays blocked: until the reset its limit names, or the reset its spent
72
+ * window reported, never past the window the limit belongs to. Undefined when the error is not
73
+ * Claude Code's account usage limit or names no reset, so the caller keeps its short default
74
+ * (senpi#3462).
75
+ */
76
+ export function usageLimitBlockMs(text, accountName, now) {
77
+ const trimmed = text.trim();
78
+ if (!isConfirmedAccountUsageLimit(trimmed))
79
+ return undefined;
80
+ const shape = CLAUDE_USAGE_LIMIT.exec(trimmed);
81
+ const window = spentWindow(accountName, now);
82
+ const parsed = usageLimitResetMs(trimmed, now);
83
+ const resetMs = parsed !== undefined && parsed > 0 ? parsed : window?.resetMs;
84
+ if (resetMs === undefined)
85
+ return undefined;
86
+ const capMs = namedWindowMs(shape?.[1]) ?? window?.windowMs ?? UNNAMED_WINDOW_MS;
87
+ return Math.min(resetMs, capMs);
88
+ }
89
+ //# sourceMappingURL=usage-limit-block.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The wider "Allow always" a prompt may offer for a tool WITHOUT its own permission parser, where
3
+ * plain "Allow always" remembers only the exact argument (one URL, one input) and so asks again for
4
+ * the next one (senpi#3403):
5
+ * - a URL argument: that URL's DOMAIN (scheme + canonical host + non-default port), stored as one
6
+ * literal and matched by equality, never the whole tool: a tool-wide allow for a browser or
7
+ * fetch tool would mean "go anywhere", close to Full access for the web;
8
+ * - a free `input`/`pattern` argument: the tool itself, for this project;
9
+ * - a command or a path: nothing wider (that would grant a shell or file access wholesale).
10
+ * Built-in tools (bash, eval, edit, write, read, ...) have parsers and never reach this.
11
+ */
12
+ export interface WiderAlwaysScope {
13
+ /** The option label, stating the scope in plain words. */
14
+ readonly label: string;
15
+ /** The patterns an answer with this option approves for the permission. */
16
+ readonly patterns: string[];
17
+ }
18
+ /**
19
+ * The one normalizer both sides of a domain grant go through (senpi#3403). A parser-less tool (an
20
+ * MCP server, a custom tool) parses the URL itself, and the permission layer cannot make it reuse
21
+ * this parse. So a domain origin is given only for raw text every parser reads as the same host:
22
+ * the strict shape above, no ambiguous character anywhere, and the same host and port as the WHATWG
23
+ * parser finds (#3416 review: `https://example.invalid\\@evil.test/` is example.invalid to WHATWG and
24
+ * evil.test to curl and Python). Anything else gets no alias and no domain option, so it asks.
25
+ */
26
+ export declare function canonicalUrlOrigin(raw: string): string | undefined;
27
+ /**
28
+ * The match target a domain grant is written for: a literal that never contains a wildcard
29
+ * character, so the grant matches by equality. The `origin=` prefix keeps it from colliding with a
30
+ * user's own URL rules.
31
+ */
32
+ export declare function urlOriginAlias(raw: string): string | undefined;
33
+ export declare function widerAlwaysScope(toolName: string, argKey: string, arg: string): WiderAlwaysScope | undefined;
34
+ //# sourceMappingURL=always-scope.d.ts.map
@@ -0,0 +1,77 @@
1
+ /** Never widened, even if a future change routes one of them through the fallback. */
2
+ const NEVER_WIDENED = new Set([
3
+ "bash",
4
+ "eval",
5
+ "edit",
6
+ "write",
7
+ "read",
8
+ "apply_patch",
9
+ "multiedit",
10
+ "glob",
11
+ "grep",
12
+ "ls",
13
+ ]);
14
+ /**
15
+ * The only raw URL shape that gets a domain alias: `http(s)://`, an ASCII DNS host (letters, digits,
16
+ * dots, dashes; no trailing dot), an optional port, then `/`, `?`, `#` or the end. No userinfo, no
17
+ * percent-escape, no IPv6 brackets.
18
+ */
19
+ const UNAMBIGUOUS_URL = /^(https?):\/\/([A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)(?::([0-9]{1,5}))?(?:[/?#]|$)/;
20
+ /** Characters that let one parser read another host than the next: backslash, `@`, `%`, whitespace, controls. */
21
+ const AMBIGUOUS_CHARACTER = /[\\@%\s\u0000-\u001f\u007f]/;
22
+ const DEFAULT_PORTS = { http: "80", https: "443" };
23
+ /**
24
+ * The one normalizer both sides of a domain grant go through (senpi#3403). A parser-less tool (an
25
+ * MCP server, a custom tool) parses the URL itself, and the permission layer cannot make it reuse
26
+ * this parse. So a domain origin is given only for raw text every parser reads as the same host:
27
+ * the strict shape above, no ambiguous character anywhere, and the same host and port as the WHATWG
28
+ * parser finds (#3416 review: `https://example.invalid\\@evil.test/` is example.invalid to WHATWG and
29
+ * evil.test to curl and Python). Anything else gets no alias and no domain option, so it asks.
30
+ */
31
+ export function canonicalUrlOrigin(raw) {
32
+ if (AMBIGUOUS_CHARACTER.test(raw))
33
+ return undefined;
34
+ const match = UNAMBIGUOUS_URL.exec(raw);
35
+ if (match === null)
36
+ return undefined;
37
+ const scheme = match[1].toLowerCase();
38
+ const host = match[2].toLowerCase();
39
+ const port = match[3] === undefined || match[3] === DEFAULT_PORTS[scheme] ? "" : String(Number(match[3]));
40
+ let url;
41
+ try {
42
+ url = new URL(raw);
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ if (url.protocol !== `${scheme}:` || url.hostname !== host || url.port !== port)
48
+ return undefined;
49
+ return `${scheme}://${host}${port === "" ? "" : `:${port}`}`;
50
+ }
51
+ /**
52
+ * The match target a domain grant is written for: a literal that never contains a wildcard
53
+ * character, so the grant matches by equality. The `origin=` prefix keeps it from colliding with a
54
+ * user's own URL rules.
55
+ */
56
+ export function urlOriginAlias(raw) {
57
+ const origin = canonicalUrlOrigin(raw);
58
+ return origin === undefined ? undefined : `origin=${origin}`;
59
+ }
60
+ export function widerAlwaysScope(toolName, argKey, arg) {
61
+ if (NEVER_WIDENED.has(toolName))
62
+ return undefined;
63
+ if (argKey === "url") {
64
+ const origin = canonicalUrlOrigin(arg);
65
+ if (origin === undefined)
66
+ return undefined;
67
+ return {
68
+ label: `Allow ${toolName} on ${origin.slice(origin.indexOf("//") + 2)} always in this project`,
69
+ patterns: [`origin=${origin}`],
70
+ };
71
+ }
72
+ if (argKey === "input" || argKey === "pattern") {
73
+ return { label: `Allow ${toolName} always in this project`, patterns: ["*"] };
74
+ }
75
+ return undefined;
76
+ }
77
+ //# sourceMappingURL=always-scope.js.map
@@ -261,6 +261,7 @@ export default function permissionSystemExtension(pi) {
261
261
  permission: permissionRequest.permission,
262
262
  patterns: permissionRequest.patterns,
263
263
  always: permissionRequest.always,
264
+ ...(permissionRequest.widerAlways ? { widerAlways: permissionRequest.widerAlways } : {}),
264
265
  metadata: createRequestMetadata(event.toolName, event.input),
265
266
  tool: {
266
267
  callID: event.toolCallId,
@@ -295,7 +296,8 @@ export default function permissionSystemExtension(pi) {
295
296
  const reply = await showPermissionPrompt(ctx, request, signal);
296
297
  activeService.reply(reply);
297
298
  if (reply.reply === "always") {
298
- persistAlways(ctx.agentDir, ctx.cwd, request, persistedAtAnswer);
299
+ const answered = reply.always ? { ...request, always: reply.always } : request;
300
+ persistAlways(ctx.agentDir, ctx.cwd, answered, persistedAtAnswer);
299
301
  }
300
302
  }
301
303
  else {
@@ -1,7 +1,7 @@
1
1
  import type { ToolInfo } from "../../types.ts";
2
2
  import type { Request } from "../permission-system/types.ts";
3
3
  /** Simplified permission request without ID/session metadata */
4
- export type PermissionRequest = Pick<Request, "permission" | "patterns" | "always"> & {
4
+ export type PermissionRequest = Pick<Request, "permission" | "patterns" | "always" | "widerAlways"> & {
5
5
  readonly autoApproveAsk?: true;
6
6
  readonly ruleAliases?: readonly string[];
7
7
  };
@@ -4,6 +4,7 @@ import { extractPatchedPaths } from "../gpt-apply-patch/index.js";
4
4
  import { BashArity } from "../permission-system/arity.js";
5
5
  import { extractExternalPaths, isExternalPath, toParentDirectoryPattern, } from "../permission-system/external-dir.js";
6
6
  import { setApprovedMonitorParent } from "../terminal/monitor-permission.js";
7
+ import { urlOriginAlias, widerAlwaysScope } from "./always-scope.js";
7
8
  import { parseReadPermission } from "./read-permission.js";
8
9
  function fallbackPermissionRequest(permission) {
9
10
  return {
@@ -19,12 +20,32 @@ function fallbackPermissionRequest(permission) {
19
20
  * meaningful arg — path, command, url, or input — and keeps `*` only for a truly arg-less call.
20
21
  */
21
22
  function fallbackPermissionRequestFor(toolName, input) {
22
- const arg = getString(input, "path", "file_path", "command", "url", "input", "pattern", "filePath");
23
- if (arg === undefined)
23
+ const found = getStringEntry(input, "path", "file_path", "command", "url", "input", "pattern", "filePath");
24
+ if (found === undefined)
24
25
  return fallbackPermissionRequest(toolName);
26
+ const [argKey, arg] = found;
25
27
  // A bare `*` alongside the concrete arg would still match an ask/all rule and widen past a
26
28
  // pattern-specific deny, so the concrete arg is the only target; `*` is the arg-less fallback.
27
- return { permission: toolName, patterns: [arg], always: [arg] };
29
+ // "Allow always" keeps the exact arg; a wider scope is a separate, labelled choice (senpi#3403).
30
+ const widerAlways = widerAlwaysScope(toolName, argKey, arg);
31
+ // A URL is also matched as its canonical origin, so a domain grant (a literal origin) applies to
32
+ // every spelling of that origin; rules on the raw URL, a deny included, still match the raw URL.
33
+ const originAlias = argKey === "url" ? urlOriginAlias(arg) : undefined;
34
+ return {
35
+ permission: toolName,
36
+ patterns: [arg],
37
+ always: [arg],
38
+ ...(originAlias === undefined ? {} : { ruleAliases: [arg, originAlias] }),
39
+ ...(widerAlways ? { widerAlways } : {}),
40
+ };
41
+ }
42
+ function getStringEntry(input, ...keys) {
43
+ for (const key of keys) {
44
+ const value = input[key];
45
+ if (typeof value === "string")
46
+ return [key, value];
47
+ }
48
+ return undefined;
28
49
  }
29
50
  function getString(input, ...keys) {
30
51
  for (const key of keys) {
@@ -2,7 +2,8 @@ export async function showPermissionPrompt(ctx, request, signal) {
2
2
  const title = `Permission required: ${request.permission}`;
3
3
  const message = formatRequestForDisplay(request);
4
4
  const displayTitle = `${title}\n\n${message}`;
5
- const options = ["Allow once", "Allow always", "Deny", "Deny with feedback"];
5
+ const wider = request.widerAlways;
6
+ const options = ["Allow once", "Allow always", ...(wider ? [wider.label] : []), "Deny", "Deny with feedback"];
6
7
  // Clients bind the dialog to the call it approves by this id; with several calls of one tool in
7
8
  // flight (the engine prepares a message's calls before running them), nothing else says which.
8
9
  const call = request.tool
@@ -22,6 +23,9 @@ export async function showPermissionPrompt(ctx, request, signal) {
22
23
  message: feedback || undefined,
23
24
  };
24
25
  }
26
+ if (wider !== undefined && choice === wider.label) {
27
+ return { requestID: request.id, reply: "always", always: [...wider.patterns] };
28
+ }
25
29
  const replyMap = {
26
30
  "Allow once": "once",
27
31
  "Allow always": "always",
@@ -206,7 +206,7 @@ export class PermissionService {
206
206
  if (input.reply === "once") {
207
207
  return;
208
208
  }
209
- for (const pattern of existing.info.always) {
209
+ for (const pattern of input.always ?? existing.info.always) {
210
210
  this.approved.push({
211
211
  permission: existing.info.permission,
212
212
  pattern,
@@ -22,6 +22,11 @@ export type Request = {
22
22
  permission: string;
23
23
  patterns: string[];
24
24
  always: string[];
25
+ /** A wider "Allow always" scope the prompt may offer (tools without a parser, senpi#3403). */
26
+ widerAlways?: {
27
+ readonly label: string;
28
+ readonly patterns: string[];
29
+ };
25
30
  metadata: Record<string, unknown>;
26
31
  /** The tool call that raised this request (`parentCallID` when another tool issued it). */
27
32
  tool?: {
@@ -35,6 +40,8 @@ export type ReplyInput = {
35
40
  requestID: string;
36
41
  reply: Reply;
37
42
  message?: string;
43
+ /** With reply "always": the patterns to approve instead of the request's own (a wider scope). */
44
+ always?: string[];
38
45
  };
39
46
  /** Error thrown when user rejects a permission request */
40
47
  export declare class RejectedError extends Error {
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { chmodSync, existsSync, globSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { stripBom } from "../utils/text.js";
5
+ import { projectAgentsSkillDirs } from "./trust-manager.js";
5
6
  function getEnv() {
6
7
  if (process.platform !== "linux" || Object.keys(process.env).length > 0) {
7
8
  return process.env;
@@ -207,36 +208,9 @@ function collectFiles(dir, filePattern, skipNodeModules = true, ignoreMatcher, r
207
208
  }
208
209
  return files;
209
210
  }
210
- function findGitRepoRoot(startDir) {
211
- let dir = resolve(startDir);
212
- while (true) {
213
- if (existsSync(join(dir, ".git"))) {
214
- return dir;
215
- }
216
- const parent = dirname(dir);
217
- if (parent === dir) {
218
- return null;
219
- }
220
- dir = parent;
221
- }
222
- }
223
211
  function collectAncestorAgentsSkillDirs(startDir) {
224
- const skillDirs = [];
225
- const resolvedStartDir = resolve(startDir);
226
- const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
227
- let dir = resolvedStartDir;
228
- while (true) {
229
- skillDirs.push(join(dir, ".agents", "skills"));
230
- if (gitRepoRoot && dir === gitRepoRoot) {
231
- break;
232
- }
233
- const parent = dirname(dir);
234
- if (parent === dir) {
235
- break;
236
- }
237
- dir = parent;
238
- }
239
- return skillDirs;
212
+ // The same walk the trust check uses: home and above are never project skills (#3420 review).
213
+ return projectAgentsSkillDirs(startDir);
240
214
  }
241
215
  function collectAutoPromptEntries(dir) {
242
216
  const entries = [];
@@ -1964,9 +1938,7 @@ export class DefaultPackageManager {
1964
1938
  };
1965
1939
  const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
1966
1940
  const projectTrusted = this.settingsManager.isProjectTrusted();
1967
- const projectAgentsSkillDirs = projectTrusted
1968
- ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
1969
- : [];
1941
+ const projectAgentsSkillDirs = projectTrusted ? collectAncestorAgentsSkillDirs(this.cwd) : [];
1970
1942
  const addResources = (resourceType, paths, metadata, overrides, baseDir) => {
1971
1943
  const target = this.getTargetMap(accumulator, resourceType);
1972
1944
  for (const path of paths) {
@@ -2,6 +2,14 @@ import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/typ
2
2
  import type { DefaultProjectTrust } from "./settings-manager.ts";
3
3
  import { type ProjectTrustStore } from "./trust-manager.ts";
4
4
  export type AppMode = "interactive" | "print" | "json" | "rpc";
5
+ /**
6
+ * Full access means no prompt anywhere (sisyphuslabs/omo-desktop-app#2465): a session whose
7
+ * permission preset is explicitly `full-access` trusts its project for that session. Explicit
8
+ * means the session's `permission-preset` flag (CLI, or `open_session.permissionPreset` through
9
+ * the launch profile), else the user's global setting. Project settings never count: they are what
10
+ * trust gates. The implicit default never counts either, so a plain launch keeps asking.
11
+ */
12
+ export declare function presetTrustsProject(presetFlag: boolean | string | undefined, globalPreset: unknown): boolean;
5
13
  export interface ResolveProjectTrustedOptions {
6
14
  cwd: string;
7
15
  trustStore: ProjectTrustStore;
@@ -1,6 +1,18 @@
1
1
  import { APP_NAME, CONFIG_DIR_NAME } from "../config.js";
2
2
  import { emitProjectTrustEvent } from "./extensions/runner.js";
3
3
  import { getProjectTrustOptions, hasTrustRequiringProjectResources, } from "./trust-manager.js";
4
+ /**
5
+ * Full access means no prompt anywhere (sisyphuslabs/omo-desktop-app#2465): a session whose
6
+ * permission preset is explicitly `full-access` trusts its project for that session. Explicit
7
+ * means the session's `permission-preset` flag (CLI, or `open_session.permissionPreset` through
8
+ * the launch profile), else the user's global setting. Project settings never count: they are what
9
+ * trust gates. The implicit default never counts either, so a plain launch keeps asking.
10
+ */
11
+ export function presetTrustsProject(presetFlag, globalPreset) {
12
+ if (typeof presetFlag === "string")
13
+ return presetFlag === "full-access";
14
+ return globalPreset === "full-access";
15
+ }
4
16
  function formatProjectTrustPrompt(cwd) {
5
17
  return `Trust project folder?\n${cwd}\n\nThis allows ${APP_NAME} to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`;
6
18
  }
@@ -1,6 +1,8 @@
1
1
  import { type Theme } from "../modes/interactive/theme/theme.ts";
2
2
  import type { ResourceDiagnostic } from "./diagnostics.ts";
3
3
  export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
4
+ export { loadProjectContextFiles };
5
+ import { loadProjectContextFiles } from "./context-files.ts";
4
6
  import { type EventBus } from "./event-bus.ts";
5
7
  import type { HostMcpRegistry } from "./extensions/builtin/mcp/host-registry.ts";
6
8
  import type { ExtensionFactory, InlineExtension, LoadExtensionsResult, LoadedHookSources, SessionContext, SessionKind } from "./extensions/types.ts";
@@ -64,6 +66,7 @@ export interface ResourceLoader {
64
66
  path: string;
65
67
  content: string;
66
68
  }>;
69
+ diagnostics?: ResourceDiagnostic[];
67
70
  };
68
71
  getSystemPrompt(): string | undefined;
69
72
  getSystemPromptSource(): {
@@ -85,13 +88,6 @@ export interface ResourceLoader {
85
88
  * session. Canonicalizing collapses both spellings to one path.
86
89
  */
87
90
  export declare function canonicalizeGlobalDefaultExtensionModulePath(modulePath: string): string;
88
- export declare function loadProjectContextFiles(options: {
89
- cwd: string;
90
- agentDir: string;
91
- }): Array<{
92
- path: string;
93
- content: string;
94
- }>;
95
91
  export interface DefaultResourceLoaderOptions {
96
92
  cwd: string;
97
93
  agentDir: string;
@@ -185,6 +181,7 @@ export declare class DefaultResourceLoader implements ResourceLoader {
185
181
  private themes;
186
182
  private themeDiagnostics;
187
183
  private agentsFiles;
184
+ private contextDiagnostics;
188
185
  private systemPrompt?;
189
186
  private systemPromptSourcePath?;
190
187
  private appendSystemPrompt;
@@ -222,6 +219,7 @@ export declare class DefaultResourceLoader implements ResourceLoader {
222
219
  path: string;
223
220
  content: string;
224
221
  }>;
222
+ diagnostics: ResourceDiagnostic[];
225
223
  };
226
224
  getSystemPrompt(): string | undefined;
227
225
  getSystemPromptSource(): {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
- import { basename, dirname, join, resolve, sep } from "node:path";
2
+ import { dirname, join, resolve, sep } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { detectCapabilities, getTerminalColorMode } from "@earendil-works/pi-tui";
5
5
  import chalk from "chalk";
@@ -7,22 +7,24 @@ import { APP_NAME, CONFIG_DIR_NAME, findNodePackageDir, getAgentDir, getPackageD
7
7
  import { loadThemeFromPath } from "../modes/interactive/theme/theme.js";
8
8
  import { resolveInstallPath } from "../runtime-snapshot/marker.js";
9
9
  import { ACCEPTED_SHIM_BANNERS, GENERATED_SHIM_BANNER } from "./generated-shim-banner.js";
10
+ export { loadProjectContextFiles };
10
11
  import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.js";
11
12
  import { stripBom } from "../utils/text.js";
12
13
  import { bundledBuiltinExtensions } from "./bundled-resources.js";
14
+ import { loadProjectContextFiles } from "./context-files.js";
13
15
  import { createEventBus } from "./event-bus.js";
14
16
  import { builtinExtensions, globalDefaultExtensionFactories, globalDefaultExtensionIds, } from "./extensions/builtin/index.js";
15
17
  import { createMcpExtension } from "./extensions/builtin/mcp/index.js";
16
18
  import { McpService } from "./extensions/builtin/mcp/service.js";
17
19
  import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions, } from "./extensions/loader.js";
18
20
  import { EMPTY_SESSION_CONTEXT } from "./extensions/types.js";
19
- import { findGitPaths } from "./footer-data-provider.js";
20
21
  import { dedupePathsByPackageIdentity, findNearestPackageIdentity } from "./package-identity.js";
21
22
  import { DefaultPackageManager } from "./package-manager.js";
22
23
  import { loadPromptTemplates } from "./prompt-templates.js";
23
24
  import { memoizeResolvedPaths, resolvedPathsMemoKey } from "./resolved-paths-memo.js";
24
25
  import { SettingsManager } from "./settings-manager.js";
25
26
  import { loadSkills } from "./skills.js";
27
+ import { warnIfLargeSkillCatalog } from "./skills-prompt.js";
26
28
  import { BUILTIN_PATH_PREFIX, createSourceInfo, getSyntheticPathSource, isSyntheticPath, } from "./source-info.js";
27
29
  import { resetTimings, time } from "./timings.js";
28
30
  const HOST_PROVIDED_EXTENSION_PACKAGES = new Set([
@@ -256,86 +258,9 @@ function resolveGeneratedGlobalDefaultExtensionFactory(extensionPath, agentDir)
256
258
  return undefined;
257
259
  }
258
260
  }
259
- function loadContextFileFromDir(dir) {
260
- const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
261
- for (const filename of candidates) {
262
- const filePath = join(dir, filename);
263
- if (existsSync(filePath)) {
264
- try {
265
- if (!statSync(filePath).isFile()) {
266
- continue;
267
- }
268
- return {
269
- path: filePath,
270
- content: stripBom(readFileSync(filePath, "utf-8")),
271
- };
272
- }
273
- catch (error) {
274
- console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
275
- }
276
- }
277
- }
278
- return null;
279
- }
280
- /**
281
- * The main repo's context file that a nested linked worktree's own copy shadows: both
282
- * occupy the same logical repository scope, so loading both applies that context twice. Returns
283
- * undefined when nothing is shadowed, leaving normal ancestor inheritance alone.
284
- *
285
- * Returned canonicalized (realpath), because `git worktree add` writes the `.git`
286
- * file's `gitdir:` target in realpath form while cwd may still be symlinked
287
- * (macOS `/tmp` -> `/private/tmp`).
288
- */
289
- function findShadowedContextFile(cwd) {
290
- const gitPaths = findGitPaths(cwd);
291
- if (!gitPaths)
292
- return undefined;
293
- const commonGitDir = canonicalizePath(gitPaths.commonGitDir);
294
- const worktreeRoot = canonicalizePath(gitPaths.repoDir);
295
- const mainRepoRoot = dirname(commonGitDir);
296
- // False for an ordinary repo, where the two are the same dir, and for a sibling
297
- // worktree (`git worktree add ../feat`), whose main repo is not an ancestor.
298
- if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`))
299
- return undefined;
300
- // dirname of the common git dir is the main worktree root only when that dir is
301
- // itself checked out from the same repo. In a bare layout (`proj/.bare` +
302
- // `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a
303
- // submodule's gitdir has no `commondir`, so it lands under `.git/modules`.
304
- if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir)
305
- return undefined;
306
- const worktreeContextFile = loadContextFileFromDir(worktreeRoot);
307
- return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined;
308
- }
309
- export function loadProjectContextFiles(options) {
310
- const resolvedCwd = resolvePath(options.cwd);
311
- const resolvedAgentDir = resolvePath(options.agentDir);
312
- const contextFiles = [];
313
- const seenPaths = new Set();
314
- const globalContext = loadContextFileFromDir(resolvedAgentDir);
315
- if (globalContext) {
316
- contextFiles.push(globalContext);
317
- seenPaths.add(globalContext.path);
318
- }
319
- const ancestorContextFiles = [];
320
- const shadowedContextFile = findShadowedContextFile(resolvedCwd);
321
- let currentDir = resolvedCwd;
322
- while (true) {
323
- const contextFile = loadContextFileFromDir(currentDir);
324
- const isShadowed = shadowedContextFile !== undefined && canonicalizePath(contextFile?.path ?? "") === shadowedContextFile;
325
- if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
326
- ancestorContextFiles.unshift(contextFile);
327
- seenPaths.add(contextFile.path);
328
- }
329
- const parentDir = dirname(currentDir);
330
- if (parentDir === currentDir)
331
- break;
332
- currentDir = parentDir;
333
- }
334
- contextFiles.push(...ancestorContextFiles);
335
- return contextFiles;
336
- }
337
261
  export class DefaultResourceLoader {
338
262
  constructor(options) {
263
+ this.contextDiagnostics = [];
339
264
  this.cwd = resolvePath(options.cwd);
340
265
  this.agentDir = resolvePath(options.agentDir);
341
266
  this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
@@ -421,7 +346,7 @@ export class DefaultResourceLoader {
421
346
  return { themes: this.themes, diagnostics: this.themeDiagnostics };
422
347
  }
423
348
  getAgentsFiles() {
424
- return { agentsFiles: this.agentsFiles };
349
+ return { agentsFiles: this.agentsFiles, diagnostics: this.contextDiagnostics };
425
350
  }
426
351
  getSystemPrompt() {
427
352
  return this.systemPrompt;
@@ -616,14 +541,16 @@ export class DefaultResourceLoader {
616
541
  }
617
542
  const agentsFiles = {
618
543
  agentsFiles: this.noContextFiles
619
- ? []
544
+ ? Object.assign([], { diagnostics: [] })
620
545
  : loadProjectContextFiles({
621
546
  cwd: this.cwd,
622
547
  agentDir: this.agentDir,
548
+ ...this.settingsManager.getSettings().contextFiles,
623
549
  }),
624
550
  };
625
551
  const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
626
- this.agentsFiles = resolvedAgentsFiles.agentsFiles;
552
+ this.agentsFiles = [...resolvedAgentsFiles.agentsFiles];
553
+ this.contextDiagnostics = agentsFiles.agentsFiles.diagnostics;
627
554
  time("contextFiles", "extensions");
628
555
  // SYSTEM.md / APPEND_SYSTEM.md file discovery was intentionally removed; the explicit
629
556
  // options are the only static prompt source (see packages/coding-agent/changes.md).
@@ -872,7 +799,7 @@ export class DefaultResourceLoader {
872
799
  skill.sourceInfo ??
873
800
  this.getDefaultSourceInfoForPath(skill.filePath),
874
801
  }));
875
- this.skillDiagnostics = resolvedSkills.diagnostics;
802
+ this.skillDiagnostics = [...resolvedSkills.diagnostics, ...warnIfLargeSkillCatalog(this.skills)];
876
803
  }
877
804
  updatePromptsFromPaths(promptPaths, metadataByPath) {
878
805
  let promptsResult;
@@ -16,6 +16,7 @@ export declare class CircuitProbes {
16
16
  constructor(circuits: FallbackCircuitAccess | undefined);
17
17
  isOpen(selector: string): boolean;
18
18
  governs(selector: string): boolean;
19
+ isExhausted(selector: string): boolean;
19
20
  admit(selector: string): CircuitAdmission["kind"];
20
21
  holds(selector: string): boolean;
21
22
  get probing(): string | undefined;