@matterailab/orbcode 0.1.8 → 0.1.13

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/README.md CHANGED
@@ -24,6 +24,7 @@ activity rows, edit/command approvals, and todo tracking.
24
24
  - [Approvals & safety](#approvals--safety)
25
25
  - [Headless mode](#headless-mode)
26
26
  - [Configuration](#configuration)
27
+ - [Hooks](#hooks)
27
28
  - [Architecture](#architecture)
28
29
  - [Tools](#tools)
29
30
  - [Agent loop](#agent-loop)
@@ -291,9 +292,9 @@ Two kinds of files under `~/.orbcode/`:
291
292
 
292
293
  All keys are optional. `customModels` entries appear in the `/model` picker
293
294
  alongside the built-in Axon models; `baseUrl` points the chat client at any
294
- OpenAI-compatible gateway; `env` is applied to the process at startup.
295
- Precedence: env vars > project settings.json > user settings.json >
296
- config.json.
295
+ OpenAI-compatible gateway; `env` is applied to the process at startup; `hooks`
296
+ configures lifecycle hooks (see [Hooks](#hooks)). Precedence: env vars > project
297
+ settings.json > user settings.json > config.json.
297
298
 
298
299
  Sessions are stored in `~/.orbcode/sessions/<id>.json` and power `/resume`
299
300
  and `--resume <id>`.
@@ -312,6 +313,97 @@ and `--resume <id>`.
312
313
  approval prompts (dangerous commands still always prompt); shift+tab cycles
313
314
  them at runtime.
314
315
 
316
+ ## Hooks
317
+
318
+ Hooks are shell commands OrbCode runs at fixed points in the agent loop — use
319
+ them to **block** dangerous actions, **auto-approve** trusted ones, **rewrite**
320
+ tool inputs, **inject context** into the model, **format code** after edits,
321
+ **notify** you, or **keep the agent working** until a condition is met. They use
322
+ the **same contract as Claude Code's hooks**, so scripts written for it work
323
+ here (just use `$ORBCODE_PROJECT_DIR` and OrbCode's tool names).
324
+
325
+ > šŸ“– **This is the overview. The complete, example-driven reference —
326
+ > per-event input/output, a copy-paste cookbook, debugging, and security — is in
327
+ > [docs/HOOKS.md](https://github.com/MatterAIOrg/OrbCode/blob/main/docs/HOOKS.md).**
328
+
329
+ ### Two-minute example
330
+
331
+ Make OrbCode block `rm -rf` and append the git branch to every prompt.
332
+
333
+ **1.** Drop a guard script at `~/.orbcode/hooks/guard.sh` (and `chmod +x` it):
334
+
335
+ ```bash
336
+ #!/usr/bin/env bash
337
+ input=$(cat) # OrbCode sends JSON on stdin
338
+ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
339
+ if printf '%s' "$cmd" | grep -Eq 'rm -rf (/|~|\*)'; then
340
+ echo "Refusing destructive command: $cmd" >&2 # stderr = the reason
341
+ exit 2 # exit 2 = block the tool
342
+ fi
343
+ exit 0
344
+ ```
345
+
346
+ **2.** Register it in `~/.orbcode/settings.json` (user-level) or a project's
347
+ `.orbcode/settings.json` — both are **merged**, so projects can add hooks
348
+ without clobbering your global ones. (User hooks always run; **project hooks are
349
+ disabled until you approve them** in a one-time trust prompt, since they run
350
+ shell commands from a repo — see [Security](https://github.com/MatterAIOrg/OrbCode/blob/main/docs/HOOKS.md#security).)
351
+
352
+ ```json
353
+ {
354
+ "hooks": {
355
+ "PreToolUse": [
356
+ {
357
+ "matcher": "execute_command",
358
+ "hooks": [{ "type": "command", "command": "~/.orbcode/hooks/guard.sh", "timeout": 30 }]
359
+ }
360
+ ],
361
+ "UserPromptSubmit": [
362
+ { "hooks": [{ "type": "command", "command": "echo \"Git branch: $(git branch --show-current 2>/dev/null)\"" }] }
363
+ ]
364
+ }
365
+ }
366
+ ```
367
+
368
+ Start OrbCode normally — that's all. Each event maps to a list of matchers; a
369
+ matcher has an optional `matcher` regex (omit, or use `"*"`, to match
370
+ everything; the regex is auto-anchored so `"execute_command"` matches exactly
371
+ that tool name) and a list of `command` hooks (`timeout` is per-command
372
+ seconds, default 10).
373
+
374
+ ### Events at a glance
375
+
376
+ | event | when it fires | matcher tests |
377
+ | ------------------ | ---------------------------------------------- | ------------- |
378
+ | `SessionStart` | first turn of a session (or after `--resume`) | `source` |
379
+ | `UserPromptSubmit` | before each prompt is sent to the model | — |
380
+ | `PreToolUse` | before a tool runs (and before its approval) | tool name |
381
+ | `PostToolUse` | after a tool returns | tool name |
382
+ | `Notification` | when OrbCode needs permission or a follow-up | — |
383
+ | `Stop` | when the model is about to finish the turn | — |
384
+ | `PreCompact` | before `/compact` summarizes the conversation | `trigger` |
385
+ | `SessionEnd` | on quit, `/logout`, or end of a `-p` run | `reason` |
386
+ | `SubagentStop` | reserved; OrbCode has no subagents yet | — |
387
+
388
+ ### How a hook talks back
389
+
390
+ A hook receives a JSON payload on **stdin** (`session_id`, `transcript_path`,
391
+ `cwd`, `hook_event_name`, plus event fields like `tool_name`/`tool_input`,
392
+ `prompt`, …) and influences OrbCode via its **exit code** — `0` success
393
+ (stdout becomes context for `UserPromptSubmit`/`SessionStart`), `2` block
394
+ (stderr is the reason), other = non-blocking warning — and/or a **JSON object
395
+ on stdout** for fine control (`decision`, `continue`, `systemMessage`, and a
396
+ `hookSpecificOutput` with `permissionDecision` allow/deny/ask, `updatedInput`,
397
+ `additionalContext`). When several hooks match, they run in parallel, the most
398
+ restrictive permission wins (`deny` > `ask` > `allow`), and a failing/slow hook
399
+ is timed out and never crashes the agent. Hooks run with a **redacted
400
+ environment** (your API token and other credential-like vars are stripped) and
401
+ injected context is wrapped in `<hook_context>` tags the model treats as
402
+ untrusted.
403
+
404
+ **→ Full reference with worked recipes for every event:
405
+ [docs/HOOKS.md](https://github.com/MatterAIOrg/OrbCode/blob/main/docs/HOOKS.md).**
406
+
315
407
  ## Architecture
316
408
 
317
409
  ```
@@ -336,6 +428,7 @@ src/
336
428
  core/
337
429
  agent.ts the agent loop (see below)
338
430
  events.ts AgentEvent model consumed by the UI
431
+ hooks.ts lifecycle hooks engine, Claude-Code compatible (see Hooks)
339
432
  ui/
340
433
  App.tsx main Ink app: static finalized rows + dynamic streaming area
341
434
  LoginView.tsx device-flow login screen with paste fallback
package/bin/orbcode.js CHANGED
@@ -1,2 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // Override Node's default process title so terminals (iTerm2 "current job name",
3
+ // VSCode terminal status, etc.) don't append " (node)" next to our own title.
4
+ process.title = "orbcode"
2
5
  import "../dist/index.js"
@@ -1,7 +1,9 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { DEFAULT_MODEL_ID, isValidAxonModel, registerCustomModels } from "../api/models.js";
6
+ import { isHookEvent } from "../core/hooks.js";
5
7
  const DEFAULTS = {
6
8
  model: DEFAULT_MODEL_ID,
7
9
  autoApproveEdits: false,
@@ -27,6 +29,19 @@ function getConfigPath() {
27
29
  export function getSettingsPaths(cwd = process.cwd()) {
28
30
  return [path.join(getConfigDir(), "settings.json"), path.join(cwd, ".orbcode", "settings.json")];
29
31
  }
32
+ /** Concatenate a settings file's `hooks` block into the accumulator, keeping
33
+ * only known events and well-formed matcher arrays. */
34
+ function mergeHooksInto(target, raw) {
35
+ if (!raw || typeof raw !== "object")
36
+ return;
37
+ for (const [event, matchers] of Object.entries(raw)) {
38
+ if (!isHookEvent(event))
39
+ continue;
40
+ if (!Array.isArray(matchers))
41
+ continue;
42
+ (target[event] ??= []).push(...matchers);
43
+ }
44
+ }
30
45
  function readJson(filePath) {
31
46
  try {
32
47
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -35,6 +50,74 @@ function readJson(filePath) {
35
50
  return undefined;
36
51
  }
37
52
  }
53
+ // --- Project-hook trust ---------------------------------------------------
54
+ // User-level hooks (~/.orbcode/settings.json) are written by the user and are
55
+ // always trusted. Project-level hooks (<cwd>/.orbcode/settings.json) ship
56
+ // inside a repo and could come from anyone, so they run arbitrary shell
57
+ // commands only after the user explicitly trusts them. Trust is keyed by
58
+ // project path + a hash of the hooks, so changing the hooks re-prompts.
59
+ function getProjectSettingsPath(cwd) {
60
+ return path.join(cwd, ".orbcode", "settings.json");
61
+ }
62
+ function getTrustStorePath() {
63
+ return path.join(getConfigDir(), "hook-trust.json");
64
+ }
65
+ /** Normalized project hooks (known events only), or undefined if none. */
66
+ function readProjectHooks(cwd) {
67
+ const merged = {};
68
+ mergeHooksInto(merged, readJson(getProjectSettingsPath(cwd))?.hooks);
69
+ return Object.keys(merged).length > 0 ? merged : undefined;
70
+ }
71
+ function hashHooks(hooks) {
72
+ return crypto.createHash("sha256").update(JSON.stringify(hooks)).digest("hex").slice(0, 16);
73
+ }
74
+ function isProjectHooksTrusted(cwd, hash) {
75
+ // Escape hatch for CI / non-interactive automation. Only honored when
76
+ // stdin is not a TTY (so a stray export in a shell rc file can't silently
77
+ // disable the trust gate for interactive sessions). A non-TTY check keeps
78
+ // the gate meaningful in the TUI while still letting CI opt in.
79
+ if (process.env.ORBCODE_TRUST_PROJECT_HOOKS === "1" && !process.stdin.isTTY)
80
+ return true;
81
+ const store = readJson(getTrustStorePath());
82
+ return Boolean(store && store[cwd] === hash);
83
+ }
84
+ /** Persist trust for the current project's hooks (call after the user agrees). */
85
+ export function trustProjectHooks(cwd = process.cwd()) {
86
+ const hooks = readProjectHooks(cwd);
87
+ if (!hooks)
88
+ return;
89
+ const store = readJson(getTrustStorePath()) ?? {};
90
+ store[cwd] = hashHooks(hooks);
91
+ try {
92
+ fs.mkdirSync(getConfigDir(), { recursive: true });
93
+ fs.writeFileSync(getTrustStorePath(), JSON.stringify(store, null, "\t") + "\n", { mode: 0o600 });
94
+ }
95
+ catch {
96
+ // best-effort; if it can't be persisted the user will simply be re-prompted
97
+ }
98
+ }
99
+ /**
100
+ * If this project defines hooks that are not yet trusted, return the list of
101
+ * shell commands awaiting approval; otherwise null (no project hooks, or already
102
+ * trusted). The TUI uses this to gate project hooks behind a trust prompt.
103
+ */
104
+ export function getPendingProjectHooks(cwd = process.cwd()) {
105
+ const hooks = readProjectHooks(cwd);
106
+ if (!hooks)
107
+ return null;
108
+ if (isProjectHooksTrusted(cwd, hashHooks(hooks)))
109
+ return null;
110
+ const commands = [];
111
+ for (const matchers of Object.values(hooks)) {
112
+ for (const matcher of matchers ?? []) {
113
+ for (const hook of matcher.hooks ?? []) {
114
+ if (hook?.command)
115
+ commands.push(hook.command);
116
+ }
117
+ }
118
+ }
119
+ return { commands };
120
+ }
38
121
  /** Make sure ~/.orbcode/settings.json exists (empty JSON) so users can find it. */
39
122
  function ensureUserSettingsFile() {
40
123
  try {
@@ -52,8 +135,9 @@ export function loadSettings() {
52
135
  ensureUserSettingsFile();
53
136
  const settings = { ...DEFAULTS, ...readJson(getConfigPath()) };
54
137
  // Layer settings.json files on top (user config dir, then project).
138
+ const cwd = process.cwd();
55
139
  const customModels = [];
56
- for (const settingsPath of getSettingsPaths()) {
140
+ for (const settingsPath of getSettingsPaths(cwd)) {
57
141
  const fileSettings = readJson(settingsPath);
58
142
  if (!fileSettings)
59
143
  continue;
@@ -71,6 +155,22 @@ export function loadSettings() {
71
155
  settings.customModels = customModels;
72
156
  registerCustomModels(customModels);
73
157
  }
158
+ // Hooks: user-level hooks always apply; project-level hooks run alongside
159
+ // them but only once trusted (they execute arbitrary shell commands). The
160
+ // two sets concatenate, so a trusted project adds to — never clobbers — your
161
+ // global hooks.
162
+ const mergedHooks = {};
163
+ mergeHooksInto(mergedHooks, readJson(path.join(getConfigDir(), "settings.json"))?.hooks);
164
+ const projectHooks = readProjectHooks(cwd);
165
+ if (projectHooks && isProjectHooksTrusted(cwd, hashHooks(projectHooks))) {
166
+ for (const [event, matchers] of Object.entries(projectHooks)) {
167
+ ;
168
+ (mergedHooks[event] ??= []).push(...matchers);
169
+ }
170
+ }
171
+ if (Object.keys(mergedHooks).length > 0) {
172
+ settings.hooks = mergedHooks;
173
+ }
74
174
  // env block from settings.json applies to this process.
75
175
  if (settings.env && typeof settings.env === "object") {
76
176
  for (const [key, value] of Object.entries(settings.env)) {
@@ -6,7 +6,8 @@ import { buildSystemPrompt } from "../prompts/system.js";
6
6
  import { describeToolCall, executeTool, getActiveTools, getApprovalKind, } from "../tools/index.js";
7
7
  import { walkFiles } from "../tools/executors/listFiles.js";
8
8
  import { previewFileChange } from "../tools/executors/files.js";
9
- import { saveSession } from "./sessions.js";
9
+ import { getSessionFilePath, saveSession } from "./sessions.js";
10
+ import { HookRunner } from "./hooks.js";
10
11
  const MAX_STEPS_PER_TURN = 50;
11
12
  const RESULT_PREVIEW_LINES = 6;
12
13
  function detectRepo(cwd) {
@@ -50,6 +51,11 @@ function getGitSummary(cwd) {
50
51
  function stripUserQueryTags(text) {
51
52
  return text.replace(/<user_query>\n?/g, "").replace(/\n?<\/user_query>/g, "");
52
53
  }
54
+ /** Wrap hook-injected context in clearly delimited tags so the model can
55
+ * distinguish it from user/system content (prompt-injection defense). */
56
+ function wrapHookContext(source, text) {
57
+ return `<hook_context source="${source}">\n${text}\n</hook_context>`;
58
+ }
53
59
  export class Agent {
54
60
  options;
55
61
  client;
@@ -61,16 +67,40 @@ export class Agent {
61
67
  sessionApproveCommands = false;
62
68
  abortController;
63
69
  totalCost = 0;
70
+ /**
71
+ * Latest context window usage (input + output tokens from the most recent
72
+ * `usage` chunk). Mirrors `totalCost` so the status bar can show it across
73
+ * /resume, /clear and process restarts.
74
+ */
75
+ contextTokens = 0;
64
76
  title = "";
65
77
  createdAt = new Date().toISOString();
78
+ hooks;
79
+ /** SessionStart fires once, lazily, before the first turn of this instance. */
80
+ sessionStarted = false;
81
+ /** carries SessionStart additionalContext into the first user message */
82
+ pendingStartContext = "";
83
+ /** guards Stop-hook forced continuation against infinite loops */
84
+ stopHookActive = false;
85
+ /** in-flight fire-and-forget hook promises (Notification), awaited on exit */
86
+ pendingBackground = new Set();
66
87
  taskId;
67
88
  constructor(options) {
68
89
  this.options = options;
69
90
  this.taskId = options.resume?.id ?? randomUUID();
91
+ this.hooks = new HookRunner({
92
+ cwd: options.cwd,
93
+ sessionId: this.taskId,
94
+ transcriptPath: getSessionFilePath(this.taskId),
95
+ config: options.hooks,
96
+ onSystemMessage: (message, isError) => options.callbacks.onEvent({ type: "system", message, isError }),
97
+ getSignal: () => this.abortController?.signal,
98
+ });
70
99
  if (options.resume) {
71
100
  this.messages = options.resume.messages;
72
101
  this.todos = options.resume.todos;
73
102
  this.totalCost = options.resume.totalCost;
103
+ this.contextTokens = options.resume.contextTokens;
74
104
  this.title = options.resume.title;
75
105
  this.createdAt = options.resume.createdAt;
76
106
  this.firstMessageSent = this.messages.length > 0;
@@ -100,11 +130,25 @@ export class Agent {
100
130
  get modelId() {
101
131
  return this.options.modelId;
102
132
  }
133
+ /**
134
+ * Latest context window usage in tokens. Falls back to 0 for fresh
135
+ * sessions; equals the persisted value after a resume so the TUI can
136
+ * repopulate its `contextTokens` state without waiting for the next
137
+ * streaming chunk.
138
+ */
139
+ get lastContextTokens() {
140
+ return this.contextTokens;
141
+ }
103
142
  /** Replace the prompt-derived title with the backend-generated one. */
104
143
  setTitle(title) {
105
144
  this.title = title;
106
145
  this.persist();
107
146
  }
147
+ /** Swap the hook config mid-session (e.g. after the user trusts project hooks). */
148
+ setHooks(hooks) {
149
+ this.options.hooks = hooks;
150
+ this.hooks.setConfig(hooks);
151
+ }
108
152
  /** Update auto-approval behavior mid-session (shift+tab cycling in the TUI). */
109
153
  setApprovalMode(autoApproveEdits, autoApproveSafeCommands) {
110
154
  this.sessionApproveEdits = autoApproveEdits;
@@ -118,7 +162,11 @@ export class Agent {
118
162
  this.todos = "";
119
163
  this.firstMessageSent = false;
120
164
  this.totalCost = 0;
165
+ this.contextTokens = 0;
121
166
  this.title = "";
167
+ this.pendingStartContext = "";
168
+ this.sessionStarted = false;
169
+ this.stopHookActive = false;
122
170
  }
123
171
  /** Write the current conversation to the sessions directory. */
124
172
  persist() {
@@ -133,6 +181,7 @@ export class Agent {
133
181
  createdAt: this.createdAt,
134
182
  updatedAt: new Date().toISOString(),
135
183
  totalCost: this.totalCost,
184
+ contextTokens: this.contextTokens,
136
185
  todos: this.todos,
137
186
  messages: this.messages,
138
187
  });
@@ -188,6 +237,21 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
188
237
  async runTurn(userText) {
189
238
  const { onEvent } = this.options.callbacks;
190
239
  this.abortController = new AbortController();
240
+ await this.maybeFireSessionStart();
241
+ // UserPromptSubmit may block the prompt outright or attach extra context.
242
+ let promptContext = "";
243
+ if (this.hooks.hasHooks("UserPromptSubmit")) {
244
+ const result = await this.hooks.run("UserPromptSubmit", { prompt: userText });
245
+ if (result.blocked || result.stopAll) {
246
+ const reason = result.blockReason || result.stopReason || "Prompt blocked by a hook.";
247
+ onEvent({ type: "system", message: reason, isError: true });
248
+ this.abortController = undefined;
249
+ onEvent({ type: "turn-end" });
250
+ return;
251
+ }
252
+ if (result.additionalContext)
253
+ promptContext = result.additionalContext;
254
+ }
191
255
  if (!this.title) {
192
256
  this.title = userText.replace(/\s+/g, " ").trim().slice(0, 80);
193
257
  }
@@ -196,12 +260,27 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
196
260
  userContent = `${this.buildEnvironmentDetails()}\n\n${userContent}`;
197
261
  this.firstMessageSent = true;
198
262
  }
263
+ // SessionStart context sits above the prompt; UserPromptSubmit context
264
+ // is appended after it. Both reach the model but neither is shown as
265
+ // user-typed text (they live outside the <user_query> markers).
266
+ if (this.pendingStartContext) {
267
+ userContent = `${wrapHookContext("SessionStart", this.pendingStartContext)}\n\n${userContent}`;
268
+ this.pendingStartContext = "";
269
+ }
270
+ if (promptContext) {
271
+ userContent = `${userContent}\n\n${wrapHookContext("UserPromptSubmit", promptContext)}`;
272
+ }
199
273
  this.messages.push({ role: "user", content: userContent });
200
274
  try {
275
+ this.stopHookActive = false;
201
276
  for (let step = 0; step < MAX_STEPS_PER_TURN; step++) {
202
277
  const done = await this.runStep();
203
- if (done)
204
- break;
278
+ if (!done)
279
+ continue;
280
+ // The model is ready to stop; Stop hooks may force it to continue.
281
+ if (await this.shouldContinueAfterStop())
282
+ continue;
283
+ break;
205
284
  }
206
285
  }
207
286
  catch (error) {
@@ -223,6 +302,65 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
223
302
  onEvent({ type: "turn-end" });
224
303
  }
225
304
  }
305
+ /** Fire SessionStart once per instance, stashing any injected context for
306
+ * the first user message. */
307
+ async maybeFireSessionStart() {
308
+ if (this.sessionStarted)
309
+ return;
310
+ this.sessionStarted = true;
311
+ if (!this.hooks.hasHooks("SessionStart"))
312
+ return;
313
+ const result = await this.hooks.run("SessionStart", {
314
+ source: this.options.resume ? "resume" : "startup",
315
+ });
316
+ if (result.additionalContext)
317
+ this.pendingStartContext = result.additionalContext;
318
+ }
319
+ /** Ask Stop hooks whether the turn should keep going. Forces at most one
320
+ * continuation per turn (stop_hook_active) so a hook can't loop forever. */
321
+ async shouldContinueAfterStop() {
322
+ if (!this.hooks.hasHooks("Stop"))
323
+ return false;
324
+ const result = await this.hooks.run("Stop", { stop_hook_active: this.stopHookActive });
325
+ if (result.stopAll)
326
+ return false;
327
+ if (result.blocked && !this.stopHookActive) {
328
+ this.stopHookActive = true;
329
+ const reason = result.blockReason || "A Stop hook asked you to keep going.";
330
+ this.messages.push({ role: "user", content: `System reminder (Stop hook): ${reason}` });
331
+ return true;
332
+ }
333
+ return false;
334
+ }
335
+ /** Track a fire-and-forget hook promise so it can be awaited on exit. */
336
+ trackBackground(p) {
337
+ this.pendingBackground.add(p);
338
+ p.finally(() => this.pendingBackground.delete(p));
339
+ }
340
+ /** Wait (up to `timeoutMs`) for in-flight background hooks to settle. */
341
+ async awaitBackground(timeoutMs) {
342
+ if (this.pendingBackground.size === 0)
343
+ return;
344
+ const all = Promise.allSettled([...this.pendingBackground]);
345
+ const cap = new Promise((resolve) => {
346
+ const t = setTimeout(resolve, timeoutMs);
347
+ t.unref?.();
348
+ });
349
+ await Promise.race([all, cap]);
350
+ }
351
+ /** Fire SessionEnd hooks. Best-effort; never blocks shutdown. */
352
+ async endSession(reason) {
353
+ // Let in-flight Notification hooks settle before the final SessionEnd.
354
+ await this.awaitBackground(3000);
355
+ if (!this.hooks.hasHooks("SessionEnd"))
356
+ return;
357
+ try {
358
+ await this.hooks.run("SessionEnd", { reason });
359
+ }
360
+ catch {
361
+ // a SessionEnd hook must never prevent the app from exiting
362
+ }
363
+ }
226
364
  /** Summarize the conversation so far and replace history with the summary. */
227
365
  async compact() {
228
366
  const { onEvent } = this.options.callbacks;
@@ -231,6 +369,20 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
231
369
  onEvent({ type: "turn-end" });
232
370
  return;
233
371
  }
372
+ // Covers the resume-then-immediately-/compact path, so SessionStart
373
+ // always fires before any SessionEnd.
374
+ await this.maybeFireSessionStart();
375
+ // If SessionStart produced context, fold it into the compaction request
376
+ // rather than letting it linger for the next turn.
377
+ let startContext = "";
378
+ if (this.pendingStartContext) {
379
+ startContext = wrapHookContext("SessionStart", this.pendingStartContext) + "\n\n";
380
+ this.pendingStartContext = "";
381
+ }
382
+ // PreCompact runs before summarizing (it cannot cancel compaction).
383
+ if (this.hooks.hasHooks("PreCompact")) {
384
+ await this.hooks.run("PreCompact", { trigger: "manual", custom_instructions: "" });
385
+ }
234
386
  this.abortController = new AbortController();
235
387
  const signal = this.abortController.signal;
236
388
  try {
@@ -238,7 +390,8 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
238
390
  ...this.outgoingMessages(),
239
391
  {
240
392
  role: "user",
241
- content: "Summarize this conversation so it can replace the full history. Capture the user's goals, decisions made, files created or modified (with paths), important code details, and any remaining next steps. Be thorough but concise. Respond with only the summary.",
393
+ content: startContext +
394
+ "Summarize this conversation so it can replace the full history. Capture the user's goals, decisions made, files created or modified (with paths), important code details, and any remaining next steps. Be thorough but concise. Respond with only the summary.",
242
395
  },
243
396
  ];
244
397
  let summary = "";
@@ -251,6 +404,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
251
404
  }
252
405
  else if (chunk.type === "usage") {
253
406
  this.totalCost += chunk.totalCost ?? 0;
407
+ this.contextTokens = (chunk.inputTokens ?? 0) + (chunk.outputTokens ?? 0);
254
408
  onEvent({
255
409
  type: "usage",
256
410
  inputTokens: chunk.inputTokens,
@@ -330,6 +484,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
330
484
  break;
331
485
  case "usage":
332
486
  this.totalCost += chunk.totalCost ?? 0;
487
+ this.contextTokens = (chunk.inputTokens ?? 0) + (chunk.outputTokens ?? 0);
333
488
  onEvent({
334
489
  type: "usage",
335
490
  inputTokens: chunk.inputTokens,
@@ -390,7 +545,6 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
390
545
  });
391
546
  return message;
392
547
  }
393
- const summary = describeToolCall(toolCall.name, args);
394
548
  if (toolCall.name === "attempt_completion") {
395
549
  onEvent({ type: "completion", result: String(args.result ?? "") });
396
550
  return "The user has been shown the completion result.";
@@ -400,9 +554,54 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
400
554
  const suggestions = (Array.isArray(args.follow_up) ? args.follow_up : [])
401
555
  .map((s) => ({ text: String(s?.text ?? "") }))
402
556
  .filter((s) => s.text);
557
+ // Notification fires whenever OrbCode pauses to wait on the user.
558
+ if (this.hooks.hasHooks("Notification")) {
559
+ this.trackBackground(this.hooks.run("Notification", {
560
+ message: question || "OrbCode is asking a follow-up question.",
561
+ }));
562
+ }
403
563
  const answer = await requestFollowup(question, suggestions);
404
564
  return `<answer>\n${answer}\n</answer>`;
405
565
  }
566
+ // PreToolUse runs before approval/execution. It can block the call,
567
+ // override the approval decision, rewrite the tool input, or add context.
568
+ let preContext = "";
569
+ let bypassApproval = false;
570
+ let forceApproval = false;
571
+ if (this.hooks.hasHooks("PreToolUse")) {
572
+ const pre = await this.hooks.run("PreToolUse", { tool_name: toolCall.name, tool_input: args });
573
+ if (pre.stopAll || pre.blocked || pre.permissionDecision === "deny") {
574
+ const reason = pre.blockReason || pre.stopReason || pre.permissionReason || "Blocked by a PreToolUse hook.";
575
+ const blockedSummary = describeToolCall(toolCall.name, args);
576
+ onEvent({ type: "tool-start", id: toolCall.id, name: toolCall.name, summary: blockedSummary });
577
+ onEvent({
578
+ type: "tool-end",
579
+ id: toolCall.id,
580
+ name: toolCall.name,
581
+ summary: blockedSummary,
582
+ resultPreview: reason,
583
+ isError: true,
584
+ });
585
+ if (pre.stopAll)
586
+ this.abortController?.abort();
587
+ return reason;
588
+ }
589
+ if (pre.updatedInput) {
590
+ args = pre.updatedInput;
591
+ onEvent({
592
+ type: "system",
593
+ message: `PreToolUse hook rewrote the input for ${toolCall.name}.`,
594
+ isError: false,
595
+ });
596
+ }
597
+ if (pre.permissionDecision === "allow")
598
+ bypassApproval = true;
599
+ if (pre.permissionDecision === "ask")
600
+ forceApproval = true;
601
+ if (pre.additionalContext)
602
+ preContext = pre.additionalContext;
603
+ }
604
+ const summary = describeToolCall(toolCall.name, args);
406
605
  onEvent({ type: "tool-start", id: toolCall.id, name: toolCall.name, summary });
407
606
  const approvalKind = getApprovalKind(toolCall.name, args);
408
607
  const diff = approvalKind === "edit" ? previewFileChange(toolCall.name, args, this.options.cwd) : undefined;
@@ -413,7 +612,18 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
413
612
  if (approvalKind === "command") {
414
613
  needsApproval = isDangerous || !(this.sessionApproveCommands || this.options.autoApproveSafeCommands);
415
614
  }
615
+ // A PreToolUse hook can force the approval prompt ("ask") or skip it ("allow").
616
+ if (forceApproval)
617
+ needsApproval = true;
618
+ else if (bypassApproval)
619
+ needsApproval = false;
416
620
  if (needsApproval) {
621
+ // Notification fires when OrbCode needs the user to grant permission.
622
+ if (this.hooks.hasHooks("Notification")) {
623
+ this.trackBackground(this.hooks.run("Notification", {
624
+ message: `OrbCode needs your permission to use ${toolCall.name}`,
625
+ }));
626
+ }
417
627
  const decision = await requestApproval({
418
628
  kind: approvalKind,
419
629
  toolName: toolCall.name,
@@ -442,7 +652,30 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
442
652
  }
443
653
  }
444
654
  const result = await executeTool(toolCall.name, args, this.toolContext());
445
- const previewLines = result.text.split("\n");
655
+ // PostToolUse can feed extra context (or a block reason) back to the
656
+ // model. PreToolUse additionalContext is delivered here too.
657
+ let resultText = result.text;
658
+ const extras = [];
659
+ if (preContext)
660
+ extras.push(wrapHookContext("PreToolUse", preContext));
661
+ if (this.hooks.hasHooks("PostToolUse")) {
662
+ const post = await this.hooks.run("PostToolUse", {
663
+ tool_name: toolCall.name,
664
+ tool_input: args,
665
+ tool_response: result.text,
666
+ });
667
+ if (post.additionalContext)
668
+ extras.push(wrapHookContext("PostToolUse", post.additionalContext));
669
+ if (post.blocked && post.blockReason)
670
+ extras.push(`[PostToolUse hook]: ${post.blockReason}`);
671
+ if (post.stopAll) {
672
+ this.abortController?.abort();
673
+ onEvent({ type: "system", message: "A PostToolUse hook stopped the turn.", isError: false });
674
+ }
675
+ }
676
+ if (extras.length)
677
+ resultText += `\n\n${extras.join("\n\n")}`;
678
+ const previewLines = resultText.split("\n");
446
679
  const resultPreview = previewLines.slice(0, RESULT_PREVIEW_LINES).join("\n") +
447
680
  (previewLines.length > RESULT_PREVIEW_LINES ? `\n… (${previewLines.length} lines)` : "");
448
681
  onEvent({
@@ -454,6 +687,6 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
454
687
  isError: Boolean(result.isError),
455
688
  diff: result.isError ? undefined : diff,
456
689
  });
457
- return result.text;
690
+ return resultText;
458
691
  }
459
692
  }