indusagi-coding-agent 0.2.8 → 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.
Files changed (219) hide show
  1. package/package.json +2 -1
  2. package/src/_decls/entry.ts +18 -0
  3. package/src/_decls/guardrails.ts +35 -0
  4. package/src/_decls/index.ts +26 -0
  5. package/src/addons/contract.ts +236 -0
  6. package/src/addons/dispatch/event-dispatcher.ts +164 -0
  7. package/src/addons/dispatch/index.ts +25 -0
  8. package/src/addons/dispatch/tool-interceptor.ts +208 -0
  9. package/src/addons/host.ts +225 -0
  10. package/src/addons/index.ts +112 -0
  11. package/src/addons/manifest.ts +158 -0
  12. package/src/addons/sandbox.ts +170 -0
  13. package/src/addons/surface.ts +78 -0
  14. package/src/boot/auth-vault.ts +195 -0
  15. package/src/boot/boot.ts +138 -0
  16. package/src/boot/contract.ts +238 -0
  17. package/src/boot/heap.ts +59 -0
  18. package/src/boot/index.ts +28 -0
  19. package/src/boot/invocation.ts +93 -0
  20. package/src/boot/runners/addon-wiring.ts +153 -0
  21. package/src/boot/runners/checkpoint.ts +169 -0
  22. package/src/boot/runners/delegate-runner.ts +294 -0
  23. package/src/boot/runners/index.ts +13 -0
  24. package/src/boot/runners/link-runner.ts +45 -0
  25. package/src/boot/runners/memdir.ts +168 -0
  26. package/src/boot/runners/oneshot-runner.ts +58 -0
  27. package/src/boot/runners/read-state.ts +90 -0
  28. package/src/boot/runners/registry.ts +42 -0
  29. package/src/boot/runners/repl-runner.ts +143 -0
  30. package/src/boot/runners/server-mode.ts +121 -0
  31. package/src/boot/runners/session.ts +641 -0
  32. package/src/boot/server-token.ts +148 -0
  33. package/src/boot/stages.ts +167 -0
  34. package/src/boot/upgrade/apply.ts +94 -0
  35. package/src/boot/upgrade/index.ts +13 -0
  36. package/src/boot/upgrade/upgrades.ts +289 -0
  37. package/src/briefing/compose.ts +150 -0
  38. package/src/briefing/context-docs.ts +19 -0
  39. package/src/briefing/contract.ts +717 -0
  40. package/src/briefing/index.ts +31 -0
  41. package/src/briefing/macros.ts +97 -0
  42. package/src/briefing/skills.ts +47 -0
  43. package/src/capability-deck/bridge-ledger/index.ts +27 -0
  44. package/src/capability-deck/bridge-ledger/key.ts +67 -0
  45. package/src/capability-deck/bridge-ledger/ledger.ts +131 -0
  46. package/src/capability-deck/bridge-ledger/network.ts +117 -0
  47. package/src/capability-deck/builtin-bridge.ts +312 -0
  48. package/src/capability-deck/cards/bg-process-card.ts +335 -0
  49. package/src/capability-deck/cards/index.ts +115 -0
  50. package/src/capability-deck/cards/memory-card.ts +146 -0
  51. package/src/capability-deck/cards/plan-file.ts +97 -0
  52. package/src/capability-deck/cards/plan-tools.ts +185 -0
  53. package/src/capability-deck/cards/saas-card.ts +183 -0
  54. package/src/capability-deck/cards/task-card.ts +207 -0
  55. package/src/capability-deck/cards/todo-card.ts +168 -0
  56. package/src/capability-deck/cards/workflow-card.ts +247 -0
  57. package/src/capability-deck/contract.ts +388 -0
  58. package/src/capability-deck/index.ts +48 -0
  59. package/src/capability-deck/manifest.ts +109 -0
  60. package/src/capability-deck/provision.ts +169 -0
  61. package/src/channels/contract.ts +191 -0
  62. package/src/channels/framer.ts +50 -0
  63. package/src/channels/index.ts +101 -0
  64. package/src/channels/link/dialog.ts +129 -0
  65. package/src/channels/link/driver.ts +190 -0
  66. package/src/channels/link/index.ts +34 -0
  67. package/src/channels/link/server.ts +134 -0
  68. package/src/channels/oneshot.ts +90 -0
  69. package/src/channels/ops.ts +65 -0
  70. package/src/channels/session-ops.ts +81 -0
  71. package/src/conductor/bash-guard.ts +599 -0
  72. package/src/conductor/catalog/catalog.ts +116 -0
  73. package/src/conductor/catalog/index.ts +8 -0
  74. package/src/conductor/catalog/matcher.ts +134 -0
  75. package/src/conductor/conductor.ts +234 -0
  76. package/src/conductor/contract.ts +842 -0
  77. package/src/conductor/diagnostics.ts +227 -0
  78. package/src/conductor/index.ts +33 -0
  79. package/src/conductor/permissions.ts +588 -0
  80. package/src/conductor/quota-error.ts +49 -0
  81. package/src/conductor/signal-hub/hub.ts +46 -0
  82. package/src/conductor/signal-hub/index.ts +2 -0
  83. package/src/conductor/signal-hub/translate.test.ts +81 -0
  84. package/src/conductor/signal-hub/translate.ts +74 -0
  85. package/src/conductor/skill-parse/index.ts +2 -0
  86. package/src/conductor/skill-parse/parse.ts +108 -0
  87. package/src/conductor/transcript-store/index.ts +22 -0
  88. package/src/conductor/transcript-store/serialize.ts +116 -0
  89. package/src/conductor/transcript-store/store.ts +205 -0
  90. package/src/console/auth-status.ts +56 -0
  91. package/src/console/components/AgentsView.ts +165 -0
  92. package/src/console/components/BackgroundAgents.ts +155 -0
  93. package/src/console/components/Banner.ts +334 -0
  94. package/src/console/components/Composer.ts +94 -0
  95. package/src/console/components/StatusBar.ts +49 -0
  96. package/src/console/components/TerminalConsole.ts +1090 -0
  97. package/src/console/components/WorkingIndicator.ts +98 -0
  98. package/src/console/components/banner-sweep.ts +24 -0
  99. package/src/console/components/welcome.ts +74 -0
  100. package/src/console/contract.ts +630 -0
  101. package/src/console/index.ts +34 -0
  102. package/src/console/input/complete.ts +127 -0
  103. package/src/console/input/dir-reader.ts +34 -0
  104. package/src/console/input/index.ts +23 -0
  105. package/src/console/input/keymap.ts +159 -0
  106. package/src/console/input/paste.ts +104 -0
  107. package/src/console/mount.ts +56 -0
  108. package/src/console/overlays/approval-queue.ts +57 -0
  109. package/src/console/overlays/approval.ts +130 -0
  110. package/src/console/overlays/auth.ts +342 -0
  111. package/src/console/overlays/boards.ts +308 -0
  112. package/src/console/overlays/host.ts +36 -0
  113. package/src/console/overlays/index.ts +26 -0
  114. package/src/console/overlays/pickers.ts +258 -0
  115. package/src/console/overlays/sessions.ts +190 -0
  116. package/src/console/reducer.ts +182 -0
  117. package/src/console/slash/builtins.ts +81 -0
  118. package/src/console/slash/commands/dynamic.ts +83 -0
  119. package/src/console/slash/commands/integrations.ts +695 -0
  120. package/src/console/slash/commands/shared.ts +75 -0
  121. package/src/console/slash/commands/transcript.ts +263 -0
  122. package/src/console/slash/commands/workbench.ts +246 -0
  123. package/src/console/slash/index.ts +15 -0
  124. package/src/console/slash/registry.ts +70 -0
  125. package/src/console/slash/resolve.ts +63 -0
  126. package/src/console/startup.ts +209 -0
  127. package/src/console/theme/adapter.ts +45 -0
  128. package/src/console/theme/index.ts +7 -0
  129. package/src/console/theme/palette.ts +68 -0
  130. package/src/console/theme/resolve.ts +39 -0
  131. package/src/console/theme/tokens.ts +71 -0
  132. package/src/entry.ts +55 -0
  133. package/src/guardrails.ts +37 -0
  134. package/src/index.ts +18 -0
  135. package/src/insight/channel.ts +88 -0
  136. package/src/insight/contract.ts +185 -0
  137. package/src/insight/index.ts +110 -0
  138. package/src/insight/recorder.ts +213 -0
  139. package/src/insight/redaction.ts +157 -0
  140. package/src/insight/replay.ts +158 -0
  141. package/src/insight/sampling.ts +70 -0
  142. package/src/insight/serialize.ts +50 -0
  143. package/src/insight/sinks/console.ts +64 -0
  144. package/src/insight/sinks/file.ts +40 -0
  145. package/src/insight/sinks/index.ts +24 -0
  146. package/src/insight/sinks/stream.ts +54 -0
  147. package/src/integrations/sarvam/attach.ts +239 -0
  148. package/src/integrations/sarvam/config.ts +156 -0
  149. package/src/integrations/sarvam/index.ts +25 -0
  150. package/src/integrations/sarvam/sarvam.test.ts +60 -0
  151. package/src/integrations/sarvam/types.ts +27 -0
  152. package/src/integrations/zoho/attach.ts +342 -0
  153. package/src/integrations/zoho/config.ts +125 -0
  154. package/src/integrations/zoho/index.ts +27 -0
  155. package/src/integrations/zoho/types.ts +21 -0
  156. package/src/integrations/zoho/zoho.test.ts +50 -0
  157. package/src/kit/clipboard-image.ts +107 -0
  158. package/src/kit/external-editor.ts +48 -0
  159. package/src/kit/image.ts +59 -0
  160. package/src/kit/index.ts +51 -0
  161. package/src/kit/shell.ts +19 -0
  162. package/src/kit/tool-fetch.ts +85 -0
  163. package/src/launch/catalog.ts +148 -0
  164. package/src/launch/contract.ts +187 -0
  165. package/src/launch/credentials.ts +625 -0
  166. package/src/launch/index.ts +98 -0
  167. package/src/launch/invocation/attachments.ts +179 -0
  168. package/src/launch/invocation/flags.ts +196 -0
  169. package/src/launch/invocation/index.ts +25 -0
  170. package/src/launch/invocation/read.ts +260 -0
  171. package/src/launch/invocation/usage.ts +67 -0
  172. package/src/launch/login.ts +324 -0
  173. package/src/launch/oauth.test.ts +18 -0
  174. package/src/launch/oauth.ts +203 -0
  175. package/src/launch/packages.ts +194 -0
  176. package/src/launch/pickers.ts +189 -0
  177. package/src/runtime-bridge/bridges/_drive.ts +96 -0
  178. package/src/runtime-bridge/bridges/builtins.ts +68 -0
  179. package/src/runtime-bridge/bridges/claude-cli.ts +123 -0
  180. package/src/runtime-bridge/bridges/codex-cli.ts +142 -0
  181. package/src/runtime-bridge/bridges/index.ts +33 -0
  182. package/src/runtime-bridge/bridges/indusagi-cli.ts +155 -0
  183. package/src/runtime-bridge/broker.ts +227 -0
  184. package/src/runtime-bridge/contract.ts +122 -0
  185. package/src/runtime-bridge/index.ts +79 -0
  186. package/src/runtime-bridge/sink.ts +180 -0
  187. package/src/sessions/contract.ts +81 -0
  188. package/src/sessions/index.ts +13 -0
  189. package/src/sessions/library.ts +229 -0
  190. package/src/settings/contract.ts +114 -0
  191. package/src/settings/index.ts +32 -0
  192. package/src/settings/manager.ts +117 -0
  193. package/src/transcript-export/index.ts +45 -0
  194. package/src/transcript-export/publish.ts +260 -0
  195. package/src/transcript-export/sgr.ts +315 -0
  196. package/src/transcript-export/template.ts +272 -0
  197. package/src/transcript-export/theme-bridge.ts +150 -0
  198. package/src/window-budget/budget/estimate.ts +135 -0
  199. package/src/window-budget/budget/gate.ts +33 -0
  200. package/src/window-budget/budget/index.ts +16 -0
  201. package/src/window-budget/budget/slice.ts +56 -0
  202. package/src/window-budget/condenser.ts +58 -0
  203. package/src/window-budget/contract.ts +184 -0
  204. package/src/window-budget/index.ts +19 -0
  205. package/src/window-budget/microcompact.ts +95 -0
  206. package/src/window-budget/rehydrate.ts +136 -0
  207. package/src/window-budget/summarize/condense.ts +103 -0
  208. package/src/window-budget/summarize/index.ts +14 -0
  209. package/src/window-budget/summarize/prompt.ts +149 -0
  210. package/src/workflow-engine/agent-runner.ts +181 -0
  211. package/src/workflow-engine/display.ts +224 -0
  212. package/src/workflow-engine/engine.ts +294 -0
  213. package/src/workflow-engine/index.ts +23 -0
  214. package/src/workflow-engine/parse.ts +172 -0
  215. package/src/workflow-engine/structured-output.ts +35 -0
  216. package/src/workspace/brand.ts +29 -0
  217. package/src/workspace/index.ts +18 -0
  218. package/src/workspace/locator.ts +103 -0
  219. package/src/workspace/runtime-detect.ts +64 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Per-session file-checkpoint store — the product half of rewind (#24).
3
+ *
4
+ * The framework's mutating file tools (write/edit) consult a host-injected handle
5
+ * on `ctx.framework` under the string-literal key {@link CHECKPOINT_HANDLE_KEY}.
6
+ * Immediately before a file is written — and after the read-before-edit gate has
7
+ * passed — the framework reads the file's CURRENT on-disk content and hands it to
8
+ * the handle as `record(absPath, previous)`, where `previous` is the old bytes or
9
+ * `null` when the file did not yet exist. This module supplies the missing host
10
+ * piece: a store that files those pre-mutation snapshots against the transcript
11
+ * node that was active when the mutation happened, so a later rewind to that node
12
+ * can roll the working tree back to exactly that point.
13
+ */
14
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
15
+ import { dirname as dirname2, normalize as normalize2 } from "node:path";
16
+
17
+ /**
18
+ * The string-literal key under which the host wires this store onto the deck's
19
+ * `ctx.framework` bag.
20
+ *
21
+ * Mirrors the existing `DELEGATE_HANDLE_KEY = "delegate"`,
22
+ * `MEMORY_HANDLE_KEY = "memoryStore"`, and `READ_STATE_HANDLE_KEY = "readState"`
23
+ * conventions. The framework's mutating file tools read
24
+ * `ctx.framework["checkpoint"]` by the same literal, so the two sides agree by
25
+ * string rather than by a shared imported symbol.
26
+ */
27
+ export const CHECKPOINT_HANDLE_KEY = "checkpoint";
28
+
29
+ const ROOT_NODE_ID = "__root__";
30
+
31
+ /**
32
+ * One file's pre-mutation snapshot: the path that was mutated and the content it
33
+ * held immediately before. `previous === null` records that the file did not
34
+ * exist on disk before the mutation, so restoring this snapshot deletes it.
35
+ */
36
+ export interface FileSnapshot {
37
+ readonly path: string;
38
+ readonly previous: string | null;
39
+ }
40
+
41
+ /**
42
+ * Per-session file-checkpoint store, keyed by transcript node id.
43
+ *
44
+ * For each node id it holds a map of normalized absolute path → the FIRST-seen
45
+ * pre-mutation content for that path under that node. The framework calls
46
+ * `record` (via the duck-typed handle) before every write; the host pins the
47
+ * active node id with `setActiveNodeId` as the conductor's head moves; and
48
+ * `restore` rolls the working tree back to a node's recorded state.
49
+ */
50
+ export class CheckpointStore {
51
+ /** node id → (normalized abs path → first-seen pre-mutation content). */
52
+ private readonly byNode = new Map<string, Map<string, string | null>>();
53
+
54
+ /** The transcript node the next `record` files its snapshot under. */
55
+ private current: string | null = null;
56
+
57
+ /** Normalize an absolute path into the store's canonical per-node key. */
58
+ private key(absPath: string): string {
59
+ return normalize2(absPath);
60
+ }
61
+
62
+ /** The node id new snapshots are currently filed under (`null` until pinned). */
63
+ activeNodeId(): string | null {
64
+ return this.current;
65
+ }
66
+
67
+ /**
68
+ * Pin the transcript node subsequent `record` calls file snapshots under.
69
+ *
70
+ * The conductor calls this as its head advances (at turn start), so all of a
71
+ * turn's edits key to the node that was active before the turn ran. Passing
72
+ * the same id again is harmless.
73
+ *
74
+ * @param id the active transcript node id, or `null` to fall back to the root
75
+ */
76
+ setActiveNodeId(id: string | null): void {
77
+ this.current = id;
78
+ }
79
+
80
+ /** The node id snapshots are filed under right now (active id or the root). */
81
+ private resolveNodeId(): string {
82
+ return this.current ?? ROOT_NODE_ID;
83
+ }
84
+
85
+ /**
86
+ * Capture the pre-mutation content of `absPath` under the active node.
87
+ *
88
+ * The framework's duck-typed `CheckpointHandle` entry point. Called once per
89
+ * mutated path immediately before the write lands, with the file's OLD
90
+ * on-disk content (or `null` when the file did not exist). First-seen wins:
91
+ * a later `record` of the same path under the same node is ignored so the
92
+ * snapshot reflects the file's state when the node became active, not a
93
+ * mid-turn rewrite.
94
+ *
95
+ * @param absPath absolute path of the file about to be written
96
+ * @param previous the file's content before this mutation, or `null` if absent
97
+ */
98
+ record(absPath: string, previous: string | null): void {
99
+ const nodeId = this.resolveNodeId();
100
+ let paths = this.byNode.get(nodeId);
101
+ if (paths === undefined) {
102
+ paths = new Map<string, string | null>();
103
+ this.byNode.set(nodeId, paths);
104
+ }
105
+ const k = this.key(absPath);
106
+ if (paths.has(k)) return;
107
+ paths.set(k, previous);
108
+ }
109
+
110
+ /**
111
+ * The recorded snapshots for a node, or `[]` when nothing was tracked there.
112
+ *
113
+ * Each entry pairs the normalized absolute path with the content it held
114
+ * before the node's first mutation of it (`null` = the file was absent).
115
+ *
116
+ * @param nodeId the transcript node to read snapshots for
117
+ */
118
+ snapshotFor(nodeId: string): readonly FileSnapshot[] {
119
+ const paths = this.byNode.get(nodeId);
120
+ if (paths === undefined) return [];
121
+ return [...paths].map(([path, previous]) => ({ path, previous }));
122
+ }
123
+
124
+ /** Whether a node has ANY recorded file snapshot (the picker's restore gate). */
125
+ hasSnapshot(nodeId: string): boolean {
126
+ const paths = this.byNode.get(nodeId);
127
+ return paths !== undefined && paths.size > 0;
128
+ }
129
+
130
+ /**
131
+ * Roll the working tree back to a node's recorded state.
132
+ *
133
+ * For each path the node tracked: when its snapshot is a string, the file
134
+ * is rewritten with that pre-mutation content (creating parent directories
135
+ * as needed); when the snapshot is `null` the file is DELETED (it did not
136
+ * exist at that point). Returns the absolute paths that were restored, in
137
+ * record order.
138
+ *
139
+ * A safe no-op when the node has no snapshot — navigating to a node that
140
+ * never mutated files restores nothing. Per-path failures are swallowed so
141
+ * one unwritable path never aborts the rest of the restore.
142
+ *
143
+ * @param nodeId the transcript node whose file state to restore to
144
+ * @returns the absolute paths that were written or deleted
145
+ */
146
+ restore(nodeId: string): string[] {
147
+ const snapshots = this.snapshotFor(nodeId);
148
+ const restored: string[] = [];
149
+ for (const { path, previous } of snapshots) {
150
+ try {
151
+ if (previous === null) {
152
+ rmSync(path, { force: true });
153
+ } else {
154
+ mkdirSync(dirname2(path), { recursive: true });
155
+ writeFileSync(path, previous, "utf8");
156
+ }
157
+ restored.push(path);
158
+ } catch {
159
+ // swallow per-path failures — never abort the rest of the restore
160
+ }
161
+ }
162
+ return restored;
163
+ }
164
+ }
165
+
166
+ /** Mint a fresh, empty per-session checkpoint store. */
167
+ export function createCheckpointStore(): CheckpointStore {
168
+ return new CheckpointStore();
169
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Boot helper: a live {@link DelegateRunner} that makes the `task` tool real.
3
+ *
4
+ * The `task` capability ({@link "../../capability-deck/cards/task-card"}) advertises
5
+ * sub-agent delegation but only *runs* it when a {@link DelegateRunner} is wired
6
+ * into the deck context under {@link DELEGATE_HANDLE_KEY}; absent one it degrades
7
+ * to a typed `STUB_NOTE`. This module supplies that runner.
8
+ *
9
+ * Each delegated objective spins a *fresh, isolated* framework {@link Agent} (the
10
+ * same `facade/bot` Agent the conductor already drives — NOT the swarm `Crew` nor
11
+ * `runtime/createAgent`) bound to the parent's model id and credential resolver,
12
+ * given a survey-style system prompt and a deck that DELIBERATELY excludes the
13
+ * `task` card. That exclusion is the recursion guard: a sub-agent with no `task`
14
+ * tool cannot spawn its own sub-agents. The runner submits one prompt, lets the
15
+ * sub-agent run its own tool loop to completion, then extracts the final
16
+ * assistant turn's text as the single report handed back to the parent.
17
+ *
18
+ * The runner never throws out of `run()`: a model that does not resolve, a
19
+ * sub-agent error, or an empty transcript all map to a `{ ok:false, report }`
20
+ * result so a delegation failure surfaces to the parent agent as an ordinary
21
+ * tool error rather than crashing the turn. Cancellation is forwarded: an
22
+ * already-aborted signal short-circuits, and an abort raised mid-run calls
23
+ * `agent.abort()`.
24
+ *
25
+ * The `spawn`/`tools` options are pure test seams — they let a unit test drive
26
+ * the runner with an in-memory fake instead of a real network round-trip.
27
+ */
28
+ import { Agent } from "indusagi/agent";
29
+ import {
30
+ type AgentMessage,
31
+ type AgentTool,
32
+ type DelegateRunner,
33
+ type SessionPermissionPolicy,
34
+ ModelCatalog,
35
+ ModelMatcher,
36
+ withGatewayBaseUrl,
37
+ createSubagentPermissionGate,
38
+ } from "../../conductor/index.js";
39
+ import { provisionDeck } from "../../capability-deck/index.js";
40
+
41
+ /**
42
+ * The minimal sub-agent surface the runner drives.
43
+ *
44
+ * A real framework {@link Agent} satisfies this structurally; a test passes a
45
+ * lightweight fake via {@link DelegateRunnerOptions.spawn}. Only the pieces the
46
+ * runner touches are named — submit a prompt, abort, and read the resulting
47
+ * transcript/error afterward.
48
+ */
49
+ export interface DelegateSubAgent {
50
+ prompt(input: string): Promise<void>;
51
+ abort(): void;
52
+ readonly state: {
53
+ messages: readonly AgentMessage[];
54
+ error?: string;
55
+ };
56
+ /**
57
+ * Optional event subscription (the real framework `Agent` provides it). The
58
+ * runner uses it to recompute live token spend as the sub-agent works; a test
59
+ * fake may omit it, in which case no progress is reported.
60
+ */
61
+ subscribe?(listener: () => void): () => void;
62
+ }
63
+
64
+ /** Configuration for {@link createDelegateRunner}. */
65
+ export interface DelegateRunnerOptions {
66
+ /** The model id the sub-agent runs under (the parent's resolved model). */
67
+ readonly modelId: string;
68
+ /** The working directory the sub-agent's deck is scoped to. */
69
+ readonly cwd: string;
70
+ /** The system prompt that shapes the sub-agent's behaviour. */
71
+ readonly system: string;
72
+ /**
73
+ * Per-call credential resolver, forwarded to the framework `Agent` unchanged
74
+ * (OAuth-only providers, short-lived token rotation). Omitted from the agent
75
+ * options entirely when undefined so the framework env-var lookup still wins.
76
+ */
77
+ readonly getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
78
+ /**
79
+ * Test seam: build the sub-agent from the objective instead of constructing a
80
+ * real framework `Agent`. When omitted the runner uses `new Agent`.
81
+ */
82
+ readonly spawn?: (objective: string, context?: string) => DelegateSubAgent;
83
+ /**
84
+ * Test seam: the tool deck the sub-agent runs with. When omitted the runner
85
+ * provisions the read-only `'authoring'` profile (which already excludes the
86
+ * `task` card — the recursion guard).
87
+ */
88
+ readonly tools?: () => AgentTool[];
89
+ /**
90
+ * Resolve the current server-tier gateway routing map (provider -> gateway
91
+ * base url). Called FRESH on every `run()` — never cached — so a mid-session
92
+ * `/login` (to "Indus Server"), including one that lands after this runner was
93
+ * constructed but before a given delegated call, is always picked up. This
94
+ * mirrors {@link getApiKey}, which is likewise invoked per-request rather than
95
+ * resolved once at construction.
96
+ */
97
+ readonly getGatewayBaseUrls?: () => Promise<Record<string, string>>;
98
+ /**
99
+ * The parent session's live permission policy: the SAME mutable rule list the
100
+ * parent gate reads plus a live getter onto the conductor's current mode. When
101
+ * present, every spawned sub-agent runs under a RESOLVER-LESS gate built from
102
+ * it — deny/ask rules, plan-mode enforcement, and the catastrophic-bash
103
+ * blocklist apply per inner tool call, and a mid-run mode switch (Shift+Tab)
104
+ * retargets the very next delegated tool call. A sub-agent cannot prompt, so
105
+ * an `ask` decision deterministically denies with an actionable message.
106
+ * Absent (a bare test construction), the sub-agent runs ungated as before.
107
+ */
108
+ readonly permissionPolicy?: SessionPermissionPolicy;
109
+ }
110
+
111
+ function sumTokens(messages: readonly AgentMessage[]): number {
112
+ let total = 0;
113
+ for (const message of messages) {
114
+ const usage = (message as { usage?: { input?: number; output?: number } }).usage;
115
+ if (usage) total += (usage.input ?? 0) + (usage.output ?? 0);
116
+ }
117
+ return total;
118
+ }
119
+
120
+ function oneLine(text: string, max = 120): string {
121
+ const flat = text.replace(/\s+/g, " ").trim();
122
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
123
+ }
124
+
125
+ function argPreview(args: unknown): string {
126
+ if (args === null || typeof args !== "object") return "";
127
+ const entries = Object.entries(args as Record<string, unknown>);
128
+ if (entries.length === 0) return "";
129
+ const value = entries[0][1];
130
+ const text = typeof value === "string" ? value : JSON.stringify(value);
131
+ return ` (${oneLine(String(text), 56)})`;
132
+ }
133
+
134
+ interface ActivityEntry {
135
+ tone: "reason" | "tool";
136
+ text: string;
137
+ }
138
+
139
+ function buildActivity(messages: readonly AgentMessage[]): ActivityEntry[] {
140
+ const out: ActivityEntry[] = [];
141
+ for (const message of messages) {
142
+ if (message.role !== "assistant") continue;
143
+ const content = (message as { content?: unknown }).content;
144
+ if (!Array.isArray(content)) continue;
145
+ for (const part of content as Array<Record<string, unknown>>) {
146
+ const p = part;
147
+ if ((p.type === "text" && typeof p.text === "string" && p.text.trim()) || (p.type === "thinking" && typeof p.thinking === "string" && p.thinking.trim())) {
148
+ out.push({ tone: "reason", text: oneLine((p.text ?? p.thinking) as string) });
149
+ } else if (p.type === "toolCall" && typeof p.name === "string") {
150
+ const name = (p.name as string).charAt(0).toUpperCase() + (p.name as string).slice(1);
151
+ out.push({ tone: "tool", text: `${name}${argPreview(p.arguments)}` });
152
+ }
153
+ }
154
+ }
155
+ return out.slice(-14);
156
+ }
157
+
158
+ const MAX_PARALLEL_TASKS = (() => {
159
+ const n = parseInt(process.env.INDUS_MAX_PARALLEL_TASKS ?? "", 10);
160
+ return Number.isFinite(n) && n >= 1 ? n : 2;
161
+ })();
162
+
163
+ const MAX_REPORT_CHARS = (() => {
164
+ const n = parseInt(process.env.INDUS_MAX_DELEGATE_REPORT_CHARS ?? "", 10);
165
+ return Number.isFinite(n) && n >= 1000 ? n : 48000;
166
+ })();
167
+
168
+ let activeTasks = 0;
169
+ const taskQueue: Array<() => void> = [];
170
+
171
+ async function acquireTaskSlot(): Promise<() => void> {
172
+ if (activeTasks < MAX_PARALLEL_TASKS) {
173
+ activeTasks++;
174
+ } else {
175
+ await new Promise<void>((resolve) => taskQueue.push(resolve));
176
+ }
177
+ let released = false;
178
+ return () => {
179
+ if (released) return;
180
+ released = true;
181
+ const next = taskQueue.shift();
182
+ if (next) next();
183
+ else activeTasks--;
184
+ };
185
+ }
186
+
187
+ function clampReport(report: string): string {
188
+ if (report.length <= MAX_REPORT_CHARS) return report;
189
+ const head = Math.floor(MAX_REPORT_CHARS * 0.7);
190
+ const tail = MAX_REPORT_CHARS - head;
191
+ const omitted = report.length - MAX_REPORT_CHARS;
192
+ return `${report.slice(0, head)}\n\n\u2026[delegate report truncated \u2014 ${omitted} characters omitted to protect parent context]\u2026\n\n${report.slice(report.length - tail)}`;
193
+ }
194
+
195
+ function finalAssistantText(messages: readonly AgentMessage[]): string {
196
+ for (let i = messages.length - 1; i >= 0; i--) {
197
+ const message = messages[i];
198
+ if (message.role !== "assistant") continue;
199
+ const content = (message as { content?: unknown }).content;
200
+ if (!Array.isArray(content)) return typeof content === "string" ? content : "";
201
+ return (content as Array<Record<string, unknown>>)
202
+ .filter(
203
+ (block) => !!block && typeof block === "object" && block.type === "text" && typeof block.text === "string",
204
+ )
205
+ .map((block) => block.text as string)
206
+ .join("");
207
+ }
208
+ return "";
209
+ }
210
+
211
+ /**
212
+ * Build a live {@link DelegateRunner} the host wires into the deck context.
213
+ *
214
+ * The model is resolved once up front; if the id resolves to nothing the runner
215
+ * still builds but every `run()` reports `{ ok:false }` rather than throwing, so
216
+ * a misconfigured model can never crash the parent's turn.
217
+ *
218
+ * @param opts the model id, cwd, system prompt, and optional credential/test seams
219
+ * @returns a runner satisfying the task card's {@link DelegateRunner} contract
220
+ */
221
+ export function createDelegateRunner(opts: DelegateRunnerOptions): DelegateRunner {
222
+ const model = new ModelMatcher(new ModelCatalog()).resolveCard(opts.modelId)?.model;
223
+ return {
224
+ async run(
225
+ request: { objective: string; context?: string },
226
+ signal?: AbortSignal,
227
+ onProgress?: (info: { tokens: number; activity: ActivityEntry[] }) => void,
228
+ ) {
229
+ if (opts.spawn === undefined && model === undefined) {
230
+ return { ok: false, report: "no model resolved for delegation" };
231
+ }
232
+ if (signal?.aborted) {
233
+ return { ok: false, report: "delegation aborted" };
234
+ }
235
+ const objective = request.context
236
+ ? `${request.objective}\n\nContext:\n${request.context}`
237
+ : request.objective;
238
+ const gatewayBaseUrls = await opts.getGatewayBaseUrls?.();
239
+ const releaseSlot = await acquireTaskSlot();
240
+ try {
241
+ if (signal?.aborted) {
242
+ return { ok: false, report: "delegation aborted" };
243
+ }
244
+ let agent: DelegateSubAgent | undefined = opts.spawn?.(request.objective, request.context);
245
+ if (agent === undefined) {
246
+ const tools = opts.tools?.() ?? provisionDeck("authoring", { cwd: opts.cwd }).tools();
247
+ agent = new Agent({
248
+ initialState: {
249
+ model: withGatewayBaseUrl(model as never, gatewayBaseUrls ?? {}),
250
+ systemPrompt: opts.system,
251
+ tools,
252
+ },
253
+ ...opts.getApiKey ? { getApiKey: opts.getApiKey } : {},
254
+ ...opts.permissionPolicy !== undefined
255
+ ? { canUseTool: createSubagentPermissionGate(opts.permissionPolicy, tools) }
256
+ : {},
257
+ }) as unknown as DelegateSubAgent;
258
+ }
259
+ const onAbort = () => agent!.abort();
260
+ signal?.addEventListener("abort", onAbort, { once: true });
261
+ let lastSignature = "";
262
+ const reportProgress = () => {
263
+ if (!onProgress) return;
264
+ const tokens = sumTokens(agent!.state.messages);
265
+ const activity = buildActivity(agent!.state.messages);
266
+ const signature = `${tokens}:${activity.length}`;
267
+ if (signature !== lastSignature) {
268
+ lastSignature = signature;
269
+ onProgress({ tokens, activity });
270
+ }
271
+ };
272
+ const offProgress = onProgress ? agent.subscribe?.(reportProgress) : undefined;
273
+ try {
274
+ await agent.prompt(objective);
275
+ reportProgress();
276
+ } catch (cause) {
277
+ const message = cause instanceof Error ? cause.message : String(cause);
278
+ return { ok: false, report: clampReport(message || "delegation failed") };
279
+ } finally {
280
+ offProgress?.();
281
+ signal?.removeEventListener("abort", onAbort);
282
+ }
283
+ const error = agent.state.error;
284
+ const report = finalAssistantText(agent.state.messages);
285
+ return {
286
+ ok: error === undefined && report.length > 0,
287
+ report: clampReport(error ?? (report || "(no output)")),
288
+ };
289
+ } finally {
290
+ releaseSlot();
291
+ }
292
+ },
293
+ };
294
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Runners subsystem — public barrel.
3
+ *
4
+ * Surfaces the runner registry and the individual placeholder runners. Boot
5
+ * consumers dispatch through {@link selectRunner} / {@link RUNNERS}; the named
6
+ * runners are exported for tests and for explicit wiring.
7
+ */
8
+ export { replRunner } from "./repl-runner.js";
9
+ export { oneshotRunner } from "./oneshot-runner.js";
10
+ export { linkRunner } from "./link-runner.js";
11
+ export { RUNNERS, selectRunner } from "./registry.js";
12
+ export { createReadStateStore, READ_STATE_HANDLE_KEY, ReadStateStore } from "./read-state.js";
13
+ export { createCheckpointStore, CHECKPOINT_HANDLE_KEY, CheckpointStore } from "./checkpoint.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Runner: `link` — headless JSON-RPC link for a driving parent process.
3
+ *
4
+ * Drives the Phase-8 link channel: it assembles a {@link SessionConductor} for
5
+ * the invocation and serves the declarative {@link SESSION_OPS} registry over the
6
+ * process stdio pair via {@link createLinkServer}. Every framed request is
7
+ * dispatched through the registry (data-driven, not a command switch) and the
8
+ * reply is framed back; the runner resolves the success exit code once the
9
+ * inbound stream ends.
10
+ */
11
+ import { createLinkServer, SESSION_OPS } from "../../channels/index.js";
12
+ import type { Runner } from "../contract.js";
13
+ import { buildSessionConductor } from "./session.js";
14
+
15
+ const EXIT_OK = 0;
16
+
17
+ const STDOUT = {
18
+ write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void) {
19
+ return process.stdout.write(chunk, cb);
20
+ },
21
+ };
22
+
23
+ /**
24
+ * The headless-link runner.
25
+ *
26
+ * {@link Runner.accepts} matches invocations whose resolved mode is `link`.
27
+ * {@link Runner.run} assembles the conductor, starts a link server reading framed
28
+ * requests from stdin and writing framed replies to stdout, and resolves the
29
+ * success exit code when the inbound stream is exhausted.
30
+ */
31
+ export const linkRunner: Runner = {
32
+ id: "link",
33
+ accepts(inv) {
34
+ return inv.mode === "link";
35
+ },
36
+ async run(ctx) {
37
+ const conductor = await buildSessionConductor(ctx);
38
+ const server = createLinkServer(SESSION_OPS, conductor, {
39
+ in: process.stdin,
40
+ out: STDOUT,
41
+ });
42
+ await server.done;
43
+ return EXIT_OK;
44
+ },
45
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Durable cross-session memory — a disk-backed working-memory store and the
3
+ * project-context document that carries it into each session's system prompt.
4
+ *
5
+ * The `memory` capability ({@link "../../capability-deck/cards/memory-card"})
6
+ * already reads a host-injected store from `ctx.framework[MEMORY_HANDLE_KEY]` and
7
+ * falls back to an in-process buffer when none is wired. This module supplies the
8
+ * missing piece: a synchronous {@link DiskMemoryStore} that persists the agent's
9
+ * note to a per-cwd `MEMORY.md` under the profile directory, so a fact written in
10
+ * one session is still there the next time the agent runs in the same directory.
11
+ *
12
+ * The same file is loaded into the briefing as a {@link ContextDoc} ({@link
13
+ * loadMemoryDoc}) so the model *sees* its prior memory at the top of a fresh
14
+ * session, not only when it explicitly calls the `memory` tool. The body is
15
+ * line-and-byte capped by {@link truncateEntrypointContent} so a runaway memory
16
+ * file cannot bloat the prompt.
17
+ *
18
+ * Design notes:
19
+ * - The store is STRICTLY SYNCHRONOUS — the {@link MemoryStore} contract the
20
+ * card validates is sync (`read`/`replace`/`append` return `void`/`string`),
21
+ * and an async store would silently fail that validation and degrade to the
22
+ * in-memory fallback.
23
+ * - The memory directory is scoped UNDER `workspace.profileDir`, NOT the cwd, so
24
+ * persisting memory never writes files into the user's working tree.
25
+ * - The cwd is slugged with the SAME regex as {@link "./session".sessionScopeDir}
26
+ * so the per-cwd partitioning lines up with the session layout.
27
+ * - Files are written `0o600` (owner-only), matching the auth vault — the memory
28
+ * note can hold sensitive project facts.
29
+ */
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import type { ContextDoc } from "../../briefing/index.js";
33
+ import { type MemoryStore } from "../../capability-deck/index.js";
34
+ import type { Workspace } from "../contract.js";
35
+
36
+ /** The on-disk filename the durable memory note is stored under. */
37
+ export const MEMORY_ENTRYPOINT = "MEMORY.md";
38
+ /** Maximum number of lines of the memory note inlined into the prompt. */
39
+ export const MAX_ENTRYPOINT_LINES = 200;
40
+ /**
41
+ * Maximum bytes of the memory note inlined into the prompt.
42
+ *
43
+ * ~125 chars/line at 200 lines: this catches long-line indexes that slip past
44
+ * the line cap (a few very long lines can be far larger than 200 short ones).
45
+ */
46
+ export const MAX_ENTRYPOINT_BYTES = 25000;
47
+
48
+ /**
49
+ * Cap the memory note to {@link MAX_ENTRYPOINT_LINES} and
50
+ * {@link MAX_ENTRYPOINT_BYTES}, appending a warning naming which cap fired.
51
+ *
52
+ * Line-truncates first (a natural boundary), then byte-truncates at the last
53
+ * newline before the cap so a line is never cut mid-way. Content within both
54
+ * caps is returned trimmed and unchanged.
55
+ *
56
+ * @param raw the raw memory-file text
57
+ * @returns the prompt-safe body (possibly with a trailing truncation warning)
58
+ */
59
+ export function truncateEntrypointContent(raw: string): string {
60
+ const trimmed = raw.trim();
61
+ const contentLines = trimmed.split("\n");
62
+ const lineCount = contentLines.length;
63
+ const byteCount = trimmed.length;
64
+ const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES;
65
+ const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES;
66
+ if (!wasLineTruncated && !wasByteTruncated) {
67
+ return trimmed;
68
+ }
69
+ let truncated = wasLineTruncated ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join("\n") : trimmed;
70
+ if (truncated.length > MAX_ENTRYPOINT_BYTES) {
71
+ const cutAt = truncated.lastIndexOf("\n", MAX_ENTRYPOINT_BYTES);
72
+ truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES);
73
+ }
74
+ const reason = wasByteTruncated && !wasLineTruncated
75
+ ? `${byteCount} bytes (limit: ${MAX_ENTRYPOINT_BYTES}) \u2014 index entries are too long`
76
+ : wasLineTruncated && !wasByteTruncated
77
+ ? `${lineCount} lines (limit: ${MAX_ENTRYPOINT_LINES})`
78
+ : `${lineCount} lines and ${byteCount} bytes`;
79
+ return `${truncated}\n\n> WARNING: ${MEMORY_ENTRYPOINT} is ${reason}. Only part of it was loaded. Keep index entries to one line under ~200 chars; move detail into topic files.`;
80
+ }
81
+
82
+ /**
83
+ * The per-cwd memory directory under the profile dir: `<profileDir>/memory/--<slug>--`.
84
+ *
85
+ * The cwd is slugged — every non-alphanumeric run collapsed to a single dash —
86
+ * and wrapped in `--…--` markers, the SAME scheme `sessionScopeDir` uses for the
87
+ * session transcript layout, so the two partitionings line up. The directory
88
+ * lives under `workspace.profileDir`, never inside the repo, so persisted memory
89
+ * never pollutes the user's working tree.
90
+ *
91
+ * @param workspace the resolved on-disk layout (supplies the profile dir)
92
+ * @param cwd the run's working directory
93
+ */
94
+ export function memoryDirFor(workspace: Workspace, cwd: string): string {
95
+ const slug = cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
96
+ return join(workspace.profileDir, "memory", `--${slug}--`);
97
+ }
98
+
99
+ /**
100
+ * A synchronous, disk-backed {@link MemoryStore} persisting the working-memory
101
+ * note to `<memDir>/MEMORY.md`.
102
+ *
103
+ * Satisfies the `memory` card's narrow three-method port. STRICTLY synchronous:
104
+ * the card validates that each of `read`/`replace`/`append` is a function and
105
+ * calls them inline, so any async variant would break the contract and silently
106
+ * fall back to the in-memory store. Writes create the directory and use mode
107
+ * `0o600` (owner-only), matching the auth vault.
108
+ */
109
+ export class DiskMemoryStore implements MemoryStore {
110
+ private readonly memDir: string;
111
+ private readonly file: string;
112
+
113
+ constructor(memDir: string) {
114
+ this.memDir = memDir;
115
+ this.file = join(memDir, MEMORY_ENTRYPOINT);
116
+ }
117
+
118
+ /** The current note, or `""` when no file has been written yet. */
119
+ read(): string {
120
+ try {
121
+ return existsSync(this.file) ? readFileSync(this.file, "utf8") : "";
122
+ } catch {
123
+ return "";
124
+ }
125
+ }
126
+
127
+ /** Overwrite the note, creating the memory directory as needed (mode 0o600). */
128
+ replace(content: string): void {
129
+ mkdirSync(this.memDir, { recursive: true });
130
+ writeFileSync(this.file, content, { mode: 384 });
131
+ }
132
+
133
+ /** Add a single line to the end of the note (composed of read + replace). */
134
+ append(line: string): void {
135
+ const current = this.read();
136
+ const next = current.length === 0 ? line : `${current}\n${line}`;
137
+ this.replace(next);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Load the per-cwd memory note as a {@link ContextDoc} for the briefing's
143
+ * `# Project context` section, or `undefined` when there is nothing to show.
144
+ *
145
+ * An absent file, an empty/whitespace-only note, or any read error yields
146
+ * `undefined` (no project-context block); a non-empty note is capped by
147
+ * {@link truncateEntrypointContent} before inlining. The doc's `path` is the
148
+ * bare `MEMORY.md` label, not the on-disk location, so the heading reads cleanly.
149
+ *
150
+ * @param memDir the per-cwd memory directory from {@link memoryDirFor}
151
+ */
152
+ export function loadMemoryDoc(memDir: string): ContextDoc | undefined {
153
+ let raw: string | undefined;
154
+ try {
155
+ const file = join(memDir, MEMORY_ENTRYPOINT);
156
+ if (!existsSync(file)) return undefined;
157
+ raw = readFileSync(file, "utf8");
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ if (raw === undefined) return undefined;
162
+ const body = truncateEntrypointContent(raw);
163
+ if (body.length === 0) return undefined;
164
+ return { path: MEMORY_ENTRYPOINT, body };
165
+ }
166
+
167
+ /** Re-exported so hosts wiring a store can use one import. */
168
+ export { MEMORY_HANDLE_KEY } from "../../capability-deck/index.js";