@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 +96 -3
- package/bin/orbcode.js +3 -0
- package/dist/config/settings.js +101 -1
- package/dist/core/agent.js +240 -7
- package/dist/core/hooks.js +358 -0
- package/dist/core/sessions.js +5 -1
- package/dist/headless.js +14 -1
- package/dist/index.js +4 -0
- package/dist/prompts/system.js +4 -0
- package/dist/ui/App.js +91 -11
- package/dist/ui/components/HookTrustPrompt.js +21 -0
- package/dist/ui/components/StatusBar.js +10 -17
- package/package.json +1 -1
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
|
|
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
package/dist/config/settings.js
CHANGED
|
@@ -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)) {
|
package/dist/core/agent.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
690
|
+
return resultText;
|
|
458
691
|
}
|
|
459
692
|
}
|