aegis-desktop 1.0.12 → 1.0.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.
@@ -0,0 +1,171 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
+ // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
+ // Conveying a modified copy, or running one as a network service, owes the
5
+ // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
+ 'use strict';
7
+
8
+ /**
9
+ * google-connect.js — the desktop's Google identity + Drive flow.
10
+ *
11
+ * Two distinct steps, run in this order because the second depends on the
12
+ * first having actually landed:
13
+ *
14
+ * 1. Sign in with Google (`client/google-signin.js`'s local round-trip) —
15
+ * replaces this machine's saved AEGIS key with the one tied to that
16
+ * Google identity, exactly as `aegiscode connect` already does for the
17
+ * CLI (cli/src/connect.js). Desktop never had this wiring before this
18
+ * file; the only Google sign-in that existed anywhere in this product
19
+ * was the CLI's.
20
+ * 2. Connect Drive (`client/drive-connect.js`, which opens `/connect/drive`
21
+ * in the system browser and polls `/api/drive/token`) — grants the
22
+ * `drive.file` scope to WHICHEVER Google identity that browser's
23
+ * consent screen uses. That is only the SAME account as step 1 if step
24
+ * 1 ran first on this machine: `/connect/drive`'s callback resolves an
25
+ * account purely by Google identity, with no notion of "the account
26
+ * this machine's API key already belongs to" — so connecting Drive
27
+ * before signing in attaches the tokens to a DIFFERENT account than the
28
+ * one this machine will poll, and the poll just times out. `connectDrive`
29
+ * refuses outright when this machine has no key at all, but cannot
30
+ * detect "has a key, but it is an anonymous one" — that failure still
31
+ * surfaces, just later, as a timeout with the right wording.
32
+ *
33
+ * Electron-free and fully injected — same shape as `account-key.js` next to
34
+ * it — so the whole flow runs under a test with a stub `aegis` client, a
35
+ * stub `googleSignIn`/`driveConnect`/`driveUpload`, and no browser, window or
36
+ * network.
37
+ */
38
+
39
+ function createGoogleConnect({
40
+ aegis,
41
+ credentials,
42
+ persistKey,
43
+ openUrl,
44
+ googleSignIn,
45
+ driveConnect,
46
+ driveUpload,
47
+ onAccountChange,
48
+ }) {
49
+ let signInInFlight = null;
50
+ let connectInFlight = null;
51
+ let lastSyncAt = null;
52
+ let lastSyncError = null;
53
+
54
+ /** Sign this machine in with Google, replacing whatever key it had. */
55
+ function signInWithGoogle() {
56
+ if (!signInInFlight) {
57
+ signInInFlight = (async () => {
58
+ try {
59
+ // Everything from here on (binding the local callback server,
60
+ // opening the browser, waiting for the round-trip) can fail, and
61
+ // every caller of this module — the IPC handler in main.js chief
62
+ // among them — is promised `{ ok, … }`/`{ ok: false, error }`,
63
+ // never a rejection. One try covers the whole flow instead of only
64
+ // the `session.wait()` half of it.
65
+ let session;
66
+ try {
67
+ session = await googleSignIn.startGoogleSignIn({ apiBase: aegis.apiBase });
68
+ } catch (e) {
69
+ return { ok: false, error: `could not start the local sign-in server — ${(e && e.message) || e}` };
70
+ }
71
+ const opened = Boolean(openUrl(session.url));
72
+ let result;
73
+ try {
74
+ result = await session.wait();
75
+ } catch (e) {
76
+ return { ok: false, opened, url: session.url, error: (e && e.message) || String(e) };
77
+ }
78
+ // Live client first: the very next call this process makes (e.g.
79
+ // the renderer asking for drive status right after this resolves)
80
+ // must see the new identity, not whatever key this process started
81
+ // with.
82
+ aegis.setApiKey(result.apiKey);
83
+ persistKey(result.apiKey);
84
+ if (result.memoryToken && credentials && typeof credentials.saveMemoryToken === 'function') {
85
+ try {
86
+ credentials.saveMemoryToken(result.memoryToken);
87
+ } catch {
88
+ /* best-effort — the account key itself is already saved */
89
+ }
90
+ }
91
+ if (typeof onAccountChange === 'function') onAccountChange();
92
+ return { ok: true, opened, url: session.url, username: result.username || null };
93
+ } finally {
94
+ signInInFlight = null;
95
+ }
96
+ })();
97
+ }
98
+ return signInInFlight;
99
+ }
100
+
101
+ /** Open `/connect/drive` and wait for this account to show connected. */
102
+ function connectDrive() {
103
+ if (!aegis.apiKey) {
104
+ return Promise.resolve({
105
+ ok: false,
106
+ error: 'sign in with Google first — this device has no AEGIS account key yet',
107
+ });
108
+ }
109
+ if (!connectInFlight) {
110
+ connectInFlight = (async () => {
111
+ try {
112
+ const url = driveConnect.driveConnectUrl(aegis.apiBase);
113
+ const opened = Boolean(openUrl(url));
114
+ try {
115
+ const status = await driveConnect.waitForDriveConnected(aegis);
116
+ return { ok: true, opened, url, connected: status.connected, email: status.email };
117
+ } catch (e) {
118
+ return { ok: false, opened, url, error: (e && e.message) || String(e) };
119
+ }
120
+ } finally {
121
+ connectInFlight = null;
122
+ }
123
+ })();
124
+ }
125
+ return connectInFlight;
126
+ }
127
+
128
+ /** A one-shot read: signed in, and if so, is Drive connected. */
129
+ async function status() {
130
+ if (!aegis.apiKey) return { signedIn: false, connected: false, email: null };
131
+ const s = await driveConnect.driveStatus(aegis);
132
+ return { signedIn: true, ...s };
133
+ }
134
+
135
+ /** Pull this account's AEGIS cloud memory and back it up to Drive. */
136
+ async function syncMemoryNow() {
137
+ try {
138
+ const tokenInfo = await aegis.driveToken();
139
+ if (!tokenInfo || !tokenInfo.connected || !tokenInfo.access_token) {
140
+ throw new Error('Google Drive is not connected yet');
141
+ }
142
+ const pulled = await aegis.memoryPull(0);
143
+ const entries = (pulled && pulled.entries) || [];
144
+ const content = JSON.stringify({ exportedAt: new Date().toISOString(), entries }, null, 2);
145
+ const result = await driveUpload.uploadMemoryBackup(tokenInfo.access_token, content);
146
+ lastSyncAt = Date.now();
147
+ lastSyncError = null;
148
+ return {
149
+ ok: true,
150
+ count: entries.length,
151
+ fileId: result.fileId,
152
+ webViewLink: result.webViewLink,
153
+ updated: result.updated,
154
+ lastSyncAt,
155
+ };
156
+ } catch (e) {
157
+ lastSyncError = (e && e.message) || String(e);
158
+ return { ok: false, error: lastSyncError, lastSyncAt };
159
+ }
160
+ }
161
+
162
+ return {
163
+ signInWithGoogle,
164
+ connectDrive,
165
+ status,
166
+ syncMemoryNow,
167
+ state: () => ({ lastSyncAt, lastSyncError }),
168
+ };
169
+ }
170
+
171
+ module.exports = { createGoogleConnect };
@@ -167,6 +167,32 @@ function normalizeToolArgs(T, name, args, ctx) {
167
167
  return fn(name, args, ctx);
168
168
  }
