indusagi-coding-agent 0.1.62 → 0.2.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 (62) hide show
  1. package/dist/entry.js +5395 -1509
  2. package/dist/types/boot/contract.d.ts +2 -0
  3. package/dist/types/boot/runners/addon-wiring.d.ts +103 -0
  4. package/dist/types/boot/runners/addon-wiring.test.d.ts +19 -0
  5. package/dist/types/boot/runners/checkpoint.d.ts +133 -0
  6. package/dist/types/boot/runners/checkpoint.test.d.ts +12 -0
  7. package/dist/types/boot/runners/delegate-runner.d.ts +83 -0
  8. package/dist/types/boot/runners/delegate-runner.test.d.ts +13 -0
  9. package/dist/types/boot/runners/memdir.d.ts +103 -0
  10. package/dist/types/boot/runners/memdir.test.d.ts +12 -0
  11. package/dist/types/boot/runners/read-state.d.ts +82 -0
  12. package/dist/types/boot/runners/read-state.test.d.ts +10 -0
  13. package/dist/types/boot/runners/session.d.ts +37 -2
  14. package/dist/types/boot/runners/session.test.d.ts +10 -0
  15. package/dist/types/briefing/context-docs.d.ts +38 -0
  16. package/dist/types/briefing/context-docs.test.d.ts +18 -0
  17. package/dist/types/briefing/index.d.ts +2 -0
  18. package/dist/types/capability-deck/cards/index.d.ts +6 -0
  19. package/dist/types/capability-deck/cards/memory-card.d.ts +9 -10
  20. package/dist/types/capability-deck/cards/plan-file.d.ts +56 -0
  21. package/dist/types/capability-deck/cards/plan-tools.d.ts +97 -0
  22. package/dist/types/capability-deck/cards/plan-tools.test.d.ts +9 -0
  23. package/dist/types/capability-deck/checkpoint.int.test.d.ts +25 -0
  24. package/dist/types/capability-deck/index.d.ts +1 -1
  25. package/dist/types/capability-deck/read-edit-gate.int.test.d.ts +21 -0
  26. package/dist/types/conductor/bash-guard.d.ts +106 -0
  27. package/dist/types/conductor/bash-guard.test.d.ts +17 -0
  28. package/dist/types/conductor/conductor.d.ts +37 -6
  29. package/dist/types/conductor/contract.d.ts +214 -2
  30. package/dist/types/conductor/diagnostics.d.ts +183 -0
  31. package/dist/types/conductor/diagnostics.test.d.ts +10 -0
  32. package/dist/types/conductor/index.d.ts +4 -1
  33. package/dist/types/conductor/permission-gate.integration.test.d.ts +22 -0
  34. package/dist/types/conductor/permission-wiring.test.d.ts +14 -0
  35. package/dist/types/conductor/permissions.d.ts +217 -0
  36. package/dist/types/conductor/permissions.test.d.ts +12 -0
  37. package/dist/types/conductor/plan-mode.integration.test.d.ts +23 -0
  38. package/dist/types/conductor/post-edit-diagnostics.test.d.ts +13 -0
  39. package/dist/types/conductor/transcript-store/serialize.test.d.ts +10 -0
  40. package/dist/types/conductor/transcript-store/store.d.ts +18 -0
  41. package/dist/types/console/components/StatusBar.d.ts +14 -3
  42. package/dist/types/console/components/WorkingIndicator.d.ts +44 -0
  43. package/dist/types/console/components/WorkingIndicator.test.d.ts +9 -0
  44. package/dist/types/console/contract.d.ts +2 -1
  45. package/dist/types/console/input/keymap.d.ts +10 -1
  46. package/dist/types/console/overlays/approval-queue.d.ts +71 -0
  47. package/dist/types/console/overlays/approval.d.ts +104 -0
  48. package/dist/types/console/overlays/approval.test.d.ts +17 -0
  49. package/dist/types/console/overlays/host.d.ts +4 -3
  50. package/dist/types/console/overlays/index.d.ts +2 -0
  51. package/dist/types/launch/contract.d.ts +2 -0
  52. package/dist/types/launch/index.d.ts +1 -1
  53. package/dist/types/launch/oauth.d.ts +13 -0
  54. package/dist/types/settings/contract.d.ts +47 -0
  55. package/dist/types/settings/index.d.ts +2 -2
  56. package/dist/types/window-budget/condenser.d.ts +15 -1
  57. package/dist/types/window-budget/index.d.ts +3 -1
  58. package/dist/types/window-budget/microcompact.d.ts +68 -0
  59. package/dist/types/window-budget/microcompact.test.d.ts +16 -0
  60. package/dist/types/window-budget/rehydrate.d.ts +56 -0
  61. package/dist/types/workspace/brand.d.ts +1 -1
  62. package/package.json +2 -2
