@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/dist/index.js CHANGED
@@ -745,20 +745,52 @@ var EntitlementsStatusResponse = z2.object({
745
745
  reposUsed: z2.number().int()
746
746
  })
747
747
  });
748
+ var AnalyticsRange = z2.enum(["24h", "7d", "30d", "90d"]);
749
+ var AnalyticsBucket = z2.enum(["hour", "day"]);
750
+ var PeriodMetric = z2.object({
751
+ current: z2.number().int().nonnegative(),
752
+ previous: z2.number().int().nonnegative(),
753
+ changePct: z2.number().nullable()
754
+ });
755
+ var DurationPercentiles = z2.object({
756
+ p50: z2.number().nonnegative().nullable(),
757
+ p95: z2.number().nonnegative().nullable()
758
+ });
748
759
  var TrendPoint = z2.object({
749
- // `YYYY-MM-DD` (UTC day).
750
760
  date: z2.string(),
751
761
  count: z2.number()
752
762
  });
763
+ var TrendSeries = z2.object({
764
+ current: z2.array(TrendPoint),
765
+ previous: z2.array(TrendPoint)
766
+ });
753
767
  var TopWorkspace = z2.object({
754
768
  name: z2.string(),
755
769
  slug: z2.string(),
756
- members: z2.number(),
757
- agents: z2.number(),
758
- liveSessions: z2.number()
770
+ members: z2.number().int().nonnegative(),
771
+ agents: z2.number().int().nonnegative(),
772
+ liveSessions: z2.number().int().nonnegative(),
773
+ // Distinct agents with any session activity inside the window.
774
+ activeAgents: z2.number().int().nonnegative(),
775
+ sessions: z2.number().int().nonnegative(),
776
+ commits: z2.number().int().nonnegative(),
777
+ claimsReleased: z2.number().int().nonnegative(),
778
+ // Median released-claim duration (created_at -> released_at), seconds.
779
+ medianClaimSeconds: z2.number().nonnegative().nullable(),
780
+ // ISO timestamp of the most recent observed activity, or null if none.
781
+ lastActivityAt: IsoTimestamp.nullable()
759
782
  });
