@korso/shepherd 0.11.1 → 0.11.3

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
@@ -105,34 +105,45 @@ default `~/.shepherd/inbox`). That file is then drained by two paths:
105
105
  > given client on this machine it **writes to that client's own configuration
106
106
  > file in your home directory**, without a separate prompt:
107
107
  >
108
- > | Client | File it edits/creates | What it adds |
109
- > | ----------- | ------------------------------------------ | ---------------------------------------------------------------- |
110
- > | Claude Code | `~/.claude/settings.json` | `SessionStart` + `PreToolUse` hook entries |
111
- > | Codex | `~/.codex/config.toml` | a `[[hooks.UserPromptSubmit]]` block (+ `features.hooks = true`) |
112
- > | Cursor | `~/.cursor/hooks.json` | a `beforeSubmitPrompt` entry |
113
- > | Pi | `~/.pi/agent/extensions/shepherd-inbox.js` | copies the bundled extension |
108
+ > | Client | File it edits/creates | What it adds |
109
+ > | ----------- | ------------------------------------------ | --------------------------------------------------------- |
110
+ > | Claude Code | `~/.claude/settings.json` | `SessionStart` + `PreToolUse` hook entries |
111
+ > | Codex | `~/.codex/config.toml` | the hooks feature plus all three canonical event handlers |
112
+ > | Cursor | `~/.cursor/hooks.json` | a `beforeSubmitPrompt` entry |
113
+ > | Pi | `~/.pi/agent/extensions/shepherd-inbox.js` | copies the bundled extension |
114
114
  >
115
- > This edit is **additive only** (existing keys/entries are never modified,
116
- > removed, or reordered), **marker-guarded** (attempted **at most once per
117
- > machine+client**, recorded under `~/.shepherd/hooks/`; if you later remove the
118
- > hook it is **never re-added**), **version-pinned** (the installed command runs
119
- > the exact shipped build, not a floating `npx latest`), and **fail-open** (any
120
- > file it can't confidently parse is left untouched with a stderr notice).
115
+ > Fresh installs are **additive only** (existing keys/entries are never removed
116
+ > or reordered), **record-guarded** under `~/.shepherd/hooks/`,
117
+ > **version-pinned** (the installed command runs the exact shipped build, not a
118
+ > floating `npx latest`). Unsupported client config shapes are left untouched;
119
+ > installer and migration failures never block or break the agent session, and
120
+ > failure logs may be emitted to stderr. Codex also has a one-time, versioned
121
+ > migration, but only when it finds both a legacy auto-install record at
122
+ > `~/.shepherd/hooks/codex.json` and the exact Shepherd-owned legacy block. It
123
+ > preserves that block and appends only the missing handlers after saving a
124
+ > persistent backup at
125
+ > `~/.shepherd/hooks/backups/codex-config-before-v2.toml`. Ambiguous or manually
126
+ > removed hooks are left alone. Existing users receive the migration after they
127
+ > update `@korso/shepherd` and restart Codex so the updated MCP server starts.
121
128
  >
122
129
  > **To opt out entirely, set `SHEPHERD_NO_AUTO_HOOKS=1`** — the server then never
123
130
  > touches any client config, and you can wire the hook manually using the
124
131
  > per-client snippets below.
125
132
 
126
133
  On its first `initialize` handshake the server detects the connecting client
127
- and, for Claude Code / Codex / Pi, installs the delivery hook **once per
128
- machine**:
134
+ and installs the delivery hook **once per machine**:
129
135
 
130
136
  - **Claude Code** — merges the `SessionStart` + `PreToolUse` hook entries into
131
137
  `~/.claude/settings.json` (additive JSON merge; an unparseable file is left
132
138
  untouched).
133
- - **Codex** — appends the `[[hooks.UserPromptSubmit]]` block (and
134
- `features.hooks = true`) to `~/.codex/config.toml` (text-level append; it
135
- respects an explicit `hooks = false` and never rewrites existing content).
139
+ - **Codex** — enables `features.hooks` and appends canonical
140
+ `UserPromptSubmit`, `SessionStart`, and wildcard `PreToolUse` handlers to
141
+ `~/.codex/config.toml`. An explicit `hooks = false` is respected. Existing
142
+ installs are migrated only when a legacy `~/.shepherd/hooks/codex.json`
143
+ auto-install record and Shepherd's exact legacy `UserPromptSubmit` block are
144
+ both present. The migration retains that block, appends the two missing
145
+ handlers, and first writes a persistent backup. Update `@korso/shepherd` and
146
+ restart Codex to run it.
136
147
  - **Pi** — copies the bundled extension to
137
148
  `~/.pi/agent/extensions/shepherd-inbox.js`.
138
149
  - **Cursor** — merges a `beforeSubmitPrompt` entry into `~/.cursor/hooks.json`
@@ -140,10 +151,11 @@ machine**:
140
151
  verified to inject hook output into the agent's context, and wiring an
141
152
  unverified event would consume announcements without delivering them.
142
153
 
143
- A record under `~/.shepherd/hooks/` guarantees at-most-once: if you remove the
144
- hook, Shepherd won't re-add it. Everything is fail-open (an error just means no
145
- hook, never a broken session), and `SHEPHERD_NO_AUTO_HOOKS=1` disables the whole
146
- mechanism.
154
+ A record under `~/.shepherd/hooks/` guarantees at-most-once installation (and
155
+ tracks the current Codex migration version): if you remove the hook, Shepherd
156
+ won't re-add it. Installation and migration are fail-open: a failure never
157
+ blocks or breaks the agent session. `SHEPHERD_NO_AUTO_HOOKS=1` disables the
158
+ whole mechanism.
147
159
 