@@ -0,0 +1,217 @@
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
+ import type { PermissionMode } from "../settings";
30
+ /** Re-exported so consumers of the engine get the mode vocabulary in one import. */
31
+ export type { PermissionMode };
32
+ /** The verdict the rule engine renders for a single tool call. */
33
+ export type PermissionBehavior = "allow" | "ask" | "deny";
34
+ /**
35
+ * One parsed permission rule: a behavior plus the tool it targets, with an
36
+ * optional argument specifier (`ruleContent`) that further narrows the match.
37
+ */
38
+ export interface PermissionRule {
39
+ /** What happens when this rule matches a tool call. */
40
+ readonly ruleBehavior: PermissionBehavior;
41
+ /** The tool (and optional argument specifier) this rule selects. */
42
+ readonly ruleValue: {
43
+ /** The canonical tool name, e.g. `"Bash"` / `"bash"` (matched case-insensitively). */
44
+ readonly toolName: string;
45
+ /** An optional argument specifier, e.g. `"npm run test:*"`. Absent = bare match. */
46
+ readonly ruleContent?: string;
47
+ };
48
+ }
49
+ /**
50
+ * The framework's tool-permission verdict. Mirrors `indusagi/agent`'s
51
+ * `PermissionDecision`; re-declared here so the product engine never imports the
52
+ * framework just for a structural type (the gate is assignable to the
53
+ * framework's `CanUseToolFn` because the shapes coincide).
54
+ */
55
+ export type PermissionDecision = {
56
+ behavior: "allow";
57
+ updatedInput?: unknown;
58
+ } | {
59
+ behavior: "deny";
60
+ message: string;
61
+ };
62
+ /**
63
+ * The hard permission gate the framework agent awaits before each tool executes.
64
+ * Structurally identical to `indusagi/agent`'s `CanUseToolFn`, so a value of this
65
+ * type is accepted by `new Agent({ canUseTool })` with no cast.
66
+ */
67
+ export type CanUseToolFn = (toolName: string, input: unknown, opts: {
68
+ signal?: AbortSignal;
69
+ }) => Promise<PermissionDecision>;
70
+ /**
71
+ * The outcome of the host approval prompt raised for an `ask` decision:
72
+ * - `allow-once` — run this single call, do not remember.
73
+ * - `allow-always` — run it and append a session allow rule for the tool.
74
+ * - `deny` — block this call.
75
+ */
76
+ export type ApprovalChoice = "allow-once" | "allow-always" | "deny";
77
+ /**
78
+ * The OPTIONAL host approval resolver. When present, an `ask` decision awaits it;
79
+ * the host (an interactive overlay) returns the user's choice. It MUST resolve to
80
+ * `"deny"` on abort so a cancelled turn never hangs on a pending prompt. When the
81
+ * resolver is absent (non-interactive boot / oneshot / link), an `ask` decision
82
+ * deterministically denies.
83
+ */
84
+ export type ApprovalResolver = (toolName: string, input: unknown, opts: {
85
+ signal?: AbortSignal;
86
+ }) => Promise<ApprovalChoice>;
87
+ /**
88
+ * Tool names known to only inspect state (never mutate). Mirrors the framework's
89
+ * `READ_ONLY_TOOL_NAMES`; matched case-insensitively. A tool the framework marks
90
+ * `readOnly: true` is also auto-allowed via {@link createPermissionGate}'s
91
+ * `readOnlyToolNames` option, so this set is the static fallback for callers that
92
+ * only know names.
93
+ */
94
+ export declare const READ_ONLY_TOOL_NAMES: ReadonlySet<string>;
95
+ /**
96
+ * Tool names that mutate the workspace by editing or writing files. Auto-allowed
97
+ * under `acceptEdits`, and (together with every other non-read-only tool) denied
98
+ * under `plan`.
99
+ */
100
+ export declare const EDIT_TOOL_NAMES: ReadonlySet<string>;
101
+ /** Whether `toolName` is a statically-known read-only tool. */
102
+ export declare function isReadOnlyToolName(toolName: string, extra?: ReadonlySet<string>): boolean;
103
+ /** Whether `toolName` is an edit/write tool (auto-allowed under `acceptEdits`). */
104
+ export declare function isEditToolName(toolName: string): boolean;
105
+ /**
106
+ * Parse an induscode-style rule string into its tool name and optional argument
107
+ * specifier.
108
+ *
109
+ * - `"Bash"` → `{ toolName: "Bash" }`
110
+ * - `"Bash(npm run test:*)"` → `{ toolName: "Bash", ruleContent: "npm run test:*" }`
111
+ * - `"mcp__server"` → `{ toolName: "mcp__server" }`
112
+ *
113
+ * Whitespace around the tool name is trimmed; an empty argument specifier
114
+ * (`"Bash()"`) is treated as a bare rule.
115
+ */
116
+ export declare function parseRule(raw: string): {
117
+ toolName: string;
118
+ ruleContent?: string;
119
+ };
120
+ /**
121
+ * Build a {@link PermissionRule} from a behavior and an induscode-style rule
122
+ * string. The single place a raw settings list entry becomes a typed rule.
123
+ */
124
+ export declare function makeRule(ruleBehavior: PermissionBehavior, raw: string): PermissionRule;
125
+ /**
126
+ * Whether a tool call matches a rule (the ANY-match semantics used for deny/ask).
127
+ *
128
+ * The tool name must match (case-insensitive), with an `mcp__server` rule also
129
+ * matching every `mcp__server__tool` under it (the induscode MCP-wildcard
130
+ * convention). A bare rule (no specifier) matches any arguments.
131
+ *
132
+ * When the rule carries an argument specifier:
133
+ * - for the **shell** tool the command is split into its constituent
134
+ * sub-commands and the specifier is tested against EACH — a match on ANY
135
+ * sub-command counts (so `Bash(rm:*)` denies `git status && rm x`);
136
+ * - for any other tool the specifier is tested against the single rendered
137
+ * subject (the command field, or a JSON encoding).
138
+ *
139
+ * The any-match semantics are correct for `deny` and `ask` (a single offending
140
+ * sub-command should trigger them). The `allow` branch needs ALL sub-commands
141
+ * covered instead — see {@link bashAllowCoversAll}.
142
+ */
143
+ export declare function toolMatchesRule(toolName: string, input: unknown, rule: PermissionRule): boolean;
144
+ /**
145
+ * Resolve the behavior for a single tool call against the rule set and mode.
146
+ *
147
+ * Precedence, highest first:
148
+ * 1. **deny rules** — any matching `deny` rule blocks, regardless of mode. Deny
149
+ * wins across every tier (the caller concatenates the tiers before passing
150
+ * the list in, and this scans all deny rules first).
151
+ * 2. **plan mode** — denies any mutating (non-read-only) tool outright.
152
+ * 3. **ask rules** — a matching `ask` rule forces a prompt (unless a deny
153
+ * already fired). Mode auto-allow does NOT override an explicit ask.
154
+ * 4. **bypass mode** — allows everything not already denied/asked.
155
+ * 5. **read-only auto-allow** — a known read-only tool allows in any mode.
156
+ * 6. **acceptEdits mode** — auto-allows edit/write tools.
157
+ * 7. **allow rules** — a matching `allow` rule (or, for the shell tool, the
158
+ * allow rule SET) allows.
159
+ * 8. **fallthrough** — `ask` (the safe default: prompt, then deny when no
160
+ * resolver is wired).
161
+ *
162
+ * The catastrophic-command blocklist sits ABOVE all of this (step 0): a shell
163
+ * command on the built-in blocklist (`rm -rf /`, fork bombs, `curl | sh`, …) is
164
+ * denied regardless of rules or mode — even `bypass` cannot run it. See
165
+ * {@link evaluateCatastrophic}.
166
+ *
167
+ * Shell handling threads through the per-sub-command matching: a compound
168
+ * command (`a && b; c | d`) is split into its constituent commands and each rule
169
+ * is evaluated against each — a deny/ask on ANY sub-command fires, while
170
+ * auto-allow requires the allow rule set to cover EVERY sub-command.
171
+ *
172
+ * Pure and total: no I/O, no async, deterministic for a given input.
173
+ */
174
+ export declare function resolveRuleDecision(toolName: string, input: unknown, rules: readonly PermissionRule[], mode: PermissionMode, readOnlyExtra?: ReadonlySet<string>): PermissionBehavior;
175
+ /** Configuration for {@link createPermissionGate}. */
176
+ export interface PermissionGateConfig {
177
+ /**
178
+ * The ordered rule set, already concatenated across the project/global tiers.
179
+ * Deny rules are scanned first so a deny anywhere in the list wins.
180
+ */
181
+ readonly rules: readonly PermissionRule[];
182
+ /** A live getter for the current permission mode (so a mode switch is honored). */
183
+ readonly mode: () => PermissionMode;
184
+ /**
185
+ * The OPTIONAL host approval resolver consulted on an `ask` decision. Absent on
186
+ * a non-interactive boot — then `ask` deterministically denies.
187
+ */
188
+ readonly requestApproval?: ApprovalResolver;
189
+ /**
190
+ * Append a session-scoped allow rule when the host returns `allow-always`. The
191
+ * conductor owns the mutable rule list and passes a setter here so the new rule
192
+ * is visible to subsequent calls within the session.
193
+ */
194
+ readonly appendAllowRule?: (rule: PermissionRule) => void;
195
+ /**
196
+ * Tool names (beyond {@link READ_ONLY_TOOL_NAMES}) to treat as read-only —
197
+ * derived from the deck's `readOnly: true` tool flags so MCP/custom read-only
198
+ * tools also auto-allow.
199
+ */
200
+ readonly readOnlyToolNames?: ReadonlySet<string>;
201
+ }
202
+ /**
203
+ * Build a framework {@link CanUseToolFn} from the rule engine + the current mode.
204
+ *
205
+ * The returned gate:
206
+ * - resolves the behavior via {@link resolveRuleDecision},
207
+ * - on `allow` proceeds (the framework keeps the validated args),
208
+ * - on `deny` short-circuits with a clear message (the framework turns it into
209
+ * an `isError` tool result so the model sees why),
210
+ * - on `ask` awaits {@link PermissionGateConfig.requestApproval} when present —
211
+ * `allow-once` proceeds, `allow-always` proceeds and appends a session allow
212
+ * rule, `deny` blocks — and DENIES when no resolver is wired (the safe
213
+ * non-interactive default).
214
+ *
215
+ * The result is assignable to `indusagi/agent`'s `CanUseToolFn`.
216
+ */
217
+ export declare function createPermissionGate(config: PermissionGateConfig): CanUseToolFn;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Permission rule engine + gate — unit tests.
3
+ *
4
+ * Exhaustively pins the pure decision ({@link resolveRuleDecision}) precedence
5
+ * (deny > plan-deny > ask > bypass > read-only > acceptEdits > allow > ask
6
+ * fallthrough), the induscode rule parsing/matching ({@link parseRule},
7
+ * {@link toolMatchesRule}, including argument specifiers and the `mcp__` wildcard),
8
+ * the mode behaviors, and the {@link createPermissionGate} `ask` routing — a host
9
+ * resolver returns allow-once / allow-always (which appends a session allow rule)
10
+ * / deny, and an ABSENT resolver denies. No framework, no network.
11
+ */
12
+ export {};
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Plan-mode round-trip — integration across the gate + the conductor handshake.
3
+ *
4
+ * Two proofs, against the REAL wired pieces (the rule engine's gate + the live
5
+ * conductor), not mocks of an unwired module:
6
+ *
7
+ * 1. **The gate denies edits in plan mode.** A `createPermissionGate` whose mode
8
+ * getter reads the conductor's LIVE `permissionMode()` allows an edit in
9
+ * `default` mode, denies it once the conductor enters plan mode
10
+ * (`togglePlanMode(true)` / `setPermissionMode('plan')`), and allows it again
11
+ * once an approved exit restores the prior mode. This is the exact gate the
12
+ * conductor threads into the framework `Agent`, so the deny lands where tools
13
+ * execute.
14
+ *
15
+ * 2. **ExitPlanMode approval restores the mode, persists the plan, and resumes.**
16
+ * A scripted fake agent emits a `tool_execution_end` carrying the
17
+ * `{ exitPlan: true, plan }` detail the conductor intercepts. With an approval
18
+ * resolver installed that approves, a turn run while in plan mode: restores
19
+ * the captured pre-plan mode, writes the plan to a file under `plansDir`, and
20
+ * enqueues the approved plan as a follow-up so the model proceeds from it. A
21
+ * rejecting resolver keeps the session in plan mode and writes nothing.
22
+ */
23
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Conductor-integration tests for the post-edit live-diagnostics seam.
3
+ *
4
+ * Drives a scripted {@link AgentLike} that emits a `tool_execution_start` for an
5
+ * `edit`/`write` tool during `prompt`, with an injected {@link DiagnosticsEngine}
6
+ * over a scripted in-memory runner (no real tsc spawn). Verifies:
7
+ * - a turn that introduces a NEW diagnostic enqueues the summary follow-up;
8
+ * - a clean edit (no new diagnostics) enqueues nothing;
9
+ * - re-editing a file re-surfaces a previously-cleared diagnostic;
10
+ * - a non-TS edit never triggers the checker;
11
+ * - the injected follow-up turn (which edits nothing) does not loop.
12
+ */
13
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Transcript serialization — head usage round-trip.
3
+ *
4
+ * Focused coverage for the cost-cmd durability seam: the head line now carries
5
+ * the session's cumulative {@link Usage}, and {@link encodeHead} →
6
+ * {@link parseSessionText} must round-trip it faithfully so resume can restore the
7
+ * running token/cost total instead of seeding zero. Heads without usage (legacy
8
+ * files, fresh sessions) must still parse cleanly with `usage` absent.
9
+ */
10
+ export {};
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import type { AgentMessage, SessionHead, TranscriptEntry, TranscriptRole } from "../contract";
29
29
  import { TRANSCRIPT_SCHEMA } from "../contract";
