indusagi-coding-agent 0.2.5 → 0.2.9
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.
- package/CHANGELOG.md +9 -0
- package/README.md +9 -5
- package/dist/entry.js +14267 -24282
- package/dist/guardrails.js +924 -7867
- package/dist/index.js +13706 -23775
- package/package.json +3 -2
- package/src/_decls/entry.ts +18 -0
- package/src/_decls/guardrails.ts +35 -0
- package/src/_decls/index.ts +26 -0
- package/src/addons/contract.ts +236 -0
- package/src/addons/dispatch/event-dispatcher.ts +164 -0
- package/src/addons/dispatch/index.ts +25 -0
- package/src/addons/dispatch/tool-interceptor.ts +208 -0
- package/src/addons/host.ts +225 -0
- package/src/addons/index.ts +112 -0
- package/src/addons/manifest.ts +158 -0
- package/src/addons/sandbox.ts +170 -0
- package/src/addons/surface.ts +78 -0
- package/src/boot/auth-vault.ts +195 -0
- package/src/boot/boot.ts +138 -0
- package/src/boot/contract.ts +238 -0
- package/src/boot/heap.ts +59 -0
- package/src/boot/index.ts +28 -0
- package/src/boot/invocation.ts +93 -0
- package/src/boot/runners/addon-wiring.ts +153 -0
- package/src/boot/runners/checkpoint.ts +169 -0
- package/src/boot/runners/delegate-runner.ts +294 -0
- package/src/boot/runners/index.ts +13 -0
- package/src/boot/runners/link-runner.ts +45 -0
- package/src/boot/runners/memdir.ts +168 -0
- package/src/boot/runners/oneshot-runner.ts +58 -0
- package/src/boot/runners/read-state.ts +90 -0
- package/src/boot/runners/registry.ts +42 -0
- package/src/boot/runners/repl-runner.ts +143 -0
- package/src/boot/runners/server-mode.ts +121 -0
- package/src/boot/runners/session.ts +641 -0
- package/src/boot/server-token.ts +148 -0
- package/src/boot/stages.ts +167 -0
- package/src/boot/upgrade/apply.ts +94 -0
- package/src/boot/upgrade/index.ts +13 -0
- package/src/boot/upgrade/upgrades.ts +289 -0
- package/src/briefing/compose.ts +150 -0
- package/src/briefing/context-docs.ts +19 -0
- package/src/briefing/contract.ts +717 -0
- package/src/briefing/index.ts +31 -0
- package/src/briefing/macros.ts +97 -0
- package/src/briefing/skills.ts +47 -0
- package/src/capability-deck/bridge-ledger/index.ts +27 -0
- package/src/capability-deck/bridge-ledger/key.ts +67 -0
- package/src/capability-deck/bridge-ledger/ledger.ts +131 -0
- package/src/capability-deck/bridge-ledger/network.ts +117 -0
- package/src/capability-deck/builtin-bridge.ts +312 -0
- package/src/capability-deck/cards/bg-process-card.ts +335 -0
- package/src/capability-deck/cards/index.ts +115 -0
- package/src/capability-deck/cards/memory-card.ts +146 -0
- package/src/capability-deck/cards/plan-file.ts +97 -0
- package/src/capability-deck/cards/plan-tools.ts +185 -0
- package/src/capability-deck/cards/saas-card.ts +183 -0
- package/src/capability-deck/cards/task-card.ts +207 -0
- package/src/capability-deck/cards/todo-card.ts +168 -0
- package/src/capability-deck/cards/workflow-card.ts +247 -0
- package/src/capability-deck/contract.ts +388 -0
- package/src/capability-deck/index.ts +48 -0
- package/src/capability-deck/manifest.ts +109 -0
- package/src/capability-deck/provision.ts +169 -0
- package/src/channels/contract.ts +191 -0
- package/src/channels/framer.ts +50 -0
- package/src/channels/index.ts +101 -0
- package/src/channels/link/dialog.ts +129 -0
- package/src/channels/link/driver.ts +190 -0
- package/src/channels/link/index.ts +34 -0
- package/src/channels/link/server.ts +134 -0
- package/src/channels/oneshot.ts +90 -0
- package/src/channels/ops.ts +65 -0
- package/src/channels/session-ops.ts +81 -0
- package/src/conductor/bash-guard.ts +599 -0
- package/src/conductor/catalog/catalog.ts +116 -0
- package/src/conductor/catalog/index.ts +8 -0
- package/src/conductor/catalog/matcher.ts +134 -0
- package/src/conductor/conductor.ts +234 -0
- package/src/conductor/contract.ts +842 -0
- package/src/conductor/diagnostics.ts +227 -0
- package/src/conductor/index.ts +33 -0
- package/src/conductor/permissions.ts +588 -0
- package/src/conductor/quota-error.ts +49 -0
- package/src/conductor/signal-hub/hub.ts +46 -0
- package/src/conductor/signal-hub/index.ts +2 -0
- package/src/conductor/signal-hub/translate.test.ts +81 -0
- package/src/conductor/signal-hub/translate.ts +74 -0
- package/src/conductor/skill-parse/index.ts +2 -0
- package/src/conductor/skill-parse/parse.ts +108 -0
- package/src/conductor/transcript-store/index.ts +22 -0
- package/src/conductor/transcript-store/serialize.ts +116 -0
- package/src/conductor/transcript-store/store.ts +205 -0
- package/src/console/auth-status.ts +56 -0
- package/src/console/components/AgentsView.ts +165 -0
- package/src/console/components/BackgroundAgents.ts +155 -0
- package/src/console/components/Banner.ts +334 -0
- package/src/console/components/Composer.ts +94 -0
- package/src/console/components/StatusBar.ts +49 -0
- package/src/console/components/TerminalConsole.ts +1090 -0
- package/src/console/components/WorkingIndicator.ts +98 -0
- package/src/console/components/banner-sweep.ts +24 -0
- package/src/console/components/welcome.ts +74 -0
- package/src/console/contract.ts +630 -0
- package/src/console/index.ts +34 -0
- package/src/console/input/complete.ts +127 -0
- package/src/console/input/dir-reader.ts +34 -0
- package/src/console/input/index.ts +23 -0
- package/src/console/input/keymap.ts +159 -0
- package/src/console/input/paste.ts +104 -0
- package/src/console/mount.ts +56 -0
- package/src/console/overlays/approval-queue.ts +57 -0
- package/src/console/overlays/approval.ts +130 -0
- package/src/console/overlays/auth.ts +342 -0
- package/src/console/overlays/boards.ts +308 -0
- package/src/console/overlays/host.ts +36 -0
- package/src/console/overlays/index.ts +26 -0
- package/src/console/overlays/pickers.ts +258 -0
- package/src/console/overlays/sessions.ts +190 -0
- package/src/console/reducer.ts +182 -0
- package/src/console/slash/builtins.ts +81 -0
- package/src/console/slash/commands/dynamic.ts +83 -0
- package/src/console/slash/commands/integrations.ts +695 -0
- package/src/console/slash/commands/shared.ts +75 -0
- package/src/console/slash/commands/transcript.ts +263 -0
- package/src/console/slash/commands/workbench.ts +246 -0
- package/src/console/slash/index.ts +15 -0
- package/src/console/slash/registry.ts +70 -0
- package/src/console/slash/resolve.ts +63 -0
- package/src/console/startup.ts +209 -0
- package/src/console/theme/adapter.ts +45 -0
- package/src/console/theme/index.ts +7 -0
- package/src/console/theme/palette.ts +68 -0
- package/src/console/theme/resolve.ts +39 -0
- package/src/console/theme/tokens.ts +71 -0
- package/src/entry.ts +55 -0
- package/src/guardrails.ts +37 -0
- package/src/index.ts +18 -0
- package/src/insight/channel.ts +88 -0
- package/src/insight/contract.ts +185 -0
- package/src/insight/index.ts +110 -0
- package/src/insight/recorder.ts +213 -0
- package/src/insight/redaction.ts +157 -0
- package/src/insight/replay.ts +158 -0
- package/src/insight/sampling.ts +70 -0
- package/src/insight/serialize.ts +50 -0
- package/src/insight/sinks/console.ts +64 -0
- package/src/insight/sinks/file.ts +40 -0
- package/src/insight/sinks/index.ts +24 -0
- package/src/insight/sinks/stream.ts +54 -0
- package/src/integrations/sarvam/attach.ts +239 -0
- package/src/integrations/sarvam/config.ts +156 -0
- package/src/integrations/sarvam/index.ts +25 -0
- package/src/integrations/sarvam/sarvam.test.ts +60 -0
- package/src/integrations/sarvam/types.ts +27 -0
- package/src/integrations/zoho/attach.ts +342 -0
- package/src/integrations/zoho/config.ts +125 -0
- package/src/integrations/zoho/index.ts +27 -0
- package/src/integrations/zoho/types.ts +21 -0
- package/src/integrations/zoho/zoho.test.ts +50 -0
- package/src/kit/clipboard-image.ts +107 -0
- package/src/kit/external-editor.ts +48 -0
- package/src/kit/image.ts +59 -0
- package/src/kit/index.ts +51 -0
- package/src/kit/shell.ts +19 -0
- package/src/kit/tool-fetch.ts +85 -0
- package/src/launch/catalog.ts +148 -0
- package/src/launch/contract.ts +187 -0
- package/src/launch/credentials.ts +625 -0
- package/src/launch/index.ts +98 -0
- package/src/launch/invocation/attachments.ts +179 -0
- package/src/launch/invocation/flags.ts +196 -0
- package/src/launch/invocation/index.ts +25 -0
- package/src/launch/invocation/read.ts +260 -0
- package/src/launch/invocation/usage.ts +67 -0
- package/src/launch/login.ts +324 -0
- package/src/launch/oauth.test.ts +18 -0
- package/src/launch/oauth.ts +203 -0
- package/src/launch/packages.ts +194 -0
- package/src/launch/pickers.ts +189 -0
- package/src/runtime-bridge/bridges/_drive.ts +96 -0
- package/src/runtime-bridge/bridges/builtins.ts +68 -0
- package/src/runtime-bridge/bridges/claude-cli.ts +123 -0
- package/src/runtime-bridge/bridges/codex-cli.ts +142 -0
- package/src/runtime-bridge/bridges/index.ts +33 -0
- package/src/runtime-bridge/bridges/indusagi-cli.ts +155 -0
- package/src/runtime-bridge/broker.ts +227 -0
- package/src/runtime-bridge/contract.ts +122 -0
- package/src/runtime-bridge/index.ts +79 -0
- package/src/runtime-bridge/sink.ts +180 -0
- package/src/sessions/contract.ts +81 -0
- package/src/sessions/index.ts +13 -0
- package/src/sessions/library.ts +229 -0
- package/src/settings/contract.ts +114 -0
- package/src/settings/index.ts +32 -0
- package/src/settings/manager.ts +117 -0
- package/src/transcript-export/index.ts +45 -0
- package/src/transcript-export/publish.ts +260 -0
- package/src/transcript-export/sgr.ts +315 -0
- package/src/transcript-export/template.ts +272 -0
- package/src/transcript-export/theme-bridge.ts +150 -0
- package/src/window-budget/budget/estimate.ts +135 -0
- package/src/window-budget/budget/gate.ts +33 -0
- package/src/window-budget/budget/index.ts +16 -0
- package/src/window-budget/budget/slice.ts +56 -0
- package/src/window-budget/condenser.ts +58 -0
- package/src/window-budget/contract.ts +184 -0
- package/src/window-budget/index.ts +19 -0
- package/src/window-budget/microcompact.ts +95 -0
- package/src/window-budget/rehydrate.ts +136 -0
- package/src/window-budget/summarize/condense.ts +103 -0
- package/src/window-budget/summarize/index.ts +14 -0
- package/src/window-budget/summarize/prompt.ts +149 -0
- package/src/workflow-engine/agent-runner.ts +181 -0
- package/src/workflow-engine/display.ts +224 -0
- package/src/workflow-engine/engine.ts +294 -0
- package/src/workflow-engine/index.ts +23 -0
- package/src/workflow-engine/parse.ts +172 -0
- package/src/workflow-engine/structured-output.ts +35 -0
- package/src/workspace/brand.ts +29 -0
- package/src/workspace/index.ts +18 -0
- package/src/workspace/locator.ts +103 -0
- package/src/workspace/runtime-detect.ts +64 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission rule engine + the `canUseTool` gate seam.
|
|
3
|
+
*
|
|
4
|
+
* This module is the product-side brain of the permission stack. It is split in
|
|
5
|
+
* two layers:
|
|
6
|
+
*
|
|
7
|
+
* 1. **A pure rule engine** — {@link resolveRuleDecision} maps a single tool
|
|
8
|
+
* call `(toolName, input, mode, rules)` to a {@link PermissionBehavior}
|
|
9
|
+
* (`allow` / `ask` / `deny`). It is total, synchronous, and side-effect free
|
|
10
|
+
* so the precedence (deny > ask > mode auto-allow > allow) is exhaustively
|
|
11
|
+
* unit-testable. Rule strings are induscode-style: a bare tool name
|
|
12
|
+
* (`"Bash"`) or a tool name with an argument specifier
|
|
13
|
+
* (`"Bash(npm run test:*)"`).
|
|
14
|
+
*
|
|
15
|
+
* 2. **A gate factory** — {@link createPermissionGate} turns that engine into a
|
|
16
|
+
* framework {@link CanUseToolFn}: the hard async hook the framework agent
|
|
17
|
+
* awaits immediately before every tool runs. An `ask` decision is routed to
|
|
18
|
+
* an OPTIONAL host-supplied approval resolver; when the resolver is absent
|
|
19
|
+
* (a non-interactive boot) `ask` denies with a clear message. An
|
|
20
|
+
* `allow-always` approval appends a session-scoped allow rule so later
|
|
21
|
+
* identical calls auto-allow.
|
|
22
|
+
*
|
|
23
|
+
* Backwards-compatibility: the framework `canUseTool` hook is itself optional, so
|
|
24
|
+
* a session that never builds a gate keeps today's allow-all behavior. A gate
|
|
25
|
+
* built from an empty rule set in `default` mode allows read-only tools and asks
|
|
26
|
+
* for everything else — and, with no resolver, the `ask` denials are explicit
|
|
27
|
+
* rather than silent.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { PermissionMode } from "../settings.js"
|
|
31
|
+
import {
|
|
32
|
+
bashSubcommandSubjects,
|
|
33
|
+
evaluateCatastrophic,
|
|
34
|
+
} from "./bash-guard.js"
|
|
35
|
+
|
|
36
|
+
/** Re-exported so consumers of the engine get the mode vocabulary in one import. */
|
|
37
|
+
export type { PermissionMode }
|
|
38
|
+
|
|
39
|
+
/** The verdict the rule engine renders for a single tool call. */
|
|
40
|
+
export type PermissionBehavior = "allow" | "ask" | "deny"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One parsed permission rule: a behavior plus the tool it targets, with an
|
|
44
|
+
* optional argument specifier (`ruleContent`) that further narrows the match.
|
|
45
|
+
*/
|
|
46
|
+
export interface PermissionRule {
|
|
47
|
+
/** What happens when this rule matches a tool call. */
|
|
48
|
+
readonly ruleBehavior: PermissionBehavior
|
|
49
|
+
/** The tool (and optional argument specifier) this rule selects. */
|
|
50
|
+
readonly ruleValue: {
|
|
51
|
+
/** The canonical tool name, e.g. `"Bash"` / `"bash"` (matched case-insensitively). */
|
|
52
|
+
readonly toolName: string
|
|
53
|
+
/** An optional argument specifier, e.g. `"npm run test:*"`. Absent = bare match. */
|
|
54
|
+
readonly ruleContent?: string
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The framework's tool-permission verdict. Mirrors `indusagi/agent`'s
|
|
60
|
+
* `PermissionDecision`; re-declared here so the product engine never imports the
|
|
61
|
+
* framework just for a structural type (the gate is assignable to the
|
|
62
|
+
* framework's `CanUseToolFn` because the shapes coincide).
|
|
63
|
+
*/
|
|
64
|
+
export type PermissionDecision =
|
|
65
|
+
| {
|
|
66
|
+
behavior: "allow"
|
|
67
|
+
updatedInput?: unknown
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
behavior: "deny"
|
|
71
|
+
message: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The hard permission gate the framework agent awaits before each tool executes.
|
|
76
|
+
* Structurally identical to `indusagi/agent`'s `CanUseToolFn`, so a value of this
|
|
77
|
+
* type is accepted by `new Agent({ canUseTool })` with no cast.
|
|
78
|
+
*/
|
|
79
|
+
export type CanUseToolFn = (
|
|
80
|
+
toolName: string,
|
|
81
|
+
input: unknown,
|
|
82
|
+
opts: { signal?: AbortSignal },
|
|
83
|
+
) => Promise<PermissionDecision>
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The outcome of the host approval prompt raised for an `ask` decision:
|
|
87
|
+
* - `allow-once` — run this single call, do not remember.
|
|
88
|
+
* - `allow-always` — run it and append a session allow rule for the tool.
|
|
89
|
+
* - `deny` — block this call.
|
|
90
|
+
*/
|
|
91
|
+
export type ApprovalChoice = "allow-once" | "allow-always" | "deny"
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What an {@link ApprovalResolver} may resolve to: a user's {@link ApprovalChoice},
|
|
95
|
+
* or `"unavailable"` — the conductor's stable delegate answers that when NO
|
|
96
|
+
* interactive resolver is installed behind it (a headless boot, or the console not
|
|
97
|
+
* yet mounted). The gate maps `"unavailable"` to the actionable "requires
|
|
98
|
+
* approval" deny (pointing at `--permission-mode` / settings allow rules) rather
|
|
99
|
+
* than the "denied by the user" message, which would be false when no user ever
|
|
100
|
+
* saw a prompt.
|
|
101
|
+
*/
|
|
102
|
+
export type ApprovalOutcome = ApprovalChoice | "unavailable"
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The OPTIONAL host approval resolver. When present, an `ask` decision awaits it;
|
|
106
|
+
* the host (an interactive overlay) returns the user's choice. It MUST resolve to
|
|
107
|
+
* `"deny"` on abort so a cancelled turn never hangs on a pending prompt. When the
|
|
108
|
+
* resolver is absent (non-interactive boot / oneshot / link), an `ask` decision
|
|
109
|
+
* deterministically denies. A stable pass-through delegate (the conductor's)
|
|
110
|
+
* resolves `"unavailable"` while no real resolver is installed behind it, so the
|
|
111
|
+
* gate can surface the actionable non-interactive deny message instead of a
|
|
112
|
+
* fictitious user denial.
|
|
113
|
+
*/
|
|
114
|
+
export type ApprovalResolver = (
|
|
115
|
+
toolName: string,
|
|
116
|
+
input: unknown,
|
|
117
|
+
opts: { signal?: AbortSignal },
|
|
118
|
+
) => Promise<ApprovalOutcome>
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tool names known to only inspect state (never mutate). Mirrors the framework's
|
|
122
|
+
* `READ_ONLY_TOOL_NAMES`; matched case-insensitively. A tool the framework marks
|
|
123
|
+
* `readOnly: true` is also auto-allowed via {@link createPermissionGate}'s
|
|
124
|
+
* `readOnlyToolNames` option, so this set is the static fallback for callers that
|
|
125
|
+
* only know names.
|
|
126
|
+
*/
|
|
127
|
+
export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
128
|
+
"read",
|
|
129
|
+
"ls",
|
|
130
|
+
"grep",
|
|
131
|
+
"find",
|
|
132
|
+
"glob",
|
|
133
|
+
"websearch",
|
|
134
|
+
"webfetch",
|
|
135
|
+
"todoread",
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Tool names that mutate the workspace by editing or writing files. Auto-allowed
|
|
140
|
+
* under `acceptEdits`, and (together with every other non-read-only tool) denied
|
|
141
|
+
* under `plan`.
|
|
142
|
+
*/
|
|
143
|
+
export const EDIT_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
144
|
+
"edit",
|
|
145
|
+
"write",
|
|
146
|
+
"multiedit",
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
function canon(toolName: string): string {
|
|
150
|
+
return toolName.toLowerCase()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether `toolName` is a statically-known read-only tool. */
|
|
154
|
+
export function isReadOnlyToolName(
|
|
155
|
+
toolName: string,
|
|
156
|
+
extra?: ReadonlySet<string>,
|
|
157
|
+
): boolean {
|
|
158
|
+
const name = canon(toolName)
|
|
159
|
+
if (READ_ONLY_TOOL_NAMES.has(name)) return true
|
|
160
|
+
if (extra) {
|
|
161
|
+
for (const candidate of extra) {
|
|
162
|
+
if (canon(candidate) === name) return true
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether `toolName` is an edit/write tool (auto-allowed under `acceptEdits`). */
|
|
169
|
+
export function isEditToolName(toolName: string): boolean {
|
|
170
|
+
return EDIT_TOOL_NAMES.has(canon(toolName))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeMode(mode: PermissionMode): PermissionMode {
|
|
174
|
+
return mode === "bypassPermissions" ? "bypass" : mode
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse an induscode-style rule string into its tool name and optional argument
|
|
179
|
+
* specifier.
|
|
180
|
+
*
|
|
181
|
+
* - `"Bash"` → `{ toolName: "Bash" }`
|
|
182
|
+
* - `"Bash(npm run test:*)"` → `{ toolName: "Bash", ruleContent: "npm run test:*" }`
|
|
183
|
+
* - `"mcp__server"` → `{ toolName: "mcp__server" }`
|
|
184
|
+
*
|
|
185
|
+
* Whitespace around the tool name is trimmed; an empty argument specifier
|
|
186
|
+
* (`"Bash()"`) is treated as a bare rule.
|
|
187
|
+
*/
|
|
188
|
+
export function parseRule(raw: string): {
|
|
189
|
+
toolName: string
|
|
190
|
+
ruleContent?: string
|
|
191
|
+
} {
|
|
192
|
+
const trimmed = raw.trim()
|
|
193
|
+
const open = trimmed.indexOf("(")
|
|
194
|
+
if (open === -1 || !trimmed.endsWith(")")) {
|
|
195
|
+
return { toolName: trimmed }
|
|
196
|
+
}
|
|
197
|
+
const toolName = trimmed.slice(0, open).trim()
|
|
198
|
+
const ruleContent = trimmed.slice(open + 1, trimmed.length - 1).trim()
|
|
199
|
+
return ruleContent.length > 0 ? { toolName, ruleContent } : { toolName }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build a {@link PermissionRule} from a behavior and an induscode-style rule
|
|
204
|
+
* string. The single place a raw settings list entry becomes a typed rule.
|
|
205
|
+
*/
|
|
206
|
+
export function makeRule(
|
|
207
|
+
ruleBehavior: PermissionBehavior,
|
|
208
|
+
raw: string,
|
|
209
|
+
): PermissionRule {
|
|
210
|
+
return { ruleBehavior, ruleValue: parseRule(raw) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function ruleSubject(toolName: string, input: unknown): string {
|
|
214
|
+
if (input === null || typeof input !== "object") {
|
|
215
|
+
return typeof input === "string" ? input : ""
|
|
216
|
+
}
|
|
217
|
+
const bag = input as Record<string, unknown>
|
|
218
|
+
const name = canon(toolName)
|
|
219
|
+
if ((name === "bash" || name === "shell") && typeof bag.command === "string") {
|
|
220
|
+
return bag.command
|
|
221
|
+
}
|
|
222
|
+
if (typeof bag.command === "string") return bag.command
|
|
223
|
+
try {
|
|
224
|
+
return JSON.stringify(bag)
|
|
225
|
+
} catch {
|
|
226
|
+
return ""
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function specifierMatches(specifier: string, subject: string): boolean {
|
|
231
|
+
if (specifier.endsWith(":*")) {
|
|
232
|
+
return subject.startsWith(specifier.slice(0, -2))
|
|
233
|
+
}
|
|
234
|
+
if (specifier.endsWith("*") && !specifier.slice(0, -1).includes("*")) {
|
|
235
|
+
return subject.startsWith(specifier.slice(0, -1))
|
|
236
|
+
}
|
|
237
|
+
if (specifier.includes("*")) {
|
|
238
|
+
const escaped = specifier
|
|
239
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
240
|
+
.replace(/\*/g, ".*")
|
|
241
|
+
return new RegExp(`^${escaped}$`).test(subject)
|
|
242
|
+
}
|
|
243
|
+
return specifier === subject
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isBashTool(toolName: string): boolean {
|
|
247
|
+
const name = canon(toolName)
|
|
248
|
+
return name === "bash" || name === "shell"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function bashSubjects(toolName: string, input: unknown): string[] {
|
|
252
|
+
const command = ruleSubject(toolName, input)
|
|
253
|
+
if (command.length === 0) return []
|
|
254
|
+
return bashSubcommandSubjects(command)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether a tool call matches a rule (the ANY-match semantics used for deny/ask).
|
|
259
|
+
*
|
|
260
|
+
* The tool name must match (case-insensitive), with an `mcp__server` rule also
|
|
261
|
+
* matching every `mcp__server__tool` under it (the induscode MCP-wildcard
|
|
262
|
+
* convention). A bare rule (no specifier) matches any arguments.
|
|
263
|
+
*
|
|
264
|
+
* When the rule carries an argument specifier:
|
|
265
|
+
* - for the **shell** tool the command is split into its constituent
|
|
266
|
+
* sub-commands and the specifier is tested against EACH — a match on ANY
|
|
267
|
+
* sub-command counts (so `Bash(rm:*)` denies `git status && rm x`);
|
|
268
|
+
* - for any other tool the specifier is tested against the single rendered
|
|
269
|
+
* subject (the command field, or a JSON encoding).
|
|
270
|
+
*
|
|
271
|
+
* The any-match semantics are correct for `deny` and `ask` (a single offending
|
|
272
|
+
* sub-command should trigger them). The `allow` branch needs ALL sub-commands
|
|
273
|
+
* covered instead — see {@link bashAllowCoversAll}.
|
|
274
|
+
*/
|
|
275
|
+
export function toolMatchesRule(
|
|
276
|
+
toolName: string,
|
|
277
|
+
input: unknown,
|
|
278
|
+
rule: PermissionRule,
|
|
279
|
+
): boolean {
|
|
280
|
+
const ruleName = canon(rule.ruleValue.toolName)
|
|
281
|
+
const callName = canon(toolName)
|
|
282
|
+
const nameMatches =
|
|
283
|
+
ruleName === callName ||
|
|
284
|
+
(ruleName.startsWith("mcp__") && callName.startsWith(`${ruleName}__`))
|
|
285
|
+
if (!nameMatches) return false
|
|
286
|
+
const specifier = rule.ruleValue.ruleContent
|
|
287
|
+
if (specifier === void 0) return true
|
|
288
|
+
if (isBashTool(toolName)) {
|
|
289
|
+
const subjects = bashSubjects(toolName, input)
|
|
290
|
+
if (subjects.length === 0)
|
|
291
|
+
return specifierMatches(specifier, ruleSubject(toolName, input))
|
|
292
|
+
return subjects.some((subject) => specifierMatches(specifier, subject))
|
|
293
|
+
}
|
|
294
|
+
return specifierMatches(specifier, ruleSubject(toolName, input))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function bashAllowSetCoversAll(
|
|
298
|
+
toolName: string,
|
|
299
|
+
input: unknown,
|
|
300
|
+
allowRules: readonly PermissionRule[],
|
|
301
|
+
): boolean {
|
|
302
|
+
const callName = canon(toolName)
|
|
303
|
+
const subjects = bashSubjects(toolName, input)
|
|
304
|
+
if (subjects.length === 0) return false
|
|
305
|
+
return subjects.every((subject) =>
|
|
306
|
+
allowRules.some((rule) => {
|
|
307
|
+
if (canon(rule.ruleValue.toolName) !== callName) return false
|
|
308
|
+
const specifier = rule.ruleValue.ruleContent
|
|
309
|
+
if (specifier === void 0) return true
|
|
310
|
+
return specifierMatches(specifier, subject)
|
|
311
|
+
}),
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Resolve the behavior for a single tool call against the rule set and mode.
|
|
317
|
+
*
|
|
318
|
+
* Precedence, highest first:
|
|
319
|
+
* 1. **deny rules** — any matching `deny` rule blocks, regardless of mode. Deny
|
|
320
|
+
* wins across every tier (the caller concatenates the tiers before passing
|
|
321
|
+
* the list in, and this scans all deny rules first).
|
|
322
|
+
* 2. **plan mode** — denies any mutating (non-read-only) tool outright.
|
|
323
|
+
* 3. **ask rules** — a matching `ask` rule forces a prompt (unless a deny
|
|
324
|
+
* already fired). Mode auto-allow does NOT override an explicit ask.
|
|
325
|
+
* 4. **bypass mode** — allows everything not already denied/asked.
|
|
326
|
+
* 5. **read-only auto-allow** — a known read-only tool allows in any mode.
|
|
327
|
+
* 6. **acceptEdits mode** — auto-allows edit/write tools.
|
|
328
|
+
* 7. **allow rules** — a matching `allow` rule (or, for the shell tool, the
|
|
329
|
+
* allow rule SET) allows.
|
|
330
|
+
* 8. **fallthrough** — `ask` (the safe default: prompt, then deny when no
|
|
331
|
+
* resolver is wired).
|
|
332
|
+
*
|
|
333
|
+
* The catastrophic-command blocklist sits ABOVE all of this (step 0): a shell
|
|
334
|
+
* command on the built-in blocklist (`rm -rf /`, fork bombs, `curl | sh`, …) is
|
|
335
|
+
* denied regardless of rules or mode — even `bypass` cannot run it. See
|
|
336
|
+
* {@link evaluateCatastrophic}.
|
|
337
|
+
*
|
|
338
|
+
* Shell handling threads through the per-sub-command matching: a compound
|
|
339
|
+
* command (`a && b; c | d`) is split into its constituent commands and each rule
|
|
340
|
+
* is evaluated against each — a deny/ask on ANY sub-command fires, while
|
|
341
|
+
* auto-allow requires the allow rule set to cover EVERY sub-command.
|
|
342
|
+
*
|
|
343
|
+
* Pure and total: no I/O, no async, deterministic for a given input.
|
|
344
|
+
*/
|
|
345
|
+
export function resolveRuleDecision(
|
|
346
|
+
toolName: string,
|
|
347
|
+
input: unknown,
|
|
348
|
+
rules: readonly PermissionRule[],
|
|
349
|
+
mode: PermissionMode,
|
|
350
|
+
readOnlyExtra?: ReadonlySet<string>,
|
|
351
|
+
): PermissionBehavior {
|
|
352
|
+
const m = normalizeMode(mode)
|
|
353
|
+
if (isBashTool(toolName)) {
|
|
354
|
+
const command = ruleSubject(toolName, input)
|
|
355
|
+
if (command.length > 0 && evaluateCatastrophic(command) !== void 0) {
|
|
356
|
+
return "deny"
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const rule of rules) {
|
|
360
|
+
if (rule.ruleBehavior === "deny" && toolMatchesRule(toolName, input, rule)) {
|
|
361
|
+
return "deny"
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const readOnly = isReadOnlyToolName(toolName, readOnlyExtra)
|
|
365
|
+
if (m === "plan" && !readOnly) {
|
|
366
|
+
return "deny"
|
|
367
|
+
}
|
|
368
|
+
for (const rule of rules) {
|
|
369
|
+
if (rule.ruleBehavior === "ask" && toolMatchesRule(toolName, input, rule)) {
|
|
370
|
+
return "ask"
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (m === "bypass") {
|
|
374
|
+
return "allow"
|
|
375
|
+
}
|
|
376
|
+
if (readOnly) {
|
|
377
|
+
return "allow"
|
|
378
|
+
}
|
|
379
|
+
if (m === "acceptEdits" && isEditToolName(toolName)) {
|
|
380
|
+
return "allow"
|
|
381
|
+
}
|
|
382
|
+
if (isBashTool(toolName)) {
|
|
383
|
+
const allowRules = rules.filter((r) => r.ruleBehavior === "allow")
|
|
384
|
+
if (
|
|
385
|
+
allowRules.length > 0 &&
|
|
386
|
+
bashAllowSetCoversAll(toolName, input, allowRules)
|
|
387
|
+
) {
|
|
388
|
+
return "allow"
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
for (const rule of rules) {
|
|
392
|
+
if (rule.ruleBehavior === "allow" && toolMatchesRule(toolName, input, rule)) {
|
|
393
|
+
return "allow"
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return "ask"
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The rule strings an `allow-always` approval mints — i.e. what the session will
|
|
402
|
+
* REMEMBER for this tool call.
|
|
403
|
+
*
|
|
404
|
+
* For the shell tool the remembered rules are scoped to the command: one
|
|
405
|
+
* `Bash(<sub-command>)` rule per constituent sub-command (so approving
|
|
406
|
+
* `git status && npm test` remembers both halves, and re-running either — or the
|
|
407
|
+
* same compound — auto-allows), but approving one command never whitelists the
|
|
408
|
+
* whole shell. Every other tool remembers the bare tool name (`Edit`), matching
|
|
409
|
+
* the prompt's "remember the tool for this session" copy.
|
|
410
|
+
*
|
|
411
|
+
* Exported so the approval overlay can show the user exactly what "Allow always"
|
|
412
|
+
* will remember (the `suggestions` line), and so the gate and the UI can never
|
|
413
|
+
* disagree about it.
|
|
414
|
+
*/
|
|
415
|
+
export function allowAlwaysRuleStrings(
|
|
416
|
+
toolName: string,
|
|
417
|
+
input: unknown,
|
|
418
|
+
): string[] {
|
|
419
|
+
if (isBashTool(toolName)) {
|
|
420
|
+
const bag = input as Record<string, unknown> | null
|
|
421
|
+
const command =
|
|
422
|
+
typeof input === "string"
|
|
423
|
+
? input
|
|
424
|
+
: bag !== null &&
|
|
425
|
+
typeof bag === "object" &&
|
|
426
|
+
typeof bag.command === "string"
|
|
427
|
+
? bag.command
|
|
428
|
+
: ""
|
|
429
|
+
if (command.length > 0) {
|
|
430
|
+
return bashSubcommandSubjects(command).map(
|
|
431
|
+
(subject: string) => `${toolName}(${subject})`,
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return [toolName]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function noResolverMessage(toolName: string): string {
|
|
439
|
+
return `Tool "${toolName}" requires approval, but no interactive approval prompt is available here (a non-interactive run, or a sub-agent). Denying. Add an allow rule under permissions.allow in settings, or run with --permission-mode acceptEdits/bypass, to permit it.`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function declinedMessage(toolName: string): string {
|
|
443
|
+
return `Tool "${toolName}" was denied by the user.`
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function blockedMessage(toolName: string): string {
|
|
447
|
+
return `Tool "${toolName}" is blocked by the active permission policy.`
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Configuration for {@link createPermissionGate}. */
|
|
451
|
+
export interface PermissionGateConfig {
|
|
452
|
+
/**
|
|
453
|
+
* The ordered rule set, already concatenated across the project/global tiers.
|
|
454
|
+
* Deny rules are scanned first so a deny anywhere in the list wins.
|
|
455
|
+
*/
|
|
456
|
+
readonly rules: readonly PermissionRule[]
|
|
457
|
+
/** A live getter for the current permission mode (so a mode switch is honored). */
|
|
458
|
+
readonly mode: () => PermissionMode
|
|
459
|
+
/**
|
|
460
|
+
* The OPTIONAL host approval resolver consulted on an `ask` decision. Absent on
|
|
461
|
+
* a non-interactive boot — then `ask` deterministically denies.
|
|
462
|
+
*/
|
|
463
|
+
readonly requestApproval?: ApprovalResolver
|
|
464
|
+
/**
|
|
465
|
+
* Append a session-scoped allow rule when the host returns `allow-always`. The
|
|
466
|
+
* boot layer owns the mutable rule list (the SAME array instance `rules` refers
|
|
467
|
+
* to) and passes a push here, so the appended rule is visible to every
|
|
468
|
+
* subsequent gate consultation within the session — and to every other gate
|
|
469
|
+
* built over the same list (sub-agent gates included). Session-scoped only:
|
|
470
|
+
* the rule is never persisted to settings.
|
|
471
|
+
*/
|
|
472
|
+
readonly appendAllowRule?: (rule: PermissionRule) => void
|
|
473
|
+
/**
|
|
474
|
+
* Tool names (beyond {@link READ_ONLY_TOOL_NAMES}) to treat as read-only —
|
|
475
|
+
* derived from the deck's `readOnly: true` tool flags so MCP/custom read-only
|
|
476
|
+
* tools also auto-allow.
|
|
477
|
+
*/
|
|
478
|
+
readonly readOnlyToolNames?: ReadonlySet<string>
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Build a framework {@link CanUseToolFn} from the rule engine + the current mode.
|
|
483
|
+
*
|
|
484
|
+
* The returned gate:
|
|
485
|
+
* - resolves the behavior via {@link resolveRuleDecision},
|
|
486
|
+
* - on `allow` proceeds (the framework keeps the validated args),
|
|
487
|
+
* - on `deny` short-circuits with a clear message (the framework turns it into
|
|
488
|
+
* an `isError` tool result so the model sees why),
|
|
489
|
+
* - on `ask` awaits {@link PermissionGateConfig.requestApproval} when present —
|
|
490
|
+
* `allow-once` proceeds, `allow-always` proceeds and appends a session allow
|
|
491
|
+
* rule, `deny` blocks — and DENIES when no resolver is wired (the safe
|
|
492
|
+
* non-interactive default).
|
|
493
|
+
*
|
|
494
|
+
* The result is assignable to `indusagi/agent`'s `CanUseToolFn`.
|
|
495
|
+
*/
|
|
496
|
+
export function createPermissionGate(
|
|
497
|
+
config: PermissionGateConfig,
|
|
498
|
+
): CanUseToolFn {
|
|
499
|
+
return async (toolName, input, opts) => {
|
|
500
|
+
if (isBashTool(toolName)) {
|
|
501
|
+
const command = ruleSubject(toolName, input)
|
|
502
|
+
if (command.length > 0) {
|
|
503
|
+
const reason = evaluateCatastrophic(command)
|
|
504
|
+
if (reason !== void 0) {
|
|
505
|
+
return { behavior: "deny", message: reason }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const behavior = resolveRuleDecision(
|
|
510
|
+
toolName,
|
|
511
|
+
input,
|
|
512
|
+
config.rules,
|
|
513
|
+
config.mode(),
|
|
514
|
+
config.readOnlyToolNames,
|
|
515
|
+
)
|
|
516
|
+
if (behavior === "allow") {
|
|
517
|
+
return { behavior: "allow" }
|
|
518
|
+
}
|
|
519
|
+
if (behavior === "deny") {
|
|
520
|
+
return { behavior: "deny", message: blockedMessage(toolName) }
|
|
521
|
+
}
|
|
522
|
+
const resolver = config.requestApproval
|
|
523
|
+
if (resolver === void 0) {
|
|
524
|
+
return { behavior: "deny", message: noResolverMessage(toolName) }
|
|
525
|
+
}
|
|
526
|
+
let choice: ApprovalOutcome
|
|
527
|
+
try {
|
|
528
|
+
choice = await resolver(toolName, input, opts)
|
|
529
|
+
} catch {
|
|
530
|
+
choice = "deny"
|
|
531
|
+
}
|
|
532
|
+
if (choice === "allow-once" || choice === "allow-always") {
|
|
533
|
+
if (choice === "allow-always" && config.appendAllowRule) {
|
|
534
|
+
for (const raw of allowAlwaysRuleStrings(toolName, input)) {
|
|
535
|
+
config.appendAllowRule(makeRule("allow", raw))
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return { behavior: "allow" }
|
|
539
|
+
}
|
|
540
|
+
if (choice === "unavailable") {
|
|
541
|
+
return { behavior: "deny", message: noResolverMessage(toolName) }
|
|
542
|
+
}
|
|
543
|
+
return { behavior: "deny", message: declinedMessage(toolName) }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The policy bundle a parent session hands to a sub-agent runner. The sub-agent
|
|
549
|
+
* gate is constructed from these fields; see {@link createSubagentPermissionGate}.
|
|
550
|
+
*/
|
|
551
|
+
export interface SessionPermissionPolicy {
|
|
552
|
+
readonly rules: readonly PermissionRule[]
|
|
553
|
+
readonly mode: () => PermissionMode
|
|
554
|
+
readonly requestApproval?: ApprovalResolver
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Build a sub-agent-scoped permission gate from the parent's policy bundle.
|
|
559
|
+
*
|
|
560
|
+
* The returned {@link CanUseToolFn} delegates to {@link createPermissionGate}
|
|
561
|
+
* using the parent's rules and live mode getter. Tool names not present in
|
|
562
|
+
* `tools` are denied — a sub-agent can only act with the tool surface it was
|
|
563
|
+
* given. This prevents a delegate from invoking capabilities outside its
|
|
564
|
+
* intended scope.
|
|
565
|
+
*
|
|
566
|
+
* @param policy the parent's permission policy (rules + mode getter)
|
|
567
|
+
* @param tools the tools the sub-agent has been granted access to
|
|
568
|
+
*/
|
|
569
|
+
export function createSubagentPermissionGate(
|
|
570
|
+
policy: SessionPermissionPolicy,
|
|
571
|
+
tools: readonly { name: string }[],
|
|
572
|
+
): CanUseToolFn {
|
|
573
|
+
const allowed = new Set<string>(tools.map((t) => t.name))
|
|
574
|
+
const gate = createPermissionGate({
|
|
575
|
+
rules: policy.rules,
|
|
576
|
+
mode: policy.mode,
|
|
577
|
+
...policy.requestApproval !== undefined
|
|
578
|
+
? { requestApproval: policy.requestApproval }
|
|
579
|
+
: {},
|
|
580
|
+
readOnlyToolNames: READ_ONLY_TOOL_NAMES,
|
|
581
|
+
})
|
|
582
|
+
return async (toolName, input, opts) => {
|
|
583
|
+
if (!allowed.has(toolName)) {
|
|
584
|
+
return { behavior: "deny", message: `Sub-agent is not allowed to use tool "${toolName}".` }
|
|
585
|
+
}
|
|
586
|
+
return gate(toolName, input, opts)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const QUOTA_MARKER = "quota_exceeded";
|
|
2
|
+
|
|
3
|
+
function collectErrorSignals(error: unknown): { statuses: number[]; text: string } {
|
|
4
|
+
const statuses: number[] = [];
|
|
5
|
+
const parts: string[] = [];
|
|
6
|
+
const seen = new Set<object>();
|
|
7
|
+
const visit = (value: unknown, depth: number): void => {
|
|
8
|
+
if (value == null || depth > 6) return;
|
|
9
|
+
if (typeof value === "string") { parts.push(value); return; }
|
|
10
|
+
if (typeof value === "number") { statuses.push(value); return; }
|
|
11
|
+
if (typeof value !== "object" || seen.has(value)) return;
|
|
12
|
+
seen.add(value);
|
|
13
|
+
const object = value as Record<string, unknown>;
|
|
14
|
+
for (const key of ["status", "statusCode", "code"]) {
|
|
15
|
+
const raw = object[key];
|
|
16
|
+
if (typeof raw === "number") statuses.push(raw);
|
|
17
|
+
else if (typeof raw === "string" && /^\d+$/.test(raw)) statuses.push(Number(raw));
|
|
18
|
+
}
|
|
19
|
+
if (typeof object.message === "string") parts.push(object.message);
|
|
20
|
+
visit(object.cause, depth + 1);
|
|
21
|
+
visit(object.error, depth + 1);
|
|
22
|
+
visit(object.response, depth + 1);
|
|
23
|
+
visit(object.body, depth + 1);
|
|
24
|
+
visit(object.data, depth + 1);
|
|
25
|
+
};
|
|
26
|
+
visit(error, 0);
|
|
27
|
+
try { parts.push(JSON.stringify(error)); } catch { /* ignore circular data */ }
|
|
28
|
+
if (error instanceof Error && typeof error.stack === "string") parts.push(error.stack);
|
|
29
|
+
return { statuses, text: parts.join(" \u0001 ").toLowerCase() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isQuotaFault(error: unknown): boolean {
|
|
33
|
+
const { statuses, text } = collectErrorSignals(error);
|
|
34
|
+
if (!text.includes(QUOTA_MARKER)) return false;
|
|
35
|
+
return statuses.includes(429) || text.includes(QUOTA_MARKER);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseUsedLimit(text: string): { used: number; limit: number } | undefined {
|
|
39
|
+
const used = text.match(/"used"\s*:\s*(\d+)/);
|
|
40
|
+
const limit = text.match(/"limit"\s*:\s*(\d+)/);
|
|
41
|
+
return used && limit ? { used: Number(used[1]), limit: Number(limit[1]) } : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function quotaErrorMessage(error: unknown): string | undefined {
|
|
45
|
+
if (!isQuotaFault(error)) return undefined;
|
|
46
|
+
const numbers = parseUsedLimit(collectErrorSignals(error).text);
|
|
47
|
+
const usage = numbers ? ` (${numbers.used}/${numbers.limit} tokens this month)` : "";
|
|
48
|
+
return `You've hit your indus usage limit${usage}. Add your own API key with \`indus signin <provider>\`, or upgrade your plan.`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SessionSignal, SignalHandler } from "../contract.js";
|
|
2
|
+
|
|
3
|
+
export interface SignalHubOptions {
|
|
4
|
+
readonly onHandlerError?: (error: unknown, signal: SessionSignal) => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class SignalHub {
|
|
8
|
+
private readonly handlers = new Set<SignalHandler>();
|
|
9
|
+
private readonly onHandlerError?: SignalHubOptions["onHandlerError"];
|
|
10
|
+
|
|
11
|
+
constructor(options: SignalHubOptions = {}) {
|
|
12
|
+
this.onHandlerError = options.onHandlerError;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
subscribe(handler: SignalHandler): () => void {
|
|
16
|
+
this.handlers.add(handler);
|
|
17
|
+
let active = true;
|
|
18
|
+
return () => {
|
|
19
|
+
if (!active) return;
|
|
20
|
+
active = false;
|
|
21
|
+
this.handlers.delete(handler);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
emit(signal: SessionSignal): void {
|
|
26
|
+
for (const handler of [...this.handlers]) {
|
|
27
|
+
try {
|
|
28
|
+
handler(signal);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
this.report(error, signal);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get size(): number { return this.handlers.size; }
|
|
36
|
+
clear(): void { this.handlers.clear(); }
|
|
37
|
+
|
|
38
|
+
private report(error: unknown, signal: SessionSignal): void {
|
|
39
|
+
if (!this.onHandlerError) return;
|
|
40
|
+
try {
|
|
41
|
+
this.onHandlerError(error, signal);
|
|
42
|
+
} catch {
|
|
43
|
+
// The error sink is isolated like ordinary subscribers.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|