148
160
  Both paths read the **same** inbox file and de-duplicate by announcement id, so
149
161
  running both is safe (the hub hands each announcement to exactly one drain; the
@@ -225,24 +237,41 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
225
237
  }
226
238
  ```
227
239
 
228
- ### Codex — `UserPromptSubmit` hook
240
+ ### Codex — `UserPromptSubmit` + `SessionStart` + `PreToolUse` hooks
229
241
 
230
- _(Installed automatically on first run — shown for reference/manual setup.)_
242
+ _(Installed automatically, with legacy Shepherd configs migrated once — shown
243
+ for reference/manual setup.)_
231
244
 
232
245
  Codex uses the **same** hook contract as Claude Code (JSON on stdin, a
233
- `hookSpecificOutput.additionalContext` reply), so the **same bin** serves it. Use
234
- `UserPromptSubmit` — Codex's `PreToolUse` only fires for Bash, not `apply_patch`
235
- or MCP calls. Hooks must be enabled with `features.hooks = true`. In
236
- `~/.codex/config.toml`:
246
+ `hookSpecificOutput.additionalContext` reply), so the **same bin** serves it.
247
+ `UserPromptSubmit` and `SessionStart` cover turn and session boundaries. In
248
+ local Codex testing, wildcard `PreToolUse` delivered before Bash,
249
+ `apply_patch`, and MCP calls. Other richer tool paths, including WebSearch, are
250
+ not guaranteed by the current Codex hook coverage. Hooks must be enabled with
251
+ `features.hooks = true`. In `~/.codex/config.toml`:
237
252
 
238
253
  ```toml
239
254
  [features]
240
255
  hooks = true
241
256
 
242
257
  [[hooks.UserPromptSubmit]]