169
169
 
170
+ /**
171
+ * Is `dir` inside a git work tree? Walks up the way git itself does, so a turn
172
+ * running in a subdirectory of a repo is still told it is one.
173
+ *
174
+ * Returns `'git'`, `''` (definitely not a repo), or `null` when the probe could
175
+ * not answer — the caller omits the line rather than asserting "not a repo",
176
+ * because a wrong negative is exactly the kind of fact a model will act on.
177
+ */
178
+ function detectVcs(dir) {
179
+ if (!dir) return null;
180
+ try {
181
+ const fs = require('node:fs');
182
+ const path = require('node:path');
183
+ let probe = path.resolve(String(dir));
184
+ for (let i = 0; i < 8; i += 1) {
185
+ if (fs.existsSync(path.join(probe, '.git'))) return 'git';
186
+ const up = path.dirname(probe);
187
+ if (up === probe) break;
188
+ probe = up;
189
+ }
190
+ return '';
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
170
196
  function hostEnv(base = {}, supplied = {}, payload = null) {
171
197
  const b = base || {};
172
198
  const s = supplied || {};
@@ -250,6 +276,16 @@ function hostEnv(base = {}, supplied = {}, payload = null) {
250
276
  // a `{ key, value }` facet, or a bare string.
251
277
  languages: pick('languages', b.languages),
252
278
  cwd: pick('cwd', cwd),
279
+ // Whether the turn's directory is a git work tree, probed once per turn.
280
+ // Measured reason: the work rules order the model to check prior work with
281
+ // `git log` before it starts, and a benchmark workspace (a temp dir holding
282
+ // three files) is NOT a repo. On every task the model dutifully spent its
283
+ // second round on `cd … && git log`, paid a failed exec, and carried the
284
+ // error text into every later round's prompt — one wasted round in seven,
285
+ // and a bigger prefix for the rest. The host already knew; this states it
286
+ // so the rule can say "skip the sources the workspace does not have" and
287
+ // mean something the model can act on without guessing.
288
+ vcs: pick('vcs', b.vcs != null ? b.vcs : detectVcs(cwd)),
253
289
  shell: pick('shell', shellName),
254
290
  shellKind: pick('shellKind', shellKind),
255
291
  roots: pick('roots', b.roots),
@@ -2563,7 +2599,34 @@ function createLocalEngine({
2563
2599
  // dispatch is summed, including the truncation retry and the synthesis
2564
2600
  // re-dispatch: both are real, separately billed provider calls.
2565
2601
  const turnUsage = { calls: 0 };
2566
- const USAGE_FIELDS = ['input_tokens', 'output_tokens', 'prompt_tokens', 'completion_tokens', 'total_tokens'];
2602
+ // The cache fields MUST be listed here, not just summed at the top level.
2603
+ // DeepSeek reports its hit count as `prompt_cache_hit_tokens` and its
2604
+ // miss count as `prompt_cache_miss_tokens`, and neither name was in this
2605
+ // list — so every tool-using turn dropped them on the way out and handed
2606
+ // its caller a usage object with no cache information at all. The CLI's
2607
+ // `--json` republishes exactly this accumulator, `usageBuckets` then had
2608
+ // nothing to read, and the published cost of an agentic run was priced
2609
+ // entirely at the miss rate. Measured on the 3-task sweep: `cache_read: 0`
2610
+ // on all three trials (tokens_in 210,904) while the row's own baseline
2611
+ // showed 72,448 cached tokens. The same defect the CLI's per-turn readout
2612
+ // was fixed for, here one layer below it.
2613
+ const USAGE_FIELDS = [
2614
+ 'input_tokens', 'output_tokens', 'prompt_tokens', 'completion_tokens', 'total_tokens',
2615
+ // Anthropic spelling (disjoint from its input count).
2616
+ 'cache_read_input_tokens', 'cache_creation_input_tokens',
2617
+ // DeepSeek/OpenAI spelling (both SUBSETs of the prompt count, which is
2618
+ // what `usageBuckets` detects and subtracts rather than double-billing).
2619
+ 'prompt_cache_hit_tokens', 'prompt_cache_miss_tokens',
2620
+ // Hidden deliberation, which every provider here bills as OUTPUT but
2621
+ // none of them split out for free. These were absent from the list
2622
+ // while `reasoningTokensOf` (below) already knew how to read them, so
2623
+ // the one place that could have separated "wrote more code" from
2624
+ // "thought longer" threw the split away at the accumulator and handed
2625
+ // its caller a single `output_tokens` with CoT folded in. A benchmark
2626
+ // row could therefore see that a turn cost 1.9x another's output and
2627
+ // still have no way to say which half moved.
2628
+ 'reasoning_tokens', 'thinking_tokens',
2629
+ ];
2567
2630
  const addUsage = (res) => {
2568
2631
  const u = res && res.usage;
2569
2632
  if (!u || typeof u !== 'object') return;
@@ -30,50 +30,62 @@
30
30
 
31
31
  /** Identity + work rules. Ported from the upstream engine reference. */
32
32
  const MAIN_CHAT_PROMPT =
33
- `You are Aegiscodex, a terminal coding assistant that works in the user's repository. ` +
34
- `Help with software engineering tasks: read and reason about code, write and edit files, ` +
35
- `run shell commands, and investigate bugs. Work rules:\n` +
33
+ `You are Aegiscodex, a terminal coding assistant working in the user's repository: read and ` +
34
+ `reason about code, write and edit files, run shell commands, investigate bugs. Work rules:\n` +
35
+ `- Output is billed by the token and is the largest line on the bill, so spend it where it is ` +
36
+ ` read. Before a tool call, one short sentence on what you are about to find out. Do NOT ` +
37
+ ` restate file contents, diffs, command output or test results the user can already see. ` +
38
+ ` Finish with the answer in a few lines — expand only when asked to explain, or when the ` +
39
+ ` change's correctness depends on a detail the user must check.\n` +
40
+ `- Batch independent tool calls into ONE turn: read several files, or grep for several ` +
41
+ ` patterns, at once, or use one exec command that does the whole job. Every round ` +
42
+ ` re-sends the prefix and bills fresh output; one call per round is the expensive shape.\n` +
36
43
  `- Use tools silently. A one-line reason is enough; do not narrate your plan as a story. ` +
37
44
  `- Never claim what a tool found or what a command returned before the tool actually runs. ` +
38
45
  ` Report only the results you really received. ` +
39
- `- Before you start a fix or feature the user asks for, check whether they have asked for ` +
40
- ` it before and whether it was already delivered: look in recalled memory, ` +
41
- ` ~/.aegiscode/queue.jsonl, PLAN.md/notes, and \`git log\` for the same demand. If it was ` +
42
- ` resolved before and the problem is back, report how it was resolved (the commit, file, or ` +
43
- ` mechanism that fixed it) and why it regressed, then fix the cause — never re-fix blind as ` +
44
- ` if the request were new. If it was never met, say so plainly before starting. ` +
45
- `- Match the response to the request. A greeting, a question about something you already ` +
46
- ` know, or a request for an opinion is answered in plain words with NO tools. Only reach ` +
47
- ` for a tool when the answer genuinely depends on something in this repo or on this ` +
48
- ` machine. "hey" is not a task.\n` +
46
+ `- Before starting a fix or feature, check whether they have asked for it before and whether ` +
47
+ ` it was delivered: recalled memory, \`queue.jsonl\`, \`PLAN.md\`/notes and \`git log\` — ` +
48
+ ` ONE batched command over sources that exist: if the Environment block says this ` +
49
+ ` directory has no version control, skip it. ` +
50
+ ` If it was resolved before and the problem is back, report how it was resolved and ` +
51
+ ` why it regressed, then fix the cause — never re-fix blind as if it were new. ` +
52
+ ` If it was never met, say so plainly before starting. ` +
53
+ `- Match the response to the request. A greeting, a question you already know the answer to, ` +
54
+ ` or a request for an opinion is answered in plain words with NO tools. Reach for a tool ` +
55
+ ` only when the answer depends on something in this repo or on this machine. ` +
56
+ ` "hey" is not a task.\n` +
49
57
  `- WHEN THE USER HAS ASKED FOR WORK: act, don't just inspect. After at most 2 rounds of ` +
50
58
  ` reading or exploration, start making changes with writeFile or editFile. Reconnaissance ` +
51
- ` is not progress — implement, then verify. This rule is about HOW to carry out a task you ` +
52
- ` were given; it is never a reason to invent one. Never write or modify a file the user ` +
59
+ ` is not progress — implement, then verify. This is about HOW to carry out a task you were ` +
60
+ ` given; it is never a reason to invent one. Never write or modify a file the user ` +
53
61
  ` did not ask you to touch.\n` +
54
- `- Zero tolerance for stalling: once the target file/cause is identified and only the edit ` +
55
- ` remains, make it in the same turn. Never end a turn with an empty or no-op response, or a ` +
56
- ` "let me continue"/"I'll now…" placeholder when the user says continue — that is not an ` +
57
- ` answer and it wastes their turn. This has been flagged as non-compliance before; treat it ` +
58
- ` as a hard rule. ` +
59
- `- When you have what you need, stop using tools and give a concise, direct answer to the ` +
60
- ` user's question. Never end your turn with an intention like "Let me check…" or "I'll now…" ` +
61
- ` — that is not an answer. ` +
62
+ `- Zero tolerance for stalling: once the cause is identified and only the edit remains, make ` +
63
+ ` it in the same turn. Never end a turn with an empty or no-op response, or a "let me ` +
64
+ ` continue"/"I'll now…" placeholder when the user says continue — that wastes their turn. ` +
65
+ ` This has been flagged as non-compliance before; treat it as a hard rule. ` +
66
+ `- When you have what you need, stop using tools and give a concise, direct answer. Never ` +
67
+ ` end a turn with an intention like "Let me check…" — that is not an answer. ` +
62
68
  `- When a focused multi-step sub-task can be delegated, use the task tool to spawn a ` +
63
- ` specialist subagent rather than doing everything inline. ` +
69
+ ` specialist subagent rather than doing it inline. ` +
64
70
  `- If the user references a workflow you don't recognize, inspect the repo/scripts for that ` +
65
- ` mechanism before acting — don't assume it means inline work.`;
71
+ ` mechanism before acting.`;
66
72
 
67
- /** The tools the desktop actually advertises (kept in step with tools.js). */
73
+ /**
74
+ * The tool line: what each tool is, and the two facts a schema cannot carry.
75
+ *
76
+ * It used to restate the path rule in full — absolute, `~` expansion, relative
77
+ * resolution — which `PATH_ARG_DESC` already says once per path argument in nine
78
+ * schemas. That duplication is what `d01068b` unwound from the schemas; this is
79
+ * the other end of it. What stays is the part no schema carries: the
80
+ * platform-specific example (the one form a model gets wrong) and the persistent
81
+ * shell, which is a property of the session rather than an argument.
82
+ */
68
83
  const TOOL_LINE =
69
- 'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute and ' +
70
- 'must be written in the form THIS machine uses — read the platform and home directory in the ' +
71
- 'Environment block below and use them: on win32 that means C:\\Users\\you\\Desktop\\file.txt, ' +
72
- 'not /home/you/Desktop/file.txt or /tmp/file.txt. "~" and "~/…" mean the home directory and are ' +
73
- 'expanded for you; a relative path resolves against the working directory. ' +
74
- 'exec runs in a persistent shell session on this machine — cd and exported env vars carry ' +
75
- 'across calls within the turn, like a real terminal. task spawns a specialist subagent ' +
76
- '(its own tool loop, same model) for a focused, self-contained piece of work.';
84
+ 'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute ' +
85
+ 'in the form THIS machine uses (see the Environment block): on win32 that means ' +
86
+ 'C:\\Users\\you\\Desktop\\file.txt, not /home/you/Desktop/file.txt or /tmp/file.txt. ' +
87
+ 'exec runs in a persistent shell session — cd and exported env vars carry across calls ' +
88
+ 'within the turn. task spawns a specialist subagent (its own tool loop, same model).';
77
89
 
78
90
  /**
79
91
  * One durable-language entry as a sentence fragment, accepting the three shapes
@@ -109,6 +121,7 @@ function environmentPreamble(env = {}) {
109
121
  localeName,
110
122
  languages,
111
123
  cwd,
124
+ vcs,
112
125
  shell,
113
126
  shellKind,
114
127
  roots,
@@ -164,8 +177,7 @@ function environmentPreamble(env = {}) {
164
177
  if (desktopdir) {
165
178
  bits.push(
166
179
  `desktop directory: ${desktopdir} — use this exact path when the user says "my desktop"; ` +
167
- 'do NOT build it yourself from the home directory, because it is not always a folder ' +
168
- 'called "Desktop" there',
180
+ 'do NOT build it yourself from the home directory',
169
181
  );
170
182
  }
171
183
  // Same failure mode as the desktop line, one directory over, and reported
@@ -176,13 +188,31 @@ function environmentPreamble(env = {}) {
176
188
  // missing path cannot tell a typo from an empty directory.
177
189
  if (dataDir) {
178
190
  bits.push(
179
- `AEGIS data directory: ${dataDir} — this is the store itself (config, sessions, ` +
191
+ `AEGIS data directory: ${dataDir} — the store itself (config, sessions, ` +
180
192
  'credentials, queue). Use this exact path; do NOT append it to the home directory ' +
181
193
  'yourself, because a dropped separator ("C:\\Users\\you.aegiscode") names a path that ' +
182
194
  'does not exist and looks like an empty directory rather than a mistake',
183
195
  );
184
196
  }
185
197
  if (cwd) bits.push(`working directory: ${cwd}`);
198
+ // The version-control fact, which turns the work rules' "check whether this
199
+ // was already delivered" from a guess into a decision. Measured: in a temp
200
+ // workspace with no `.git`, the model still ran `git log` on its second round
201
+ // of all three tasks, spent a round on a guaranteed failure, and carried the
202
+ // error into the rest of the turn. Stated only when the host actually probed
203
+ // (`null`/`undefined` means "could not tell", and silence beats a wrong fact).
204
+ if (vcs === 'git') {
205
+ bits.push(
206
+ `version control: this directory IS inside a git work tree, so \`git log\`, ` +
207
+ '`git diff` and `git status` are usable for checking what was already done',
208
+ );
209
+ } else if (vcs === '') {
210
+ bits.push(
211
+ `version control: this directory is NOT a git repository and has no history to ` +
212
+ 'consult — do NOT run `git log`, `git diff`, `git blame` or `git status`; they ' +
213
+ 'fail here and cost a round. Read the files that exist instead',
214
+ );
215
+ }
186
216
  if (shell) bits.push(`exec shell: ${shell}${shellKind ? ` (${shellKind})` : ''}`);
187
217
  if (model) bits.push(`model: ${model}`);
188
218
  if (appVersion) bits.push(`AEGIS Desktop ${appVersion}`);
@@ -86,10 +86,18 @@ const OUTPUT_CAP = 30_000; // chars fed back to the model per tool result
86
86
  * Desktop. The schema is the first place to say so — a refusal after the fact
87
87
  * costs the model a round, a correct description costs it nothing.
88
88
  */
89
+ // Kept SHORT on purpose. This sentence is repeated in the description of every
90
+ // path argument (9 of the 10 tools), so its length is paid once per tool, every
91
+ // round, and the tool schemas are part of the wire prefix the provider is asked
92
+ // to cache. The long form spelled out win32 examples and the Environment-block
93
+ // cross-reference — 267 chars, ~68 tokens, ~616 tokens per round once
94
+ // multiplied across the nine. That instruction is NOT lost: prompt.js's
95
+ // TOOL_LINE states the same rule once, in the system prompt where it belongs
96
+ // and where it costs one copy instead of nine. `test/tool-paths.test.mjs`
97
+ // asserts this constant appears in each path argument, so it must stay the
98
+ // single shared string rather than being inlined per tool.
89
99
  const PATH_ARG_DESC =
90
- 'Absolute path in THIS machine\'s own form (the Environment block names the platform and home ' +
91
- 'directory — a win32 host needs C:\\Users\\you\\file.txt, never /home/you/file.txt), or "~" / ' +
92
- '"~/…" for the home directory. A relative path resolves against the working directory.';
100
+ 'Absolute path on THIS machine; "~"/"~/…" = home. Relative resolves against the working directory.';
93
101
 
94
102
  /** `<what>. <the path rules>` — the shape every path argument's description takes. */
95
103
  const pathDesc = (what) => `${what}. ${PATH_ARG_DESC}`;
@@ -297,18 +305,45 @@ const SCHEMAS = {
297
305
  // MAX_SUBAGENT_DEPTH, hard-bounding runaway recursion.
298
306
  const SUBAGENT_TOOL = 'task';
299
307
 
308
+ /**
309
+ * The hook layer (`hooks`, `installHooks`) is the ONE part of this registry that
310
+ * is not agent work, and it is the one part the benchmark never touches.
311
+ *
312
+ * Measured on `benchmarks/raw/beat-pi-20261022-2318` (three tasks, the same wire
313
+ * model, nine trials): the advertised schemas are re-sent as a wire PREFIX on
314
+ * every round, and these two are 427 of its ~1,757 tokens. On the round that
315
+ * misses the prefix cache they are billed at the miss rate; on every later round
316
+ * they are billed at the cheap hit rate but are still tokens the model reads
317
+ * before it reads the task. Pi ships seven tools and no hook surface at all.
318
+ *
319
+ * So they stay RUNNABLE (`isTool`, `executeTool`, `toolList`, the CLI's `/hooks`)
320
+ * and stay on the schema list for a caller that asks for them, but they are no
321
+ * longer advertised to the model by default. Capability is unchanged; the
322
+ * prefix it was paying for is gone.
323
+ */
324
+ const HOOK_TOOLS = new Set(['hooks', 'installHooks']);
325
+
300
326
  /** Tool names in advertisement order. `includeSubagent: false` drops task (depth cap). */
301
- function toolNames({ includeSubagent = true } = {}) {
302
- return schemaList(includeSubagent).map((s) => s.name);
327
+ function toolNames({ includeSubagent = true, includeHooks = false } = {}) {
328
+ return schemaList({ includeSubagent, includeHooks }).map((s) => s.name);
303
329
  }
304
330
 
305
- function schemaList(includeSubagent) {
306
- return Object.values(SCHEMAS).filter((s) => includeSubagent || s.name !== SUBAGENT_TOOL);
331
+ function schemaList({ includeSubagent = true, includeHooks = false } = {}) {
332
+ return Object.values(SCHEMAS).filter(
333
+ (s) =>
334
+ (includeSubagent || s.name !== SUBAGENT_TOOL) &&
335
+ (includeHooks || !HOOK_TOOLS.has(s.name))
336
+ );
307
337
  }
308
338
 
309
- /** OpenAI-compatible /chat/completions tool definitions ({type:'function', function}). */
310
- function openaiTools({ includeSubagent = true } = {}) {
311
- return schemaList(includeSubagent).map((s) => ({
339
+ /**
340
+ * OpenAI-compatible /chat/completions tool definitions ({type:'function', function}).
341
+ * `includeHooks: true` re-advertises the hook tools; the default is the agent
342
+ * set, because a hook install is a thing the USER asks the CLI for, not a step
343
+ * in a coding task.
344
+ */
345
+ function openaiTools({ includeSubagent = true, includeHooks = false } = {}) {
346
+ return schemaList({ includeSubagent, includeHooks }).map((s) => ({
312
347
  type: 'function',
313
348
  function: { name: s.name, description: s.description, parameters: s.parameters },
314
349
  }));
@@ -319,10 +354,10 @@ function openaiTools({ includeSubagent = true } = {}) {
319
354
  * goes through the AEGIS relay, which speaks the OpenAI shape — so this takes
320
355
  * no `wire` argument. The Anthropic-shaped variant went away with the
321
356
  * direct-dial transport. `includeSubagent: false` drops the task tool
322
- * (subagent depth cap).
357
+ * (subagent depth cap); `includeHooks: true` re-adds hooks/installHooks.
323
358
  */
324
- function toolsFor({ includeSubagent = true } = {}) {
325
- return openaiTools({ includeSubagent });
359
+ function toolsFor({ includeSubagent = true, includeHooks = false } = {}) {
360
+ return openaiTools({ includeSubagent, includeHooks });
326
361
  }
327
362
 
328
363
  // ── Path normalisation ──────────────────────────────────────────────────────
@@ -1175,6 +1210,7 @@ module.exports = {
1175
1210
  // schemas
1176
1211
  SCHEMAS,
1177
1212
  SUBAGENT_TOOL,
1213
+ HOOK_TOOLS,
1178
1214
  toolNames,
1179
1215
  openaiTools,
1180
1216
  toolsFor,