760
783
  var ShepherdAnalyticsResponse = z2.object({
761
784
  generatedAt: IsoTimestamp,
785
+ // Echo of the (validated) requested window plus the bucket granularity and
786
+ // the exact half-open window [windowStart, windowEnd) the hub computed
787
+ // against — clients label charts from these instead of re-deriving time math.
788
+ range: AnalyticsRange,
789
+ bucket: AnalyticsBucket,
790
+ windowStart: IsoTimestamp,
791
+ windowEnd: IsoTimestamp,
792
+ // Current-state totals: whole-platform counts as of `generatedAt`,
793
+ // independent of the requested range.
762
794
  totals: z2.object({
763
795
  accounts: z2.number(),
764
796
  workspaces: z2.number(),
@@ -778,12 +810,30 @@ var ShepherdAnalyticsResponse = z2.object({
778
810
  avgMembersPerWorkspace: z2.number(),
779
811
  largestWorkspace: z2.number()
780
812
  }),
813
+ // Range-scoped KPIs, each with its aligned previous-period comparison.
814
+ period: z2.object({
815
+ activeWorkspaces: PeriodMetric,
816
+ newAccounts: PeriodMetric,
817
+ newSessions: PeriodMetric,
818
+ commits: PeriodMetric,
819
+ claimsReleased: PeriodMetric
820
+ }),
821
+ // Observed timing diagnostics over the current window: session span is
822
+ // created_at -> last_heartbeat_at; claim duration is created_at ->
823
+ // released_at (released claims only).
824
+ timing: z2.object({
825
+ sessionSpanSeconds: DurationPercentiles,
826
+ claimDurationSeconds: DurationPercentiles
827
+ }),
781
828
  feedbackByType: z2.array(z2.object({ type: z2.string(), count: z2.number() })),
829
+ // Bucketed activity series (hourly for 24h, daily otherwise), each carrying
830
+ // its aligned previous-period twin for chart overlays.
782
831
  trends: z2.object({
783
- newAccounts: z2.array(TrendPoint),
784
- newWorkspaces: z2.array(TrendPoint),
785
- newSessions: z2.array(TrendPoint),
786
- commits: z2.array(TrendPoint)
832
+ newAccounts: TrendSeries,
833
+ newWorkspaces: TrendSeries,
834
+ newSessions: TrendSeries,
835
+ commits: TrendSeries,
836
+ claimsReleased: TrendSeries
787
837
  }),
788
838
  topWorkspaces: z2.array(TopWorkspace)
789
839
  });
@@ -1208,6 +1258,12 @@ function sessionMailboxPath(dir, serverPid) {
1208
1258
  function sessionMetaPath(dir, serverPid) {
1209
1259
  return join3(dir, `agent-${serverPid}.json`);
1210
1260
  }
1261
+ var HOOK_CHAIN_REACH = { codex: 8 };
1262
+ var DEFAULT_HOOK_CHAIN_REACH = 3;
1263
+ var MAX_HOOK_CHAIN_REACH = Math.max(
1264
+ DEFAULT_HOOK_CHAIN_REACH,
1265
+ ...Object.values(HOOK_CHAIN_REACH)
1266
+ );
1211
1267
  function normalizeCwd(cwd) {
1212
1268
  let normalized = resolve3(cwd);
1213
1269
  if (process.platform === "win32") normalized = normalized.toLowerCase();
@@ -1220,7 +1276,12 @@ function writeMailboxMeta(dir, serverPid, meta) {
1220
1276
  const tmp = `${dest}.tmp`;
1221
1277
  writeFileSync3(
1222
1278
  tmp,
1223
- JSON.stringify({ v: 1, cwd: normalizeCwd(meta.cwd), chain: meta.chain })
1279
+ JSON.stringify({
1280
+ v: 1,
1281
+ cwd: normalizeCwd(meta.cwd),
1282
+ chain: meta.chain,
1283
+ ...meta.client === void 0 ? {} : { client: meta.client }
1284
+ })
1224
1285
  );
1225
1286
  renameSync(tmp, dest);
1226
1287
  } catch {
@@ -2403,143 +2464,585 @@ function createHeartbeat({
2403
2464
  return { start, stop };
2404
2465
  }
2405
2466
 
2406
- // src/instructions.ts
2407
- function sanitizeWorkspace(workspace) {
2408
- return workspace.replace(/\s+/g, " ").slice(0, 64);
2409
- }
2410
- function buildInstructions(state, workspace) {
2411
- switch (state) {
2412
- case "linked":
2413
- return `${INTRO} This repository is linked to the \`${workspace ? sanitizeWorkspace(workspace) : "team"}\` workspace, so coordination is active.
2467
+ // src/hookInstall.ts
2468
+ import {
2469
+ readFileSync as readFileSync8,
2470
+ writeFileSync as writeFileSync7,
2471
+ mkdirSync as mkdirSync6,
2472
+ copyFileSync,
2473
+ existsSync as existsSync6,
2474
+ renameSync as renameSync3
2475
+ } from "node:fs";
2476
+ import { homedir as homedir5 } from "node:os";
2477
+ import { dirname as dirname7, join as join7 } from "node:path";
2478
+ import { fileURLToPath } from "node:url";
2414
2479
 
2415
- ${PROCEDURE}`;
2416
- case "declined":
2417
- return "Shepherd (team coordination) is connected, but the user declined coordination for this repository. Do not call Shepherd tools or bring up coordination here. If the user asks to start coordinating this repo, call `link`.";
2418
- case "unanswered":
2419
- return `${INTRO}
2480
+ // src/codexHookMigration.ts
2481
+ import { existsSync as existsSync5, readFileSync as readFileSync7 } from "node:fs";
2482
+ import { createHash as createHash3 } from "node:crypto";
2483
+ import { join as join6 } from "node:path";
2484
+ import { z as z5 } from "zod";
2420
2485
 
2421
- ${FIRST_RUN_ASK}`;
2486
+ // src/codexHookInstall.ts
2487
+ import { parse, TomlDate } from "smol-toml";
2488
+ var CODEX_SHEPHERD_COMMENT = "# Added by Shepherd: delivers teammate announcements to the agent. Remove to disable.";
2489
+ function canonicalHandlerBlock(event, command, matcher) {
2490
+ return [
2491
+ "[[hooks." + event + "]]",
2492
+ ...matcher === void 0 ? [] : ["matcher = " + JSON.stringify(matcher)],
2493
+ "[[hooks." + event + ".hooks]]",
2494
+ 'type = "command"',
2495
+ "command = " + JSON.stringify(command),
2496
+ "timeout = 20",
2497
+ ""
2498
+ ].join("\n");
2499
+ }
2500
+ function canonicalHookBlock(command) {
2501
+ return [
2502
+ "",
2503
+ CODEX_SHEPHERD_COMMENT,
2504
+ canonicalHandlerBlock("UserPromptSubmit", command),
2505
+ canonicalHandlerBlock("SessionStart", command),
2506
+ canonicalHandlerBlock("PreToolUse", command, "*")
2507
+ ].join("\n");
2508
+ }
2509
+ function parseConfig2(source) {
2510
+ try {
2511
+ return parse(source, { integersAsBigInt: false });
2512
+ } catch {
2513
+ return null;
2422
2514
  }
2423
2515
  }
2424
- var INTRO = "You are connected to Shepherd, the shared coordination hub for a team of agents (human and AI) working in the same repositories.";
2425
- var FIRST_RUN_ASK = `This repository isn't linked to a Shepherd workspace yet, so coordination is dormant. Shepherd normally asks the user directly (a popup) when file edits are detected \u2014 you don't need to raise it yourself.
2426
-
2427
- If the user asks you to set up coordination \u2014 or you're about to change files and no popup or Shepherd message has settled the question \u2014 ask at most once: call \`link\` with no argument. It auto-links when the user belongs to exactly one workspace, or lists the choices; ask the user which workspace, then call \`link\` again with their answer. If they say no, call \`decline\` so they're never asked again. Once linked, the tool results will guide the coordination procedure.`;
2428
- var PROCEDURE = `Follow this procedure on every session, proactively and without being asked:
2429
-
2430
- 1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
2431
-
2432
- 2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
2433
-
2434
- 3. Re-call \`work\` only when you move to a DIFFERENT area not covered by a live claim. (\`work\` and \`sync\` also renew your existing claims.)
2435
-
2436
- 4. Call \`announce\` whenever you discover something another agent needs \u2014 a shared decision, a gotcha, an API change, a finding. If the landscape shows a specific agent working in the affected area, direct it to them by passing their name as \`target\`; otherwise broadcast. A human teammate's name (or \`admin\`) as \`target\` reaches them on the dashboard \u2014 reply to a human's message that way, directed to its sender, never in your own chat. Awareness only, not task assignment.
2437
-
2438
- 5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
2439
-
2440
- Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or thinking that produces no file. The moment you're going to WRITE something, source or doc, claim it first. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
2441
-
2442
- Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
2516
+ function isTomlTable(value) {
2517
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof TomlDate);
2518
+ }
2519
+ function hasEnabledHooks(config) {
2520
+ const features = config["features"];
2521
+ return isTomlTable(features) && features["hooks"] === true;
2522
+ }
2523
+ function insertHooksFeature(source) {
2524
+ const featuresHeader = /^(\s*\[features\]\s*(?:#.*)?)$/gm;
2525
+ let match;
2526
+ while ((match = featuresHeader.exec(source)) !== null) {
2527
+ const insertionPoint = match.index + match[0].length;
2528
+ const candidate = source.slice(0, insertionPoint) + "\nhooks = true" + source.slice(insertionPoint);
2529
+ const config = parseConfig2(candidate);
2530
+ if (config !== null && hasEnabledHooks(config)) return candidate;
2531
+ }
2532
+ return null;
2533
+ }
2534
+ function installCandidate(source, config, command) {
2535
+ const features = config["features"];
2536
+ let candidate;
2537
+ if (isTomlTable(features)) {
2538
+ if (Object.prototype.hasOwnProperty.call(features, "hooks")) {
2539
+ candidate = features["hooks"] === true ? source + canonicalHookBlock(command) : null;
2540
+ } else {
2541
+ const withFeature = insertHooksFeature(source);
2542
+ candidate = withFeature === null ? null : withFeature + canonicalHookBlock(command);
2543
+ }
2544
+ } else if (features === void 0) {
2545
+ candidate = source + (source.length === 0 ? "" : "\n") + "[features]\nhooks = true\n" + canonicalHookBlock(command);
2546
+ } else {
2547
+ candidate = null;
2548
+ }
2549
+ const parsed = candidate === null ? null : parseConfig2(candidate);
2550
+ return parsed !== null && hasEnabledHooks(parsed) ? candidate : null;
2551
+ }
2552
+ function planCodexConfig(source, command) {
2553
+ const config = parseConfig2(source);
2554
+ if (config === null) return { kind: "skip", outcome: "unsupported-shape" };
2555
+ const features = config["features"];
2556
+ if (isTomlTable(features) && features["hooks"] !== void 0) {
2557
+ if (features["hooks"] === false) {
2558
+ return { kind: "skip", outcome: "opted-out" };
2559
+ }
2560
+ if (features["hooks"] !== true) {
2561
+ return { kind: "skip", outcome: "unsupported-shape" };
2562
+ }
2563
+ } else if (features !== void 0 && !isTomlTable(features)) {
2564
+ return { kind: "skip", outcome: "unsupported-shape" };
2565
+ }
2566
+ if (source.includes(canonicalHookBlock(command))) {
2567
+ return { kind: "already-canonical" };
2568
+ }
2569
+ const candidate = installCandidate(source, config, command);
2570
+ return candidate === null ? { kind: "skip", outcome: "unsupported-shape" } : { kind: "install", candidate };
2571
+ }
2572
+ function hasCanonicalHandler(source, event, hookMarker) {
2573
+ const headers = new RegExp("^\\[\\[hooks\\." + event + "\\]\\]$", "gm");
2574
+ const boundary = new RegExp("^\\[(?!\\[hooks\\." + event + "\\.)", "m");
2575
+ const nested = new RegExp("^\\[\\[hooks\\." + event + "\\.hooks\\]\\]$", "m");
2576
+ let header;
2577
+ while ((header = headers.exec(source)) !== null) {
2578
+ const rest = source.slice(header.index + header[0].length);
2579
+ const end = rest.search(boundary);
2580
+ const group = end === -1 ? rest : rest.slice(0, end);
2581
+ if (group.includes(hookMarker) && nested.test(group)) return true;
2582
+ }
2583
+ return false;
2584
+ }
2585
+ function appendMissingCodexHandlers(source, command, hookMarker) {
2586
+ const handlers = [
2587
+ ["UserPromptSubmit", void 0],
2588
+ ["SessionStart", void 0],
2589
+ ["PreToolUse", "*"]
2590
+ ].filter(([event]) => !hasCanonicalHandler(source, event, hookMarker)).map(([event, matcher]) => canonicalHandlerBlock(event, command, matcher));
2591
+ const candidate = handlers.length === 0 ? source : source + (source.endsWith("\n") ? "" : "\n") + handlers.join("");
2592
+ return parseConfig2(candidate) === null ? null : candidate;
2593
+ }
2443
2594
 
2444
- // src/processTree.ts
2445
- import { execFile as execFile2 } from "node:child_process";
2446
- import { promisify } from "node:util";
2447
- var execFileAsync = promisify(execFile2);
2448
- function pidChainFromMap(startPid, parentOf, maxDepth = 32) {
2449
- const chain = [];
2450
- const seen = /* @__PURE__ */ new Set();
2451
- let pid = startPid;
2452
- while (chain.length < maxDepth && pid > 0 && !seen.has(pid)) {
2453
- chain.push(pid);
2454
- seen.add(pid);
2455
- const parent = parentOf.get(pid);
2456
- if (parent === void 0) break;
2457
- pid = parent;
2595
+ // src/codexHookFs.ts
2596
+ import {
2597
+ chmodSync,
2598
+ closeSync,
2599
+ existsSync as existsSync4,
2600
+ fchmodSync,
2601
+ fsyncSync,
2602
+ linkSync,
2603
+ mkdirSync as mkdirSync5,
2604
+ openSync,
2605
+ readFileSync as readFileSync6,
2606
+ renameSync as renameSync2,
2607
+ statSync as statSync2,
2608
+ unlinkSync,
2609
+ writeFileSync as writeFileSync6
2610
+ } from "node:fs";
2611
+ import { createHash as createHash2, randomUUID } from "node:crypto";
2612
+ import { basename as basename2, dirname as dirname6, join as join5 } from "node:path";
2613
+ import { z as z4 } from "zod";
2614
+ var STALE_LOCK_MS = 3e4;
2615
+ var lockSchema = z4.object({
2616
+ pid: z4.number().int().positive(),
2617
+ createdAt: z4.string(),
2618
+ owner: z4.string().min(1).optional()
2619
+ });
2620
+ function errorCode(error) {
2621
+ return error instanceof Error && "code" in error ? String(error.code) : void 0;
2622
+ }
2623
+ function modeOf(path3) {
2624
+ try {
2625
+ return statSync2(path3).mode & 511;
2626
+ } catch {
2627
+ return 384;
2458
2628
  }
2459
- return chain;
2460
2629
  }
2461
- function quickChain() {
2462
- return [process.pid, process.ppid];
2630
+ function syncParent(path3) {
2631
+ if (process.platform === "win32") return;
2632
+ const descriptor = openSync(dirname6(path3), "r");
2633
+ try {
2634
+ fsyncSync(descriptor);
2635
+ } finally {
2636
+ closeSync(descriptor);
2637
+ }
2463
2638
  }
2464
- function parseWmicProcessList(text) {
2465
- const map = /* @__PURE__ */ new Map();
2466
- const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
2467
- if (lines.length === 0) return map;
2468
- const header = lines[0].trimStart();
2469
- let pidFirst;
2470
- if (header.startsWith("ParentProcessId")) pidFirst = false;
2471
- else if (header.startsWith("ProcessId")) pidFirst = true;
2472
- else return map;
2473
- for (const line of lines.slice(1)) {
2474
- const nums = line.trim().split(/\s+/).map(Number);
2475
- if (nums.length !== 2 || nums.some((n) => !Number.isInteger(n))) continue;
2476
- const [a, b] = nums;
2477
- const [pid, ppid] = pidFirst ? [a, b] : [b, a];
2478
- map.set(pid, ppid);
2639
+ function durableTemp(path3, contents, mode) {
2640
+ const descriptor = openSync(path3, "wx", mode);
2641
+ try {
2642
+ writeFileSync6(descriptor, contents);
2643
+ fchmodSync(descriptor, mode);
2644
+ fsyncSync(descriptor);
2645
+ } finally {
2646
+ closeSync(descriptor);
2647
+ }
2648
+ }
2649
+ function atomicWrite(path3, contents) {
2650
+ mkdirSync5(dirname6(path3), { recursive: true });
2651
+ const temporary = join5(
2652
+ dirname6(path3),
2653
+ "." + basename2(path3) + "." + process.pid + "." + randomUUID() + ".tmp"
2654
+ );
2655
+ try {
2656
+ durableTemp(temporary, contents, modeOf(path3));
2657
+ renameSync2(temporary, path3);
2658
+ syncParent(path3);
2659
+ } catch (error) {
2660
+ try {
2661
+ unlinkSync(temporary);
2662
+ } catch {
2663
+ }
2664
+ throw error;
2479
2665
  }
2480
- return map;
2481
2666
  }
2482
- function parsePidPpidLines(text) {
2483
- const map = /* @__PURE__ */ new Map();
2484
- for (const line of text.split(/\r?\n/)) {
2485
- const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
2486
- if (m) map.set(Number(m[1]), Number(m[2]));
2667
+ function processIsLive(pid) {
2668
+ try {
2669
+ process.kill(pid, 0);
2670
+ return true;
2671
+ } catch (error) {
2672
+ return errorCode(error) !== "ESRCH";
2487
2673
  }
2488
- return map;
2489
2674
  }
2490
- async function snapshotParentMap() {
2491
- if (process.platform === "win32") {
2675
+ function lockSnapshot(lockFile) {
2676
+ try {
2677
+ const bytes = readFileSync6(lockFile);
2678
+ const modifiedAt = statSync2(lockFile).mtimeMs;
2679
+ let owner = null;
2680
+ let reclaimable = false;
2492
2681
  try {
2493
- const { stdout: stdout3 } = await execFileAsync(
2494
- "wmic",
2495
- ["process", "get", "ProcessId,ParentProcessId"],
2496
- { windowsHide: true, timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
2497
- );
2498
- const map = parseWmicProcessList(stdout3);
2499
- if (map.size > 0) return map;
2682
+ const decoded = JSON.parse(bytes.toString("utf8"));
2683
+ const parsed = lockSchema.safeParse(decoded);
2684
+ if (parsed.success) {
2685
+ owner = parsed.data.owner ?? null;
2686
+ const createdAt = Date.parse(parsed.data.createdAt);
2687
+ const ageBasis = Number.isFinite(createdAt) ? createdAt : modifiedAt;
2688
+ reclaimable = Date.now() - ageBasis > STALE_LOCK_MS && !processIsLive(parsed.data.pid);
2689
+ }
2500
2690
  } catch {
2501
2691
  }
2502
- const { stdout: stdout2 } = await execFileAsync(
2503
- "powershell.exe",
2504
- [
2505
- "-NoProfile",
2506
- "-NonInteractive",
2507
- "-Command",
2508
- 'Get-CimInstance -Query "SELECT ProcessId,ParentProcessId FROM Win32_Process" | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
2509
- ],
2510
- { windowsHide: true, timeout: 15e3, maxBuffer: 8 * 1024 * 1024 }
2692
+ return { bytes, owner, reclaimable };
2693
+ } catch {
2694
+ return null;
2695
+ }
2696
+ }
2697
+ function lockContents(owner) {
2698
+ return JSON.stringify({
2699
+ pid: process.pid,
2700
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2701
+ owner
2702
+ });
2703
+ }
2704
+ function createOwnedLock(lockFile, owner) {
2705
+ const temporary = lockFile + ".owner-" + owner + ".tmp";
2706
+ try {
2707
+ durableTemp(temporary, lockContents(owner), 384);
2708
+ linkSync(temporary, lockFile);
2709
+ return true;
2710
+ } catch {
2711
+ return false;
2712
+ } finally {
2713
+ try {
2714
+ unlinkSync(temporary);
2715
+ } catch {
2716
+ }
2717
+ }
2718
+ }
2719
+ function ownedBy(lockFile, owner) {
2720
+ try {
2721
+ const decoded = JSON.parse(readFileSync6(lockFile, "utf8"));
2722
+ const parsed = lockSchema.safeParse(decoded);
2723
+ return parsed.success && parsed.data.owner === owner;
2724
+ } catch {
2725
+ return false;
2726
+ }
2727
+ }
2728
+ function removeOwnedLock(lockFile, owner) {
2729
+ if (ownedBy(lockFile, owner)) unlinkSync(lockFile);
2730
+ }
2731
+ function snapshotMatches(current, expected) {
2732
+ return current !== null && current.reclaimable && current.bytes.equals(expected.bytes);
2733
+ }
2734
+ function nextReclaimClaim(rootClaim, snapshot) {
2735
+ const generation = createHash2("sha256").update(snapshot.bytes).digest("hex");
2736
+ return rootClaim + ".generation-" + generation;
2737
+ }
2738
+ function acquireReclaimClaim(rootClaim, owner) {
2739
+ let claimFile = rootClaim;
2740
+ for (let generation = 0; generation < 32; generation += 1) {
2741
+ if (createOwnedLock(claimFile, owner)) return claimFile;
2742
+ const snapshot = lockSnapshot(claimFile);
2743
+ if (snapshot === null || !snapshot.reclaimable || snapshot.owner === null) {
2744
+ return null;
2745
+ }
2746
+ claimFile = nextReclaimClaim(rootClaim, snapshot);
2747
+ }
2748
+ return null;
2749
+ }
2750
+ function replaceStaleLock(lockFile, claimFile, owner, expected) {
2751
+ const replacement = lockFile + ".replacement-" + owner;
2752
+ let published = false;
2753
+ try {
2754
+ durableTemp(replacement, lockContents(owner), 384);
2755
+ if (!ownedBy(claimFile, owner)) return false;
2756
+ if (!snapshotMatches(lockSnapshot(lockFile), expected)) return false;
2757
+ renameSync2(replacement, lockFile);
2758
+ published = true;
2759
+ syncParent(lockFile);
2760
+ if (ownedBy(claimFile, owner)) return true;
2761
+ removeOwnedLock(lockFile, owner);
2762
+ return false;
2763
+ } catch {
2764
+ if (published) removeOwnedLock(lockFile, owner);
2765
+ return false;
2766
+ } finally {
2767
+ try {
2768
+ unlinkSync(replacement);
2769
+ } catch {
2770
+ }
2771
+ }
2772
+ }
2773
+ function acquireMigrationLock(lockFile) {
2774
+ mkdirSync5(dirname6(lockFile), { recursive: true });
2775
+ const owner = randomUUID();
2776
+ if (createOwnedLock(lockFile, owner)) return owner;
2777
+ const snapshot = lockSnapshot(lockFile);
2778
+ if (snapshot === null || !snapshot.reclaimable) return null;
2779
+ const claimFile = acquireReclaimClaim(lockFile + ".reclaim", owner);
2780
+ if (claimFile === null) return null;
2781
+ try {
2782
+ return replaceStaleLock(lockFile, claimFile, owner, snapshot) ? owner : null;
2783
+ } finally {
2784
+ try {
2785
+ removeOwnedLock(claimFile, owner);
2786
+ } catch {
2787
+ }
2788
+ }
2789
+ }
2790
+ function releaseMigrationLock(lockFile, owner, log) {
2791
+ try {
2792
+ removeOwnedLock(lockFile, owner);
2793
+ } catch (error) {
2794
+ log(
2795
+ "[shepherd] Codex hook migration lock cleanup failed: " + String(error)
2511
2796
  );
2512
- return parsePidPpidLines(stdout2);
2513
2797
  }
2514
- const { stdout } = await execFileAsync(
2515
- "ps",
2516
- ["-eo", "pid=,ppid="],
2517
- { timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
2518
- );
2519
- return parsePidPpidLines(stdout);
2520
2798
  }
2521
- async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
2799
+ function validateBackup(backupFile, source) {
2800
+ if (!readFileSync6(backupFile).equals(source)) {
2801
+ throw new Error("existing Codex migration backup does not match config");
2802
+ }
2803
+ chmodSync(backupFile, 384);
2804
+ }
2805
+ function ensureMigrationBackup(backupFile, source) {
2806
+ const backupDirectory = dirname6(backupFile);
2807
+ mkdirSync5(backupDirectory, { recursive: true });
2808
+ syncParent(backupDirectory);
2809
+ if (existsSync4(backupFile)) {
2810
+ validateBackup(backupFile, source);
2811
+ syncParent(backupFile);
2812
+ return;
2813
+ }
2814
+ const temporary = backupFile + "." + process.pid + "." + randomUUID() + ".tmp";
2522
2815
  try {
2523
- const map = await snapshot();
2524
- const chain = pidChainFromMap(process.pid, map, maxDepth);
2525
- return chain.length >= 2 ? chain : quickChain();
2816
+ durableTemp(temporary, source, 384);
2817
+ try {
2818
+ linkSync(temporary, backupFile);
2819
+ } catch (error) {
2820
+ if (errorCode(error) !== "EEXIST") throw error;
2821
+ validateBackup(backupFile, source);
2822
+ }
2823
+ syncParent(backupFile);
2824
+ } finally {
2825
+ try {
2826
+ unlinkSync(temporary);
2827
+ } catch {
2828
+ }
2829
+ }
2830
+ }
2831
+
2832
+ // src/codexHookMigration.ts
2833
+ var MIGRATION_VERSION = 3;
2834
+ var migrationOutcomeSchema = z5.enum([
2835
+ "migrated",
2836
+ "already-canonical",
2837
+ "user-removed",
2838
+ "ambiguous",
2839
+ "opted-out",
2840
+ "unsupported-shape"
2841
+ ]);
2842
+ var recordSchema = z5.object({
2843
+ status: z5.string(),
2844
+ at: z5.string(),
2845
+ migrationVersion: z5.number().int().nonnegative().optional(),
2846
+ migrationOutcome: migrationOutcomeSchema.optional()
2847
+ }).passthrough();
2848
+ function migrationPaths(homeDir) {
2849
+ const hooksDir = join6(homeDir, ".shepherd", "hooks");
2850
+ return {
2851
+ hooksDir,
2852
+ recordFile: join6(hooksDir, "codex.json"),
2853
+ lockFile: join6(hooksDir, `codex-migration-v${MIGRATION_VERSION}.lock`),
2854
+ backupFile: join6(
2855
+ hooksDir,
2856
+ "backups",
2857
+ `codex-config-before-v${MIGRATION_VERSION}.toml`
2858
+ ),
2859
+ configFile: join6(homeDir, ".codex", "config.toml")
2860
+ };
2861
+ }
2862
+ function readRecord(recordFile) {
2863
+ if (!existsSync5(recordFile)) return { kind: "none" };
2864
+ try {
2865
+ const decoded = JSON.parse(readFileSync7(recordFile, "utf8"));
2866
+ const parsed = recordSchema.safeParse(decoded);
2867
+ if (!parsed.success) return { kind: "corrupt" };
2868
+ const record = parsed.data;
2869
+ const version = parsed.data.migrationVersion;
2870
+ if (version === void 0 || version < MIGRATION_VERSION) {
2871
+ return { kind: "legacy", record };
2872
+ }
2873
+ if (version > MIGRATION_VERSION) return { kind: "future", record };
2874
+ return parsed.data.migrationOutcome === void 0 ? { kind: "corrupt" } : { kind: "current", record };
2526
2875
  } catch {
2527
- return quickChain();
2876
+ return { kind: "corrupt" };
2877
+ }
2878
+ }
2879
+ function migrationRecord(prior, status, outcome) {
2880
+ return JSON.stringify(
2881
+ {
2882
+ ...prior,
2883
+ status: typeof prior?.["status"] === "string" ? prior["status"] : status,
2884
+ at: typeof prior?.["at"] === "string" ? prior["at"] : (/* @__PURE__ */ new Date()).toISOString(),
2885
+ migrationVersion: MIGRATION_VERSION,
2886
+ migrationOutcome: outcome
2887
+ },
2888
+ null,
2889
+ 2
2890
+ ) + "\n";
2891
+ }
2892
+ function fingerprint(bytes) {
2893
+ return createHash3("sha256").update(bytes).digest("hex");
2894
+ }
2895
+ function readConfigBytes(configFile) {
2896
+ return existsSync5(configFile) ? readFileSync7(configFile) : Buffer.alloc(0);
2897
+ }
2898
+ function decodeConfig(bytes) {
2899
+ try {
2900
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
2901
+ } catch {
2902
+ return null;
2903
+ }
2904
+ }
2905
+ function advanceRecord(recordFile, state, status, outcome) {
2906
+ atomicWrite(recordFile, migrationRecord(state.record, status, outcome));
2907
+ }
2908
+ function legacyHookBlock(commandValue) {
2909
+ return [
2910
+ "",
2911
+ CODEX_SHEPHERD_COMMENT,
2912
+ "[[hooks.UserPromptSubmit]]",
2913
+ "command = " + commandValue,
2914
+ ""
2915
+ ].join("\n");
2916
+ }
2917
+ function exactOwnedLegacyBlock(source, hooksDir) {
2918
+ const semver = "(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)";
2919
+ const pinned = new RegExp(
2920
+ '^command = (\\["npx", "-y", "--package=@korso/shepherd@' + semver + '", "shepherd-inbox-hook"\\])$',
2921
+ "gm"
2922
+ );
2923
+ const candidates = Array.from(
2924
+ source.matchAll(pinned),
2925
+ (match) => legacyHookBlock(match[1])
2926
+ );
2927
+ candidates.push(
2928
+ legacyHookBlock(
2929
+ '["node", ' + JSON.stringify(join6(hooksDir, "shepherd-inbox-hook.mjs")) + "]"
2930
+ )
2931
+ );
2932
+ const exact = candidates.filter(
2933
+ (block) => source.indexOf(block) >= 0 && source.indexOf(block) === source.lastIndexOf(block)
2934
+ );
2935
+ return exact.length === 1 ? exact[0] : void 0;
2936
+ }
2937
+ function migrateLegacy(context, state, sourceBytes, source) {
2938
+ const { paths, command, hookMarker, log } = context;
2939
+ const candidate = appendMissingCodexHandlers(source, command, hookMarker);
2940
+ if (candidate === null) {
2941
+ advanceRecord(paths.recordFile, state, "skipped", "unsupported-shape");
2942
+ return "skipped";
2943
+ }
2944
+ const recordBytes = readFileSync7(paths.recordFile);
2945
+ const configChanged = candidate !== source;
2946
+ ensureMigrationBackup(paths.backupFile, sourceBytes);
2947
+ if (!readConfigBytes(paths.configFile).equals(sourceBytes)) return "skipped";
2948
+ try {
2949
+ if (configChanged) atomicWrite(paths.configFile, candidate);
2950
+ advanceRecord(paths.recordFile, state, "installed", "migrated");
2951
+ } catch (error) {
2952
+ if (!readConfigBytes(paths.configFile).equals(sourceBytes)) {
2953
+ atomicWrite(paths.configFile, sourceBytes);
2954
+ }
2955
+ if (!readFileSync7(paths.recordFile).equals(recordBytes)) {
2956
+ atomicWrite(paths.recordFile, recordBytes);
2957
+ }
2958
+ throw error;
2959
+ }
2960
+ log(
2961
+ "[shepherd] Migrated Codex hooks. Persistent backup: " + paths.backupFile + "; you may remove it after validation."
2962
+ );
2963
+ return "installed";
2964
+ }
2965
+ function processLockedConfig(context, expectedFingerprint) {
2966
+ const { paths, command, hookMarker } = context;
2967
+ const state = readRecord(paths.recordFile);
2968
+ if (state.kind === "current" || state.kind === "future") {
2969
+ return "already-attempted";
2970
+ }
2971
+ if (state.kind === "corrupt") return "skipped";
2972
+ const sourceBytes = readConfigBytes(paths.configFile);
2973
+ if (fingerprint(sourceBytes) !== expectedFingerprint) return "skipped";
2974
+ const source = decodeConfig(sourceBytes);
2975
+ if (source === null) {
2976
+ advanceRecord(paths.recordFile, state, "skipped", "unsupported-shape");
2977
+ return "skipped";
2978
+ }
2979
+ const plan = planCodexConfig(source, command);
2980
+ if (plan.kind === "skip") {
2981
+ advanceRecord(paths.recordFile, state, "skipped", plan.outcome);
2982
+ return "skipped";
2983
+ }
2984
+ if (plan.kind === "already-canonical") {
2985
+ advanceRecord(
2986
+ paths.recordFile,
2987
+ state,
2988
+ "already-present",
2989
+ "already-canonical"
2990
+ );
2991
+ return "already-present";
2992
+ }
2993
+ const ownedBlock = exactOwnedLegacyBlock(source, paths.hooksDir);
2994
+ if (state.kind === "legacy" && ownedBlock !== void 0) {
2995
+ return migrateLegacy(context, state, sourceBytes, source);
2996
+ }
2997
+ if (ownedBlock !== void 0 || source.includes(CODEX_SHEPHERD_COMMENT) || source.includes(hookMarker)) {
2998
+ advanceRecord(paths.recordFile, state, "already-present", "ambiguous");
2999
+ return "already-present";
3000
+ }
3001
+ if (state.kind === "legacy") {
3002
+ advanceRecord(paths.recordFile, state, "skipped", "user-removed");
3003
+ return "skipped";
3004
+ }
3005
+ atomicWrite(paths.configFile, plan.candidate);
3006
+ advanceRecord(paths.recordFile, state, "installed", "already-canonical");
3007
+ return "installed";
3008
+ }
3009
+ async function installCodexHooks({
3010
+ homeDir,
3011
+ command,
3012
+ hookMarker,
3013
+ log
3014
+ }) {
3015
+ const paths = migrationPaths(homeDir);
3016
+ const context = { paths, command, hookMarker, log };
3017
+ const initialRecord = readRecord(paths.recordFile);
3018
+ if (initialRecord.kind === "current" || initialRecord.kind === "future") {
3019
+ return "already-attempted";
3020
+ }
3021
+ if (initialRecord.kind === "corrupt") return "skipped";
3022
+ let initialBytes;
3023
+ try {
3024
+ initialBytes = readConfigBytes(paths.configFile);
3025
+ } catch (error) {
3026
+ log(
3027
+ "[shepherd] Codex hook migration could not read config: " + String(error)
3028
+ );
3029
+ return "skipped";
3030
+ }
3031
+ const expectedFingerprint = fingerprint(initialBytes);
3032
+ const owner = acquireMigrationLock(paths.lockFile);
3033
+ if (owner === null) return "skipped";
3034
+ try {
3035
+ await Promise.resolve();
3036
+ return processLockedConfig(context, expectedFingerprint);
3037
+ } catch (error) {
3038
+ log("[shepherd] Codex hook migration skipped: " + String(error));
3039
+ return "skipped";
3040
+ } finally {
3041
+ releaseMigrationLock(paths.lockFile, owner, log);
2528
3042
  }
2529
3043
  }
2530
3044
 
2531
3045
  // src/hookInstall.ts
2532
- import {
2533
- readFileSync as readFileSync6,
2534
- writeFileSync as writeFileSync6,
2535
- mkdirSync as mkdirSync5,
2536
- copyFileSync,
2537
- existsSync as existsSync4,
2538
- renameSync as renameSync2
2539
- } from "node:fs";
2540
- import { homedir as homedir5 } from "node:os";
2541
- import { dirname as dirname6, join as join5 } from "node:path";
2542
- import { fileURLToPath } from "node:url";
2543
3046
  function detectClient(clientName) {
2544
3047
  const name = (clientName ?? "").toLowerCase();
2545
3048
  if (!name) return "unknown";
@@ -2552,17 +3055,17 @@ function detectClient(clientName) {
2552
3055
  var HOOK_COMMAND = `npx -y --package=@korso/shepherd@${PACKAGE_VERSION} shepherd-inbox-hook`;
2553
3056
  var HOOK_MARKER = "shepherd-inbox-hook";
2554
3057
  function ensureHookScript(homeDir, hookScriptSource) {
2555
- const source = hookScriptSource ?? join5(dirname6(fileURLToPath(import.meta.url)), "inboxHook.js");
3058
+ const source = hookScriptSource ?? join7(dirname7(fileURLToPath(import.meta.url)), "inboxHook.js");
2556
3059
  try {
2557
- if (!existsSync4(source)) return null;
2558
- const dest = join5(homeDir, ".shepherd", "hooks", "shepherd-inbox-hook.mjs");
2559
- const next = readFileSync6(source);
2560
- const current = existsSync4(dest) ? readFileSync6(dest) : null;
3060
+ if (!existsSync6(source)) return null;
3061
+ const dest = join7(homeDir, ".shepherd", "hooks", "shepherd-inbox-hook.mjs");
3062
+ const next = readFileSync8(source);
3063
+ const current = existsSync6(dest) ? readFileSync8(dest) : null;
2561
3064
  if (current === null || !current.equals(next)) {
2562
- mkdirSync5(dirname6(dest), { recursive: true });
3065
+ mkdirSync6(dirname7(dest), { recursive: true });
2563
3066
  const tmp = dest + ".tmp";
2564
- writeFileSync6(tmp, next);
2565
- renameSync2(tmp, dest);
3067
+ writeFileSync7(tmp, next);
3068
+ renameSync3(tmp, dest);
2566
3069
  }
2567
3070
  return dest;
2568
3071
  } catch {
@@ -2572,16 +3075,6 @@ function ensureHookScript(homeDir, hookScriptSource) {
2572
3075
  function hookCommandFor(scriptPath) {
2573
3076
  return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath.replace(/\\/g, "/")}"`;
2574
3077
  }
2575
- function codexHookBlock(scriptPath) {
2576
- const command = scriptPath === null ? `["npx", "-y", "--package=@korso/shepherd@${PACKAGE_VERSION}", "shepherd-inbox-hook"]` : `["node", ${JSON.stringify(scriptPath)}]`;
2577
- return [
2578
- "",
2579
- "# Added by Shepherd: delivers teammate announcements to the agent. Remove to disable.",
2580
- "[[hooks.UserPromptSubmit]]",
2581
- `command = ${command}`,
2582
- ""
2583
- ].join("\n");
2584
- }
2585
3078
  async function autoInstallHooks({
2586
3079
  clientName,
2587
3080
  homeDir = homedir5(),
@@ -2597,20 +3090,32 @@ async function autoInstallHooks({
2597
3090
  return { client, status: "unsupported" };
2598
3091
  }
2599
3092
  const scriptPath = ensureHookScript(homeDir, hookScriptSource);
2600
- const recordFile = join5(homeDir, ".shepherd", "hooks", `${client}.json`);
2601
- if (existsSync4(recordFile)) return { client, status: "already-attempted" };
3093
+ const recordFile = join7(homeDir, ".shepherd", "hooks", `${client}.json`);
3094
+ if (client === "codex") {
3095
+ const status2 = await installCodexHooks({
3096
+ homeDir,
3097
+ command: hookCommandFor(scriptPath),
3098
+ hookMarker: HOOK_MARKER,
3099
+ log
3100
+ });
3101
+ if (status2 === "installed") {
3102
+ log(
3103
+ "[shepherd] Installed the announcement-delivery hook for codex (disable by removing it, or set SHEPHERD_NO_AUTO_HOOKS=1 to never auto-install)."
3104
+ );
3105
+ }
3106
+ return { client, status: status2 };
3107
+ }
3108
+ if (existsSync6(recordFile)) return { client, status: "already-attempted" };
2602
3109
  let status;
2603
3110
  if (client === "claude") {
2604
3111
  status = installClaude(homeDir, scriptPath, log);
2605
- } else if (client === "codex") {
2606
- status = installCodex(homeDir, scriptPath, log);
2607
3112
  } else if (client === "cursor") {
2608
3113
  status = installCursor(homeDir, scriptPath, log);
2609
3114
  } else {
2610
3115
  status = installPi(homeDir, extensionSource, log);
2611
3116
  }
2612
- mkdirSync5(dirname6(recordFile), { recursive: true });
2613
- writeFileSync6(
3117
+ mkdirSync6(dirname7(recordFile), { recursive: true });
3118
+ writeFileSync7(
2614
3119
  recordFile,
2615
3120
  JSON.stringify({ status, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
2616
3121
  "utf8"
@@ -2629,10 +3134,10 @@ async function autoInstallHooks({
2629
3134
  }
2630
3135
  }
2631
3136
  function installClaude(homeDir, scriptPath, log) {
2632
- const settingsFile = join5(homeDir, ".claude", "settings.json");
3137
+ const settingsFile = join7(homeDir, ".claude", "settings.json");
2633
3138
  let raw = "";
2634
- if (existsSync4(settingsFile)) {
2635
- raw = readFileSync6(settingsFile, "utf8");
3139
+ if (existsSync6(settingsFile)) {
3140
+ raw = readFileSync8(settingsFile, "utf8");
2636
3141
  if (raw.includes(HOOK_MARKER)) return "already-present";
2637
3142
  }
2638
3143
  let settings = {};
@@ -2675,60 +3180,15 @@ function installClaude(homeDir, scriptPath, log) {
2675
3180
  matcher: "*",
2676
3181
  hooks: [{ type: "command", command }]
2677
3182
  });
2678
- mkdirSync5(dirname6(settingsFile), { recursive: true });
2679
- writeFileSync6(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2680
- return "installed";
2681
- }
2682
- function installCodex(homeDir, scriptPath, log) {
2683
- const configFile = join5(homeDir, ".codex", "config.toml");
2684
- const manualHint = "Add the hook manually (see the dashboard's Connect screen).";
2685
- const hookBlock = codexHookBlock(scriptPath);
2686
- if (!existsSync4(configFile)) {
2687
- mkdirSync5(dirname6(configFile), { recursive: true });
2688
- writeFileSync6(configFile, `[features]
2689
- hooks = true
2690
- ${hookBlock}`, "utf8");
2691
- return "installed";
2692
- }
2693
- const toml = readFileSync6(configFile, "utf8");
2694
- if (toml.includes(HOOK_MARKER)) return "already-present";
2695
- if (/^\s*\[hooks\.UserPromptSubmit\]\s*$/m.test(toml)) {
2696
- log(
2697
- `[shepherd] ${configFile} defines [hooks.UserPromptSubmit] \u2014 not touching it. ${manualHint}`
2698
- );
2699
- return "skipped";
2700
- }
2701
- if (/^\s*\[features\]/m.test(toml)) {
2702
- const hooksKey = /^\s*hooks\s*=\s*(.+)$/m.exec(toml);
2703
- if (hooksKey && hooksKey[1].trim() !== "true") {
2704
- log(
2705
- `[shepherd] ${configFile} sets hooks = ${hooksKey[1].trim()} \u2014 respecting it. ${manualHint}`
2706
- );
2707
- return "skipped";
2708
- }
2709
- let updated = toml;
2710
- if (!hooksKey) {
2711
- updated = toml.replace(/^(\s*\[features\]\s*)$/m, `$1
2712
- hooks = true`);
2713
- }
2714
- writeFileSync6(configFile, updated + hookBlock, "utf8");
2715
- return "installed";
2716
- }
2717
- writeFileSync6(
2718
- configFile,
2719
- `${toml}
2720
- [features]
2721
- hooks = true
2722
- ${hookBlock}`,
2723
- "utf8"
2724
- );
3183
+ mkdirSync6(dirname7(settingsFile), { recursive: true });
3184
+ writeFileSync7(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2725
3185
  return "installed";
2726
3186
  }
2727
3187
  function installCursor(homeDir, scriptPath, log) {
2728
- const hooksFile = join5(homeDir, ".cursor", "hooks.json");
3188
+ const hooksFile = join7(homeDir, ".cursor", "hooks.json");
2729
3189
  let raw = "";
2730
- if (existsSync4(hooksFile)) {
2731
- raw = readFileSync6(hooksFile, "utf8");
3190
+ if (existsSync6(hooksFile)) {
3191
+ raw = readFileSync8(hooksFile, "utf8");
2732
3192
  if (raw.includes(HOOK_MARKER)) return "already-present";
2733
3193
  }
2734
3194
  let config = {};
@@ -2763,25 +3223,177 @@ function installCursor(homeDir, scriptPath, log) {
2763
3223
  return "skipped";
2764
3224
  }
2765
3225
  entries.push({ command: hookCommandFor(scriptPath) });
2766
- mkdirSync5(dirname6(hooksFile), { recursive: true });
2767
- writeFileSync6(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
3226
+ mkdirSync6(dirname7(hooksFile), { recursive: true });
3227
+ writeFileSync7(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
2768
3228
  return "installed";
2769
3229
  }
2770
3230
  function installPi(homeDir, extensionSource, log) {
2771
- const source = extensionSource ?? join5(dirname6(fileURLToPath(import.meta.url)), "inboxExtension.js");
2772
- const dest = join5(homeDir, ".pi", "agent", "extensions", "shepherd-inbox.js");
2773
- if (existsSync4(dest)) return "already-present";
2774
- if (!existsSync4(source)) {
3231
+ const source = extensionSource ?? join7(dirname7(fileURLToPath(import.meta.url)), "inboxExtension.js");
3232
+ const dest = join7(homeDir, ".pi", "agent", "extensions", "shepherd-inbox.js");
3233
+ if (existsSync6(dest)) return "already-present";
3234
+ if (!existsSync6(source)) {
2775
3235
  log(
2776
3236
  `[shepherd] bundled Pi extension not found at ${source} \u2014 skipping auto-install.`
2777
3237
  );
2778
3238
  return "skipped";
2779
3239
  }
2780
- mkdirSync5(dirname6(dest), { recursive: true });
3240
+ mkdirSync6(dirname7(dest), { recursive: true });
2781
3241
  copyFileSync(source, dest);
2782
3242
  return "installed";
2783
3243
  }
2784
3244
 
3245
+ // src/instructions.ts
3246
+ function sanitizeWorkspace(workspace) {
3247
+ return workspace.replace(/\s+/g, " ").slice(0, 64);
3248
+ }
3249
+ function buildInstructions(state, workspace) {
3250
+ switch (state) {
3251
+ case "linked":
3252
+ return `${INTRO} This repository is linked to the \`${workspace ? sanitizeWorkspace(workspace) : "team"}\` workspace, so coordination is active.
3253
+
3254
+ ${PROCEDURE}`;
3255
+ case "declined":
3256
+ return "Shepherd (team coordination) is connected, but the user declined coordination for this repository. Do not call Shepherd tools or bring up coordination here. If the user asks to start coordinating this repo, call `link`.";
3257
+ case "unanswered":
3258
+ return `${INTRO}
3259
+
3260
+ ${FIRST_RUN_ASK}`;
3261
+ }
3262
+ }
3263
+ var INTRO = "You are connected to Shepherd, the shared coordination hub for a team of agents (human and AI) working in the same repositories.";
3264
+ var FIRST_RUN_ASK = `This repository isn't linked to a Shepherd workspace yet, so coordination is dormant. Shepherd normally asks the user directly (a popup) when file edits are detected \u2014 you don't need to raise it yourself.
3265
+
3266
+ If the user asks you to set up coordination \u2014 or you're about to change files and no popup or Shepherd message has settled the question \u2014 ask at most once: call \`link\` with no argument. It auto-links when the user belongs to exactly one workspace, or lists the choices; ask the user which workspace, then call \`link\` again with their answer. If they say no, call \`decline\` so they're never asked again. Once linked, the tool results will guide the coordination procedure.`;
3267
+ var PROCEDURE = `Follow this procedure on every session, proactively and without being asked:
3268
+
3269
+ 1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
3270
+
3271
+ 2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
3272
+
3273
+ 3. Re-call \`work\` only when you move to a DIFFERENT area not covered by a live claim. (\`work\` and \`sync\` also renew your existing claims.)
3274
+
3275
+ 4. Call \`announce\` whenever you discover something another agent needs \u2014 a shared decision, a gotcha, an API change, a finding. If the landscape shows a specific agent working in the affected area, direct it to them by passing their name as \`target\`; otherwise broadcast. A human teammate's name (or \`admin\`) as \`target\` reaches them on the dashboard \u2014 reply to a human's message that way, directed to its sender, never in your own chat. Awareness only, not task assignment.
3276
+
3277
+ 5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
3278
+
3279
+ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or thinking that produces no file. The moment you're going to WRITE something, source or doc, claim it first. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
3280
+
3281
+ Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
3282
+ function clientInjectsInstructions(client) {
3283
+ return client === "claude";
3284
+ }
3285
+ function stageCoordinationBriefing({
3286
+ clientName,
3287
+ linkState,
3288
+ workspace,
3289
+ append
3290
+ }) {
3291
+ if (linkState !== "linked") return false;
3292
+ if (clientInjectsInstructions(detectClient(clientName))) return false;
3293
+ append([coordinationBriefing(workspace)]);
3294
+ return true;
3295
+ }
3296
+ function coordinationBriefing(workspace) {
3297
+ const safeWorkspace = sanitizeWorkspace(workspace ?? "team");
3298
+ return {
3299
+ // Negative, timestamp-derived: the mailbox dedupes by id and the hub's ids
3300
+ // are positive, so a locally-minted id can never collide with a real one.
3301
+ id: -Date.now(),
3302
+ fromAgentName: "shepherd",
3303
+ fromHuman: "shepherd",
3304
+ targetAgentName: null,
3305
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3306
+ body: `Shepherd coordination is ACTIVE for this repository (workspace \`${safeWorkspace}\`), and your client does not surface Shepherd's standing instructions \u2014 so they arrive here. Procedure from now on, proactively and without being asked: call \`work\` (a one-line intent plus the \`pathGlobs\` you expect to touch) BEFORE you start changing files in an area \u2014 a plan or design doc you are about to author counts \u2014 and hold that ONE claim across every edit in that area; call \`done\` with its \`workItemId\` when the unit of work is complete; call \`announce\` whenever you find something teammates need; call \`sync\` when you resume or switch tasks. Skip \`work\` for read-only exploration that produces no file. If \`work\` reports a conflict, coordinate or pick different work \u2014 never silently collide. These tools are advisory: never block real work on them.`
3307
+ };
3308
+ }
3309
+
3310
+ // src/processTree.ts
3311
+ import { execFile as execFile2 } from "node:child_process";
3312
+ import { promisify } from "node:util";
3313
+ var execFileAsync = promisify(execFile2);
3314
+ function pidChainFromMap(startPid, parentOf, maxDepth = 32) {
3315
+ const chain = [];
3316
+ const seen = /* @__PURE__ */ new Set();
3317
+ let pid = startPid;
3318
+ while (chain.length < maxDepth && pid > 0 && !seen.has(pid)) {
3319
+ chain.push(pid);
3320
+ seen.add(pid);
3321
+ const parent = parentOf.get(pid);
3322
+ if (parent === void 0) break;
3323
+ pid = parent;
3324
+ }
3325
+ return chain;
3326
+ }
3327
+ function quickChain() {
3328
+ return [process.pid, process.ppid];
3329
+ }
3330
+ function parseWmicProcessList(text) {
3331
+ const map = /* @__PURE__ */ new Map();
3332
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
3333
+ if (lines.length === 0) return map;
3334
+ const header = lines[0].trimStart();
3335
+ let pidFirst;
3336
+ if (header.startsWith("ParentProcessId")) pidFirst = false;
3337
+ else if (header.startsWith("ProcessId")) pidFirst = true;
3338
+ else return map;
3339
+ for (const line of lines.slice(1)) {
3340
+ const nums = line.trim().split(/\s+/).map(Number);
3341
+ if (nums.length !== 2 || nums.some((n) => !Number.isInteger(n))) continue;
3342
+ const [a, b] = nums;
3343
+ const [pid, ppid] = pidFirst ? [a, b] : [b, a];
3344
+ map.set(pid, ppid);
3345
+ }
3346
+ return map;
3347
+ }
3348
+ function parsePidPpidLines(text) {
3349
+ const map = /* @__PURE__ */ new Map();
3350
+ for (const line of text.split(/\r?\n/)) {
3351
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
3352
+ if (m) map.set(Number(m[1]), Number(m[2]));
3353
+ }
3354
+ return map;
3355
+ }
3356
+ async function snapshotParentMap() {
3357
+ if (process.platform === "win32") {
3358
+ try {
3359
+ const { stdout: stdout3 } = await execFileAsync(
3360
+ "wmic",
3361
+ ["process", "get", "ProcessId,ParentProcessId"],
3362
+ { windowsHide: true, timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
3363
+ );
3364
+ const map = parseWmicProcessList(stdout3);
3365
+ if (map.size > 0) return map;
3366
+ } catch {
3367
+ }
3368
+ const { stdout: stdout2 } = await execFileAsync(
3369
+ "powershell.exe",
3370
+ [
3371
+ "-NoProfile",
3372
+ "-NonInteractive",
3373
+ "-Command",
3374
+ 'Get-CimInstance -Query "SELECT ProcessId,ParentProcessId FROM Win32_Process" | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
3375
+ ],
3376
+ { windowsHide: true, timeout: 15e3, maxBuffer: 8 * 1024 * 1024 }
3377
+ );
3378
+ return parsePidPpidLines(stdout2);
3379
+ }
3380
+ const { stdout } = await execFileAsync(
3381
+ "ps",
3382
+ ["-eo", "pid=,ppid="],
3383
+ { timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
3384
+ );
3385
+ return parsePidPpidLines(stdout);
3386
+ }
3387
+ async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
3388
+ try {
3389
+ const map = await snapshot();
3390
+ const chain = pidChainFromMap(process.pid, map, maxDepth);
3391
+ return chain.length >= 2 ? chain : quickChain();
3392
+ } catch {
3393
+ return quickChain();
3394
+ }
3395
+ }
3396
+
2785
3397
  // src/index.ts
2786
3398
  async function main() {
2787
3399
  const config = loadConfig();
@@ -2794,10 +3406,12 @@ async function main() {
2794
3406
  const inboxFile = sessionMailboxPath(inboxDir, process.pid);
2795
3407
  const launchCwd = process.cwd();
2796
3408
  let serverChain = quickChain();
3409
+ let serverClient;
2797
3410
  const liveness = {
2798
3411
  refresh: () => writeMailboxMeta(inboxDir, process.pid, {
2799
3412
  cwd: launchCwd,
2800
- chain: serverChain
3413
+ chain: serverChain,
3414
+ client: serverClient
2801
3415
  }),
2802
3416
  remove: () => removeMailboxMeta(inboxDir, process.pid)
2803
3417
  };
@@ -2837,10 +3451,19 @@ async function main() {
2837
3451
  });
2838
3452
  const transport = new StdioServerTransport();
2839
3453
  server.server.oninitialized = () => {
3454
+ const clientName = server.server.getClientVersion()?.name;
2840
3455
  void autoInstallHooks({
2841
- clientName: server.server.getClientVersion()?.name,
3456
+ clientName,
2842
3457
  disabled: config.SHEPHERD_NO_AUTO_HOOKS
2843
3458
  });
3459
+ serverClient = detectClient(clientName);
3460
+ liveness.refresh();
3461
+ stageCoordinationBriefing({
3462
+ clientName,
3463
+ linkState: context.linkState,
3464
+ workspace: context.workspace,
3465
+ append: (announcements) => appendAnnouncements(inboxFile, announcements)
3466
+ });
2844
3467
  };
2845
3468
  let shuttingDown = false;
2846
3469
  const shutdown = async () => {