243
- command = ["npx", "-y", "--package=@korso/shepherd", "shepherd-inbox-hook"]
244
- # On Windows use command_windows instead:
245
- # command_windows = ["cmd", "/c", "npx -y --package=@korso/shepherd shepherd-inbox-hook"]
258
+ [[hooks.UserPromptSubmit.hooks]]
259
+ type = "command"
260
+ command = "npx -y --package=@korso/shepherd shepherd-inbox-hook"
261
+ timeout = 20
262
+
263
+ [[hooks.SessionStart]]
264
+ [[hooks.SessionStart.hooks]]
265
+ type = "command"
266
+ command = "npx -y --package=@korso/shepherd shepherd-inbox-hook"
267
+ timeout = 20
268
+
269
+ [[hooks.PreToolUse]]
270
+ matcher = "*"
271
+ [[hooks.PreToolUse.hooks]]
272
+ type = "command"
273
+ command = "npx -y --package=@korso/shepherd shepherd-inbox-hook"
274
+ timeout = 20
246
275
  ```
247
276
 
248
277
  ### Pi — extension
@@ -139,6 +139,15 @@ var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
139
139
  function sessionMailboxPath(dir, serverPid) {
140
140
  return join3(dir, `agent-${serverPid}.jsonl`);
141
141
  }
142
+ var HOOK_CHAIN_REACH = { codex: 8 };
143
+ var DEFAULT_HOOK_CHAIN_REACH = 3;
144
+ var MAX_HOOK_CHAIN_REACH = Math.max(
145
+ DEFAULT_HOOK_CHAIN_REACH,
146
+ ...Object.values(HOOK_CHAIN_REACH)
147
+ );
148
+ function hookChainReach(client) {
149
+ return (client === void 0 ? void 0 : HOOK_CHAIN_REACH[client]) ?? DEFAULT_HOOK_CHAIN_REACH;
150
+ }
142
151
  function normalizeCwd(cwd) {
143
152
  let normalized = resolve3(cwd);
144
153
  if (process.platform === "win32") normalized = normalized.toLowerCase();
@@ -146,7 +155,7 @@ function normalizeCwd(cwd) {
146
155
  }
147
156
  function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()) {
148
157
  try {
149
- const chain = hookChain.slice(0, 3);
158
+ const chain = hookChain.slice(0, MAX_HOOK_CHAIN_REACH);
150
159
  const wantedCwd = hookCwd === null ? null : normalizeCwd(hookCwd);
151
160
  const candidates = [];
152
161
  for (const name of readdirSync(dir)) {
@@ -180,7 +189,7 @@ function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH
180
189
  }
181
190
  if (!Array.isArray(meta.chain) || typeof meta.cwd !== "string") continue;
182
191
  const i = chain.findIndex((pid) => meta.chain.includes(pid));
183
- if (i === -1) continue;
192
+ if (i === -1 || i >= hookChainReach(meta.client)) continue;
184
193
  const j = meta.chain.indexOf(chain[i]);
185
194
  if (i >= 2 && (j > 2 || wantedCwd === null || meta.cwd !== wantedCwd))
186
195
  continue;
@@ -280,6 +289,51 @@ function mergeAnnouncements(...lists) {
280
289
  return [...byId.values()].sort((x, y) => x.id - y.id);
281
290
  }
282
291
 
292
+ // src/codexHookMigration.ts
293
+ import { z as z2 } from "zod";
294
+
295
+ // src/codexHookInstall.ts
296
+ import { parse, TomlDate } from "smol-toml";
297
+
298
+ // src/codexHookFs.ts
299
+ import { z } from "zod";
300
+ var lockSchema = z.object({
301
+ pid: z.number().int().positive(),
302
+ createdAt: z.string(),
303
+ owner: z.string().min(1).optional()
304
+ });
305
+
306
+ // src/codexHookMigration.ts
307
+ var migrationOutcomeSchema = z2.enum([
308
+ "migrated",
309
+ "already-canonical",
310
+ "user-removed",
311
+ "ambiguous",
312
+ "opted-out",
313
+ "unsupported-shape"
314
+ ]);
315
+ var recordSchema = z2.object({
316
+ status: z2.string(),
317
+ at: z2.string(),
318
+ migrationVersion: z2.number().int().nonnegative().optional(),
319
+ migrationOutcome: migrationOutcomeSchema.optional()
320
+ }).passthrough();
321
+
322
+ // src/version.ts
323
+ import { createRequire } from "node:module";
324
+ var PACKAGE_VERSION = (() => {
325
+ try {
326
+ const req = createRequire(import.meta.url);
327
+ const pkg = req("../package.json");
328
+ return pkg.version ?? "0.0.0";
329
+ } catch {
330
+ return "0.0.0";
331
+ }
332
+ })();
333
+
334
+ // src/hookInstall.ts
335
+ var HOOK_COMMAND = `npx -y --package=@korso/shepherd@${PACKAGE_VERSION} shepherd-inbox-hook`;
336
+
283
337
  // src/instructions.ts
284
338
  function sanitizeWorkspace(workspace) {
285
339
  return workspace.replace(/\s+/g, " ").slice(0, 64);
package/dist/inboxHook.js CHANGED
@@ -141,6 +141,15 @@ var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
141
141
  function sessionMailboxPath(dir, serverPid) {
142
142
  return join3(dir, `agent-${serverPid}.jsonl`);
143
143
  }
144
+ var HOOK_CHAIN_REACH = { codex: 8 };
145
+ var DEFAULT_HOOK_CHAIN_REACH = 3;
146
+ var MAX_HOOK_CHAIN_REACH = Math.max(
147
+ DEFAULT_HOOK_CHAIN_REACH,
148
+ ...Object.values(HOOK_CHAIN_REACH)
149
+ );
150
+ function hookChainReach(client) {
151
+ return (client === void 0 ? void 0 : HOOK_CHAIN_REACH[client]) ?? DEFAULT_HOOK_CHAIN_REACH;
152
+ }
144
153
  function normalizeCwd(cwd) {
145
154
  let normalized = resolve3(cwd);
146
155
  if (process.platform === "win32") normalized = normalized.toLowerCase();
@@ -163,7 +172,7 @@ function hasFreshSessionMeta(dir, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()
163
172
  }
164
173
  function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()) {
165
174
  try {
166
- const chain = hookChain.slice(0, 3);
175
+ const chain = hookChain.slice(0, MAX_HOOK_CHAIN_REACH);
167
176
  const wantedCwd = hookCwd === null ? null : normalizeCwd(hookCwd);
168
177
  const candidates = [];
169
178
  for (const name of readdirSync(dir)) {
@@ -197,7 +206,7 @@ function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH
197
206
  }
198
207
  if (!Array.isArray(meta.chain) || typeof meta.cwd !== "string") continue;
199
208
  const i = chain.findIndex((pid) => meta.chain.includes(pid));
200
- if (i === -1) continue;
209
+ if (i === -1 || i >= hookChainReach(meta.client)) continue;
201
210
  const j = meta.chain.indexOf(chain[i]);
202
211
  if (i >= 2 && (j > 2 || wantedCwd === null || meta.cwd !== wantedCwd))
203
212
  continue;