30
+ import type { Usage } from "indusagi/ai";
30
31
  /**
31
32
  * Where a transcript's NDJSON log lives. The store appends lines and, on branch,
32
33
  * rewrites the whole file; a backend supplies those three primitives plus a way
@@ -101,6 +102,13 @@ export declare class TranscriptStore {
101
102
  get sessionId(): string;
102
103
  /** The current head (session id + active leaf). */
103
104
  get head(): SessionHead;
105
+ /**
106
+ * The cumulative session usage last persisted onto the head, or `undefined`
107
+ * when none has been written (a fresh session, or a legacy file without a
108
+ * usage-bearing head). `resume` reads this to restore the running token/cost
109
+ * total instead of seeding zero.
110
+ */
111
+ get usage(): Usage | undefined;
104
112
  /** Number of nodes currently in the transcript. */
105
113
  get size(): number;
106
114
  /** Backend location this session persists to (for diagnostics/UI). */
@@ -116,6 +124,16 @@ export declare class TranscriptStore {
116
124
  * a settled turn.
117
125
  */
118
126
  append(content: AgentMessage, role?: TranscriptRole, meta?: Readonly<Record<string, unknown>>): Promise<TranscriptEntry>;
127
+ /**
128
+ * Persist the session's cumulative usage onto the head and rewrite the backing
129
+ * file so the running token/cost total survives a reload. Distinct from
130
+ * {@link append}, which only appends an entry line and never rewrites the head:
131
+ * usage durability MUST flow through here so the re-emitted head line carries
132
+ * the latest tally. Leaves the node tree and active leaf untouched.
133
+ *
134
+ * @param usage the cumulative usage to pin on the head
135
+ */
136
+ persistUsage(usage: Usage): Promise<void>;
119
137
  /**
120
138
  * Repoint the head at an earlier node, forking a branch. The next {@link append}
121
139
  * becomes a child of `id`; existing nodes are untouched. Rewrites the head line.
@@ -10,6 +10,7 @@
10
10
  * (Renamed surface: the console's status strip.)
11
11
  */
12
12
  import { type InkThemeAdapter, type SessionSnapshot, type StatusMessage } from "indusagi/react-ink";
13
+ import type { PermissionMode } from "../../settings";
13
14
  /** What the {@link StatusBar} renders. */
14
15
  export interface StatusBarProps {
15
16
  /** The framework adapter that turns token roles into terminal colours. */
@@ -22,10 +23,20 @@ export interface StatusBarProps {
22
23
  readonly providerCount: number;
23
24
  /** The transient status toast, when one is showing. */
24
25
  readonly status?: StatusMessage;
26
+ /**
27
+ * The active permission mode. When supplied, a small badge is rendered above the
28
+ * footer (parallel to the transient status toast) so the user can see at a glance
29
+ * which of the four cycled modes is live: `default` (tools prompt as usual),
30
+ * `acceptEdits` (edits auto-approved), `plan` (mutating tools blocked), or
31
+ * `bypass` (every tool auto-allowed — drawn in an alert colour). The
32
+ * `bypassPermissions` alias renders as `bypass`. Omitted renders nothing extra.
33
+ */
34
+ readonly permissionMode?: PermissionMode;
25
35
  }
26
36
  /**
27
- * Render the toast row above the footer strip.
37
+ * Render the toast row above the footer strip, plus an optional permission-mode
38
+ * badge when a non-default mode is active.
28
39
  *
29
- * @param props the snapshot + status to display
40
+ * @param props the snapshot + status (+ optional permission mode) to display
30
41
  */
31
- export declare function StatusBar({ theme, snapshot, branch, providerCount, status, }: StatusBarProps): JSX.Element;
42
+ export declare function StatusBar({ theme, snapshot, branch, providerCount, status, permissionMode, }: StatusBarProps): JSX.Element;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * WorkingIndicator — the live "agent is working" affordance.
3
+ *
4
+ * While a turn is in flight (`busy`) this draws an animated braille spinner, a
5
+ * label, an elapsed-seconds clock, and an "esc to interrupt" hint. The label is
6
+ * a rotating whimsical word (changing every few seconds, induscode-style) for
7
+ * the general thinking/streaming case, and a concrete phase label while the
8
+ * agent runs tools or compacts context.
9
+ *
10
+ * It renders nothing when idle, and self-drives its animation via a short
11
+ * interval that ticks ONLY while busy (torn down the moment `busy` clears), so an
12
+ * idle console does no spinner work. It is a sibling row above the
13
+ * {@link Composer} and does NOT consume the sticky status toast — so a previous
14
+ * toast (e.g. the permission-mode line) can never mask that the agent is working.
15
+ */
16
+ import type { InkThemeAdapter } from "indusagi/react-ink";
17
+ /**
18
+ * The rotating "thinking" words shown while the agent reasons/streams. Whimsical
19
+ * present participles, in the spirit of induscode's spinner — purely cosmetic.
20
+ */
21
+ export declare const WORKING_WORDS: readonly ["Cogitating", "Pondering", "Noodling", "Simmering", "Brewing", "Percolating", "Conjuring", "Tinkering", "Wrangling", "Synthesizing", "Computing", "Musing", "Scheming", "Hatching", "Whirring", "Crunching", "Spelunking", "Untangling", "Marinating", "Ruminating", "Architecting", "Finagling", "Concocting", "Deliberating", "Orchestrating", "Calibrating", "Channeling", "Vibing", "Forging", "Assembling"];
22
+ /** Pick a working word by (wrapping, non-negative) index. Exported for testing. */
23
+ export declare function pickWord(index: number): string;
24
+ /** What the {@link WorkingIndicator} renders. */
25
+ export interface WorkingIndicatorProps {
26
+ /** The framework adapter that turns token roles into terminal colours. */
27
+ readonly theme: InkThemeAdapter;
28
+ /** Whether a turn is in flight. When false the indicator renders nothing. */
29
+ readonly busy: boolean;
30
+ /** The conductor snapshot phase, used to pick a concrete label when relevant. */
31
+ readonly phase?: string;
32
+ }
33
+ /**
34
+ * The concrete label for the phases where a specific word is more useful than a
35
+ * whimsical one. Returns null for the general thinking/streaming case (the caller
36
+ * then shows a rotating {@link WORKING_WORDS} word instead). Exported for testing.
37
+ */
38
+ export declare function phaseLabel(phase: string | undefined): string | null;
39
+ /**
40
+ * Render the animated working row, or nothing when idle.
41
+ *
42
+ * @param props the theme, the busy flag, and the snapshot phase
43
+ */
44
+ export declare function WorkingIndicator({ theme, busy, phase, }: WorkingIndicatorProps): JSX.Element | null;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * WorkingIndicator — unit tests for the pure label/word logic.
3
+ *
4
+ * The animated render is driven by an Ink-only timer that is impractical to
5
+ * assert headlessly; the load-bearing pure logic is {@link phaseLabel} (concrete
6
+ * labels for tool/compaction phases) and {@link pickWord} (the rotating whimsical
7
+ * word for the general thinking case).
8
+ */
9
+ export {};
@@ -244,8 +244,9 @@ export interface ViewRow {
244
244
  * - `signOut` — the provider sign-out confirmation.
245
245
  * - `oauth` — the in-flight OAuth device/redirect flow.
246
246
  * - `plugin` — a plugin-supplied select/confirm/input/custom overlay.
247
+ * - `approval` — a pending tool-permission prompt (allow once / always / deny).
247
248
  */
248
- export type ModalKind = "none" | "settings" | "models" | "scopedModels" | "theme" | "sessions" | "tree" | "userTurns" | "signIn" | "signOut" | "oauth" | "plugin";
249
+ export type ModalKind = "none" | "settings" | "models" | "scopedModels" | "theme" | "sessions" | "tree" | "userTurns" | "signIn" | "signOut" | "oauth" | "plugin" | "approval";
249
250
  /**
250
251
  * The active modal plus any opaque payload the matching dialog needs.
251
252
  *
@@ -84,6 +84,15 @@ export interface KeyChord {
84
84
  * Model / reasoning
85
85
  * - `model:cycleThinking` — advance the reasoning-effort ladder one step.
86
86
  *
87
+ * Permission mode
88
+ * - `mode:cyclePermission` — cycle through ALL permission modes (Shift+Tab),
89
+ * advancing one step per press: default → acceptEdits → plan → bypass →
90
+ * (wrap) default. In `plan` mode every mutating tool is blocked; in `bypass`
91
+ * every tool is auto-allowed.
92
+ * - `mode:togglePlan` — toggle read-only plan mode on/off. Drives `/plan`; no
93
+ * longer bound to Shift+Tab (which now cycles all modes). In plan mode every
94
+ * mutating tool is blocked at the permission gate.
95
+ *
87
96
  * Queue
88
97
  * - `queue:dequeue` — pull the most recently queued input back into the
89
98
  * composer so it can be edited or dropped.
@@ -106,7 +115,7 @@ export interface KeyChord {
106
115
  * Inert
107
116
  * - `none` — the keystroke carries no console meaning.
108
117
  */
109
- export type ConsoleVerb = "text:type" | "text:newline" | "edit:erasePrev" | "edit:eraseNext" | "edit:clearLine" | "nav:left" | "nav:right" | "nav:home" | "nav:end" | "nav:up" | "nav:down" | "flow:submit" | "flow:dismiss" | "flow:accept" | "flow:interrupt" | "flow:suspend" | "flow:cycleModel" | "model:cycleThinking" | "queue:dequeue" | "input:pasteImage" | "input:externalEditor" | "overlay:open" | "view:expandTools" | "view:toggleReasoning" | "none";
118
+ export type ConsoleVerb = "text:type" | "text:newline" | "edit:erasePrev" | "edit:eraseNext" | "edit:clearLine" | "nav:left" | "nav:right" | "nav:home" | "nav:end" | "nav:up" | "nav:down" | "flow:submit" | "flow:dismiss" | "flow:accept" | "flow:interrupt" | "flow:suspend" | "flow:cycleModel" | "model:cycleThinking" | "mode:cyclePermission" | "mode:togglePlan" | "queue:dequeue" | "input:pasteImage" | "input:externalEditor" | "overlay:open" | "view:expandTools" | "view:toggleReasoning" | "none";
110
119
  /**
111
120
  * A classified keystroke: a {@link ConsoleVerb} plus the small payload a verb
112
121
  * may carry — the literal text for a `text:type` insert, or the target overlay
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Approval queue — the headless, pure serializer behind the approval overlay.
3
+ *
4
+ * The permission gate may reach an `ask` decision while another approval prompt is
5
+ * already on screen (e.g. a turn that batches several tool calls). The UI shows ONE
6
+ * prompt at a time, so the pending requests are held in an ordered queue: each
7
+ * `enqueue` parks a request, the HEAD is the prompt currently shown, and `settle`
8
+ * resolves the head's parked promise then advances to the next. This module is a
9
+ * pure reducer over an immutable {@link ApprovalQueueState} plus a couple of tiny
10
+ * helpers — no React, no Ink, no timers — so the queue logic is unit-testable in
11
+ * isolation (the overlay component only renders the head and dispatches settles).
12
+ *
13
+ * Each queued entry carries the parked promise's `resolve` (the gate awaits it) and
14
+ * the request itself. Settling resolves with an {@link ApprovalChoice}; an
15
+ * abort/clear settles every outstanding entry with `"deny"` so a cancelled turn
16
+ * never hangs on a pending prompt.
17
+ */
18
+ import type { ApprovalChoice } from "../../conductor";
19
+ import type { ApprovalRequest } from "./approval";
20
+ /** One parked approval request awaiting the user's choice. */
21
+ export interface ApprovalEntry {
22
+ /** A stable id, unique within the queue's lifetime, used to settle the right one. */
23
+ readonly id: string;
24
+ /** The pending request the prompt renders. */
25
+ readonly request: ApprovalRequest;
26
+ /** Fulfil the gate's parked promise with the user's choice. Called exactly once. */
27
+ readonly resolve: (choice: ApprovalChoice) => void;
28
+ }
29
+ /**
30
+ * The immutable queue state. The array is ordered oldest-first; the head (index 0)
31
+ * is the request currently prompted. An empty queue means no prompt is showing.
32
+ */
33
+ export interface ApprovalQueueState {
34
+ /** The parked entries, oldest first; `[0]` is the active prompt. */
35
+ readonly entries: readonly ApprovalEntry[];
36
+ }
37
+ /** The empty queue — no approval prompt pending. */
38
+ export declare const EMPTY_APPROVAL_QUEUE: ApprovalQueueState;
39
+ /**
40
+ * The closed action union the queue reducer folds:
41
+ * - `enqueue` — park a new request at the tail.
42
+ * - `settle` — resolve the entry matching `id` with `choice` and drop it.
43
+ * - `clear` — drop EVERY entry (the caller resolves each with `"deny"`).
44
+ */
45
+ export type ApprovalQueueEvent = {
46
+ readonly type: "enqueue";
47
+ readonly entry: ApprovalEntry;
48
+ } | {
49
+ readonly type: "settle";
50
+ readonly id: string;
51
+ } | {
52
+ readonly type: "clear";
53
+ };
54
+ /**
55
+ * Fold one {@link ApprovalQueueEvent} over the queue, returning a fresh state.
56
+ *
57
+ * Pure and total: it never resolves a promise itself (the caller does that around
58
+ * a `settle`/`clear`), it only mutates the ordered entry list. `enqueue` appends;
59
+ * `settle` removes the entry with the matching `id` (a no-op when absent, e.g. a
60
+ * double-settle); `clear` empties the queue.
61
+ *
62
+ * @param state the prior queue state
63
+ * @param event the action to apply
64
+ */
65
+ export declare function approvalQueueReducer(state: ApprovalQueueState, event: ApprovalQueueEvent): ApprovalQueueState;
66
+ /**
67
+ * The active prompt — the head entry — or `undefined` when the queue is empty.
68
+ *
69
+ * @param state the queue state
70
+ */
71
+ export declare function activeApproval(state: ApprovalQueueState): ApprovalEntry | undefined;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Approval overlay — the interactive tool-permission prompt.
3
+ *
4
+ * This group owns the single modal kind raised when the permission gate reaches an
5
+ * `ask` decision for a tool call: it shows the pending request (tool name, a short
6
+ * rendering of the arguments, and any suggested allow-rule) and lets the user pick
7
+ * one of three outcomes — allow this one call, allow it for the rest of the session
8
+ * (which the gate turns into a session allow-rule), or deny it. It is mounted
9
+ * unconditionally by {@link import("./host").OverlayHost}, which routes by
10
+ * {@link ModalState.kind}; this component renders a dialog body only for its own
11
+ * `approval` kind and is otherwise inert.
12
+ *
13
+ * The wiring is a parked promise: when the React layer builds the conductor's
14
+ * approval resolver it returns a promise and stashes the request (plus the
15
+ * promise's `resolve`) under {@link ModalState.payload}. This overlay reads that
16
+ * payload, renders the choices, and on a pick calls `payload.resolve(choice)` then
17
+ * closes the modal — handing control back to the gate, which proceeds or denies. An
18
+ * Esc (the dialog's own close) resolves `"deny"` so a dismissed prompt never hangs
19
+ * the in-flight turn. The actual select UI reuses the framework `ThemeDialog`
20
+ * (a labelled, navigable pick-from-list with descriptions), so the overlay needs no
21
+ * bespoke key handling.
22
+ *
23
+ * React/Ink are obtained the same way the other overlays do (default React export
24
+ * from `indusagi/react-host`, `Box`/`Text` from `indusagi/react-host/ink`).
25
+ */
26
+ import { type ThemeDialogItem } from "indusagi/react-ink";
27
+ import type { ApprovalChoice } from "../../conductor";
28
+ import type { OverlayGroupProps } from "./pickers";
29
+ /**
30
+ * The pending tool-permission request a prompt is raised for. Mirrors the
31
+ * arguments the conductor's approval resolver is handed for an `ask` decision.
32
+ */
33
+ export interface ApprovalRequest {
34
+ /** The tool the model asked to run (e.g. `"Bash"`, `"Edit"`). */
35
+ readonly toolName: string;
36
+ /** The validated arguments the tool would run with. */
37
+ readonly input: unknown;
38
+ /**
39
+ * Optional human-readable allow-rule suggestions the prompt surfaces (e.g.
40
+ * `"Bash(npm run test:*)"`), so the user knows what an "allow always" remembers.
41
+ */
42
+ readonly suggestions?: readonly string[];
43
+ }
44
+ /**
45
+ * The opaque {@link ModalState.payload} the `approval` overlay narrows: the pending
46
+ * request plus the resolver of the parked approval promise. The overlay calls
47
+ * `resolve(choice)` exactly once, then closes the modal.
48
+ */
49
+ export interface ApprovalPayload {
50
+ /** The pending request to render. */
51
+ readonly request: ApprovalRequest;
52
+ /** Fulfil the parked approval promise with the user's choice. */
53
+ readonly resolve: (choice: ApprovalChoice) => void;
54
+ }
55
+ /**
56
+ * The three offerable outcomes, in listing order. The `id` is the
57
+ * {@link ApprovalChoice} the gate consumes; `label`/`description` are what the
58
+ * prompt shows. Kept as data (not an `if`-ladder) so the choice→resolution mapping
59
+ * is a pure table the headless test asserts against.
60
+ */
61
+ export declare const APPROVAL_CHOICES: readonly (ThemeDialogItem & {
62
+ id: ApprovalChoice;
63
+ })[];
64
+ /** The {@link ApprovalChoice} a dismissed (Esc) prompt resolves to. */
65
+ export declare const DISMISS_CHOICE: ApprovalChoice;
66
+ /**
67
+ * Map a selected dialog `id` back to the {@link ApprovalChoice} the gate consumes.
68
+ *
69
+ * Pure and total: a recognised id maps to itself; anything unrecognised falls back
70
+ * to {@link DISMISS_CHOICE} (`"deny"`) so a stray selection can never silently
71
+ * allow a tool. This is the unit the headless test pins.
72
+ *
73
+ * @param id the `id` the {@link ThemeDialog} reported on select
74
+ */
75
+ export declare function choiceFromId(id: string): ApprovalChoice;
76
+ /**
77
+ * Narrow the opaque modal payload into a typed {@link ApprovalPayload}, or
78
+ * `undefined` when it is not a well-formed approval request (a defensive guard —
79
+ * the overlay renders nothing rather than crash on a malformed payload).
80
+ *
81
+ * @param payload the opaque {@link ModalState.payload}
82
+ */
83
+ export declare function readApprovalPayload(payload: unknown): ApprovalPayload | undefined;
84
+ /**
85
+ * Render a tool call's arguments as a single short, human-readable line for the
86
+ * prompt body. A shell `command` (or any `command` field) shows verbatim;
87
+ * otherwise a compact JSON encoding is used. Long values are elided so the prompt
88
+ * never floods the terminal.
89
+ *
90
+ * @param input the validated tool arguments
91
+ */
92
+ export declare function summarizeInput(input: unknown): string;
93
+ /**
94
+ * The approval overlay group: the single `approval` modal kind.
95
+ *
96
+ * Renders the prompt body when the active modal is `approval` and its payload is a
97
+ * well-formed {@link ApprovalPayload}; every other kind (and a malformed payload)
98
+ * yields nothing. Unlike the session/auth groups this does NOT require the runtime
99
+ * services bundle — the payload carries everything the prompt needs — so it works
100
+ * on any mount path that wired a resolver.
101
+ *
102
+ * @param props the forwarded overlay props (modal, services, theme, dispatch, closer)
103
+ */
104
+ export declare function ApprovalOverlays(props: OverlayGroupProps): JSX.Element | null;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Approval overlay — headless unit tests for the pure logic behind the prompt.
3
+ *
4
+ * Rendering an Ink dialog headlessly is brittle, so these tests pin the two pure
5
+ * pieces the interactive overlay is built from — exactly the bar the task sets:
6
+ *
7
+ * 1. The choice→resolution mapping ({@link choiceFromId}) + the payload narrowing
8
+ * ({@link readApprovalPayload}) + the argument summary ({@link summarizeInput}):
9
+ * a selected dialog row maps to the {@link ApprovalChoice} the gate consumes, a
10
+ * malformed payload is rejected, and a stray id falls back to `deny`.
11
+ * 2. The queue reducer ({@link approvalQueueReducer}) + head selector
12
+ * ({@link activeApproval}): requests serialise oldest-first, a settle drops the
13
+ * matching entry (and a double-settle is inert), and a clear empties the queue.
14
+ *
15
+ * No React, no Ink, no conductor — just the exported functions.
16
+ */
17
+ export {};
@@ -10,9 +10,10 @@
10
10
  * owns a disjoint slice of the kind space and renders a body only for its own
11
11
  * kinds:
12
12
  *
13
- * - {@link PickerOverlays} — `models`, `scopedModels`, `theme`, `settings`.
14
- * - {@link SessionOverlays} — `sessions`, `tree`, `userTurns`.
15
- * - {@link AuthOverlays} — `signIn`, `signOut`, `oauth`, `plugin`.
13
+ * - {@link PickerOverlays} — `models`, `scopedModels`, `theme`, `settings`.
14
+ * - {@link SessionOverlays} — `sessions`, `tree`, `userTurns`.
15
+ * - {@link AuthOverlays} — `signIn`, `signOut`, `oauth`, `plugin`.
16
+ * - {@link ApprovalOverlays} — `approval` (the tool-permission prompt).
16
17
  *
17
18
  * When no overlay is raised (`kind === "none"`) the host renders nothing, so the
18
19
  * composer keeps focus and the groups are never mounted needlessly.