@korso/shepherd 0.9.0 → 0.10.0

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
@@ -9,7 +9,9 @@ import { z } from "zod";
9
9
  var DEFAULT_WORKSPACE = "default";
10
10
  var ConfigSchema = z.object({
11
11
  // Hard-required: Hub endpoint.
12
- HUB_URL: z.string({ required_error: "HUB_URL is required" }).url("HUB_URL must be a full URL, e.g. https://your-shepherd-hub.example.com"),
12
+ HUB_URL: z.string({ required_error: "HUB_URL is required" }).url(
13
+ "HUB_URL must be a full URL, e.g. https://your-shepherd-hub.example.com"
14
+ ),
13
15
  // Auth credentials. Exactly one form is needed (enforced by the refine below):
14
16
  // - SHEPHERD_TOKEN: the hosted Hub credential (carries its own workspace).
15
17
  // - TEAM_TOKEN: the self-host credential.
@@ -67,33 +69,51 @@ function parseConfig(env) {
67
69
  function loadConfig(env = process.env) {
68
70
  try {
69
71
  const config = parseConfig(env);
70
- warnInsecureHubUrl(config.HUB_URL);
72
+ assertHubUrlAllowed(config.HUB_URL, env);
71
73
  return config;
72
74
  } catch (err) {
73
75
  if (err instanceof z.ZodError) {
74
76
  const messages = err.issues.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
75
- process.stderr.write(`[shepherd] Configuration error \u2014 missing or invalid env vars:
77
+ process.stderr.write(
78
+ `[shepherd] Configuration error \u2014 missing or invalid env vars:
76
79
  ${messages}
80
+ `
81
+ );
82
+ } else if (err instanceof Error) {
83
+ process.stderr.write(`[shepherd] Configuration error: ${err.message}
77
84
  `);
78
85
  } else {
79
- process.stderr.write(`[shepherd] Unexpected configuration error: ${String(err)}
80
- `);
86
+ process.stderr.write(
87
+ `[shepherd] Unexpected configuration error: ${String(err)}
88
+ `
89
+ );
81
90
  }
82
91
  process.exit(1);
83
92
  }
84
93
  }
85
- function warnInsecureHubUrl(hubUrl) {
94
+ function assertHubUrlAllowed(hubUrl, env = process.env) {
95
+ let url;
86
96
  try {
87
- const url = new URL(hubUrl);
88
- const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
89
- if (url.protocol === "http:" && !loopback) {
90
- process.stderr.write(
91
- `[shepherd] WARNING: HUB_URL (${hubUrl}) uses plain http to a non-local host \u2014 the team token and all coordination traffic travel unencrypted. Use https.
92
- `
93
- );
94
- }
97
+ url = new URL(hubUrl);
95
98
  } catch {
99
+ return;
100
+ }
101
+ if (url.protocol !== "http:") return;
102
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
103
+ if (loopback) return;
104
+ const allowInsecure = ["1", "true", "yes"].includes(
105
+ (env["SHEPHERD_ALLOW_INSECURE_HTTP"] ?? "").toLowerCase()
106
+ );
107
+ if (allowInsecure) {
108
+ process.stderr.write(
109
+ `[shepherd] WARNING: HUB_URL (${hubUrl}) uses plain http to a non-local host \u2014 the team token and all coordination traffic travel unencrypted (permitted via SHEPHERD_ALLOW_INSECURE_HTTP). Use https.
110
+ `
111
+ );
112
+ return;
96
113
  }
114
+ throw new Error(
115
+ `HUB_URL (${hubUrl}) uses plain http to a non-local host \u2014 the team token would travel unencrypted. Use https, or set SHEPHERD_ALLOW_INSECURE_HTTP=1 to permit cleartext to a private-network hub (not recommended).`
116
+ );
97
117
  }
98
118
 
99
119
  // src/hubClient.ts
@@ -125,7 +145,7 @@ function createHubClient({
125
145
  const controller = new AbortController();
126
146
  const timer = setTimeout(() => controller.abort(), timeoutMs);
127
147
  const headers = {
128
- "Authorization": `Bearer ${token}`
148
+ Authorization: `Bearer ${token}`
129
149
  };
130
150
  if (method === "POST") {
131
151
  headers["Content-Type"] = "application/json";
@@ -520,7 +540,10 @@ var SyncRequest = z2.object({
520
540
  var SyncResponse = z2.object({
521
541
  landscape: Landscape
522
542
  });
523
- var WorkAgentInput = WorkRequest.omit({ sessionId: true, changeReport: true });
543
+ var WorkAgentInput = WorkRequest.omit({
544
+ sessionId: true,
545
+ changeReport: true
546
+ });
524
547
  var AnnounceAgentInput = AnnounceRequest.omit({ sessionId: true });
525
548
  var DoneAgentInput = DoneRequest.omit({ sessionId: true });
526
549
  var JoinAgentInput = z2.object({});
@@ -576,7 +599,10 @@ var WorkspaceSummary = z2.object({
576
599
  isOwner: z2.boolean()
577
600
  });
578
601
  var CreateWorkspaceRequest = z2.object({
579
- name: z2.string().min(1)
602
+ // Cap the name like every other persisted string field (256), so a workspace
603
+ // name (and the slug candidate derived from it) can't be inflated toward the
604
+ // request body limit. min(1) keeps the "non-empty" contract.
605
+ name: z2.string().min(1).max(256)
580
606
  });
581
607
  var ListWorkspacesResponse = z2.object({
582
608
  workspaces: z2.array(WorkspaceSummary)
@@ -670,15 +696,50 @@ var TransferOwnershipResponse = z2.object({
670
696
  ok: z2.literal(true)
671
697
  });
672
698
  var FeedbackType = z2.enum(["bug", "suggestion", "other"]);
699
+ var FeedbackContext = z2.object({
700
+ route: z2.string().max(256).optional(),
701
+ appVersion: z2.string().max(256).optional(),
702
+ userAgent: z2.string().max(512).optional(),
703
+ viewport: z2.string().max(256).optional()
704
+ });
673
705
  var FeedbackRequest = z2.object({
674
706
  type: FeedbackType,
675
- body: z2.string().trim().min(1).max(4e3)
707
+ body: z2.string().trim().min(1).max(4e3),
708
+ context: FeedbackContext.optional()
676
709
  });
677
710
  var FeedbackResponse = z2.object({
678
711
  ok: z2.literal(true),
679
712
  // uuid PK (the feedback table, like workspaces, uses gen_random_uuid()).
680
713
  id: z2.string()
681
714
  });
715
+ var NullableCap = z2.number().int().positive().nullable();
716
+ var EntitlementLimits = z2.object({
717
+ seatsLimit: NullableCap,
718
+ reposLimit: NullableCap,
719
+ retentionDays: NullableCap
720
+ });
721
+ var LimitExceededErrorBody = z2.object({
722
+ error: z2.string(),
723
+ code: z2.literal("limit_exceeded"),
724
+ limit: z2.enum(["seats", "repos"]),
725
+ current: z2.number().int(),
726
+ max: z2.number().int()
727
+ });
728
+ var WorkspaceEntitlements = EntitlementLimits.extend({
729
+ expiresAt: IsoTimestamp.nullable(),
730
+ updatedAt: IsoTimestamp
731
+ });
732
+ var PutEntitlementsRequest = EntitlementLimits.extend({
733
+ expiresAt: IsoTimestamp.nullable()
734
+ });
735
+ var EntitlementsStatusResponse = z2.object({
736
+ record: WorkspaceEntitlements.nullable(),
737
+ effective: EntitlementLimits,
738
+ usage: z2.object({
739
+ seatsUsed: z2.number().int(),
740
+ reposUsed: z2.number().int()
741
+ })
742
+ });
682
743
  var TrendPoint = z2.object({
683
744
  // `YYYY-MM-DD` (UTC day).
684
745
  date: z2.string(),
@@ -723,9 +784,10 @@ var ShepherdAnalyticsResponse = z2.object({
723
784
  });
724
785
 
725
786
  // src/marker.ts
726
- import * as fs from "fs";
727
- import * as path from "path";
787
+ import * as fs from "node:fs";
788
+ import * as path from "node:path";
728
789
  var MARKER_FILENAME = ".shepherd";
790
+ var WORKSPACE_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
729
791
  function findRepoRoot(cwd) {
730
792
  let dir = path.resolve(cwd);
731
793
  for (; ; ) {
@@ -750,8 +812,12 @@ function readMarker(cwd = process.cwd()) {
750
812
  }
751
813
  try {
752
814
  const parsed = JSON.parse(raw);
753
- if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string" && parsed.workspace.length > 0) {
754
- return { workspace: parsed.workspace };
815
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string") {
816
+ const workspace = parsed.workspace;
817
+ if (WORKSPACE_SLUG_PATTERN.test(workspace)) {
818
+ return { workspace };
819
+ }
820
+ return null;
755
821
  }
756
822
  return null;
757
823
  } catch {
@@ -761,7 +827,9 @@ function readMarker(cwd = process.cwd()) {
761
827
  function writeMarker(cwd = process.cwd(), slug) {
762
828
  const file = markerPath(cwd);
763
829
  if (file === null) {
764
- throw new Error("not inside a git repository \u2014 cannot write .shepherd marker");
830
+ throw new Error(
831
+ "not inside a git repository \u2014 cannot write .shepherd marker"
832
+ );
765
833
  }
766
834
  fs.writeFileSync(file, JSON.stringify({ workspace: slug }) + "\n", "utf8");
767
835
  }
@@ -775,10 +843,16 @@ function removeMarker(cwd = process.cwd()) {
775
843
  }
776
844
 
777
845
  // src/declined.ts
778
- import { createHash } from "crypto";
779
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
780
- import { homedir, tmpdir } from "os";
781
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
846
+ import { createHash } from "node:crypto";
847
+ import {
848
+ existsSync as existsSync2,
849
+ mkdirSync,
850
+ readFileSync as readFileSync2,
851
+ rmSync as rmSync2,
852
+ writeFileSync as writeFileSync2
853
+ } from "node:fs";
854
+ import { homedir, tmpdir } from "node:os";
855
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
782
856
  function defaultDeclinedDir() {
783
857
  let base = "";
784
858
  try {
@@ -814,7 +888,9 @@ function setDeclined(repoRoot, dir = defaultDeclinedDir()) {
814
888
  const file = declinedFilePath(repoRoot, dir);
815
889
  try {
816
890
  mkdirSync(dirname2(file), { recursive: true });
817
- const payload = JSON.stringify({ declinedAt: (/* @__PURE__ */ new Date()).toISOString() });
891
+ const payload = JSON.stringify({
892
+ declinedAt: (/* @__PURE__ */ new Date()).toISOString()
893
+ });
818
894
  writeFileSync2(file, payload + "\n", "utf8");
819
895
  } catch (err) {
820
896
  console.error(
@@ -835,8 +911,8 @@ function clearDeclined(repoRoot, dir = defaultDeclinedDir()) {
835
911
  }
836
912
 
837
913
  // src/gitContext.ts
838
- import { execFileSync } from "child_process";
839
- import * as path2 from "path";
914
+ import { execFileSync } from "node:child_process";
915
+ import * as path2 from "node:path";
840
916
  var GIT_TIMEOUT_MS = 2e3;
841
917
  var MAX_COMMITS = 100;
842
918
  var MAX_PATHS_PER_COMMIT = 500;
@@ -911,13 +987,22 @@ function detectHuman(cwd = process.cwd()) {
911
987
  return null;
912
988
  }
913
989
  function detectBaseBranch(cwd = process.cwd()) {
914
- const symref = runGit(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
990
+ const symref = runGit(cwd, [
991
+ "symbolic-ref",
992
+ "--quiet",
993
+ "refs/remotes/origin/HEAD"
994
+ ]);
915
995
  if (symref) {
916
996
  const stripped = symref.replace(/^refs\/remotes\//, "");
917
997
  if (stripped) return stripped;
918
998
  }
919
999
  for (const candidate of ["origin/main", "origin/master"]) {
920
- if (runGitExitOk(cwd, ["rev-parse", "--verify", "--quiet", `refs/remotes/${candidate}`])) {
1000
+ if (runGitExitOk(cwd, [
1001
+ "rev-parse",
1002
+ "--verify",
1003
+ "--quiet",
1004
+ `refs/remotes/${candidate}`
1005
+ ])) {
921
1006
  return candidate;
922
1007
  }
923
1008
  }
@@ -970,7 +1055,12 @@ function unlandedCommits(cwd = process.cwd(), baseBranch) {
970
1055
  return { commits, truncated };
971
1056
  }
972
1057
  function dirtyPaths(cwd = process.cwd()) {
973
- const out = runGit(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"]);
1058
+ const out = runGit(cwd, [
1059
+ "status",
1060
+ "--porcelain",
1061
+ "-z",
1062
+ "--untracked-files=all"
1063
+ ]);
974
1064
  if (out === null) {
975
1065
  return { paths: [], truncated: false };
976
1066
  }
@@ -1082,17 +1172,19 @@ async function buildChangeReport(cwd, config) {
1082
1172
  }
1083
1173
 
1084
1174
  // src/inbox.ts
1085
- import { createHash as createHash2 } from "crypto";
1086
1175
  import {
1087
1176
  appendFileSync,
1088
1177
  mkdirSync as mkdirSync2,
1089
1178
  readFileSync as readFileSync3,
1179
+ readdirSync,
1090
1180
  renameSync,
1091
1181
  rmSync as rmSync3,
1182
+ statSync,
1183
+ writeFileSync as writeFileSync3,
1092
1184
  existsSync as existsSync3
1093
- } from "fs";
1094
- import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
1095
- import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
1185
+ } from "node:fs";
1186
+ import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
1187
+ import { dirname as dirname3, join as join3, resolve as resolve3 } from "node:path";
1096
1188
  function defaultInboxDir() {
1097
1189
  let base = "";
1098
1190
  try {
@@ -1103,11 +1195,37 @@ function defaultInboxDir() {
1103
1195
  if (!base) base = tmpdir2();
1104
1196
  return join3(base, ".shepherd", "inbox");
1105
1197
  }
1106
- function inboxFilePath(dir, cwd) {
1198
+ var MAILBOX_TTL_MS = 24 * 60 * 60 * 1e3;
1199
+ var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
1200
+ function sessionMailboxPath(dir, serverPid) {
1201
+ return join3(dir, `agent-${serverPid}.jsonl`);
1202
+ }
1203
+ function sessionMetaPath(dir, serverPid) {
1204
+ return join3(dir, `agent-${serverPid}.json`);
1205
+ }
1206
+ function normalizeCwd(cwd) {
1107
1207
  let normalized = resolve3(cwd);
1108
1208
  if (process.platform === "win32") normalized = normalized.toLowerCase();
1109
- const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
1110
- return join3(dir, `${hash}.jsonl`);
1209
+ return normalized;
1210
+ }
1211
+ function writeMailboxMeta(dir, serverPid, meta) {
1212
+ try {
1213
+ mkdirSync2(dir, { recursive: true });
1214
+ const dest = sessionMetaPath(dir, serverPid);
1215
+ const tmp = `${dest}.tmp`;
1216
+ writeFileSync3(
1217
+ tmp,
1218
+ JSON.stringify({ v: 1, cwd: normalizeCwd(meta.cwd), chain: meta.chain })
1219
+ );
1220
+ renameSync(tmp, dest);
1221
+ } catch {
1222
+ }
1223
+ }
1224
+ function removeMailboxMeta(dir, serverPid) {
1225
+ try {
1226
+ rmSync3(sessionMetaPath(dir, serverPid), { force: true });
1227
+ } catch {
1228
+ }
1111
1229
  }
1112
1230
  function appendAnnouncements(filePath, announcements) {
1113
1231
  if (!announcements || announcements.length === 0) return;
@@ -1159,6 +1277,19 @@ function oneLine(text) {
1159
1277
  function indentContinuation(text) {
1160
1278
  return text.replace(/\r?\n/g, "\n ");
1161
1279
  }
1280
+ function relativeAge(iso) {
1281
+ const then = Date.parse(iso);
1282
+ if (Number.isNaN(then)) return "recently";
1283
+ const ms = Date.now() - then;
1284
+ if (ms < 0) return "just now";
1285
+ const mins = Math.floor(ms / 6e4);
1286
+ if (mins < 1) return "just now";
1287
+ if (mins < 60) return `${mins}m ago`;
1288
+ const hours = Math.floor(mins / 60);
1289
+ if (hours < 24) return `${hours}h ago`;
1290
+ const days = Math.floor(hours / 24);
1291
+ return `${days}d ago`;
1292
+ }
1162
1293
  function mergeAnnouncements(...lists) {
1163
1294
  const byId = /* @__PURE__ */ new Map();
1164
1295
  for (const list of lists) {
@@ -1171,7 +1302,7 @@ function mergeAnnouncements(...lists) {
1171
1302
  }
1172
1303
 
1173
1304
  // src/editTripwire.ts
1174
- import { execFile } from "child_process";
1305
+ import { execFile } from "node:child_process";
1175
1306
  function createEditTripwire({
1176
1307
  cwd,
1177
1308
  intervalMs = 3e4,
@@ -1325,7 +1456,7 @@ function formatLandscape(landscape) {
1325
1456
  lines.push("CONFLICTS (files overlapping with your claim):");
1326
1457
  for (const c of landscape.conflicts) {
1327
1458
  lines.push(
1328
- ` [${c.agentName} / ${c.human}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
1459
+ ` [${oneLine(c.agentName)} / ${oneLine(c.human)}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
1329
1460
  );
1330
1461
  }
1331
1462
  } else {
@@ -1335,7 +1466,7 @@ function formatLandscape(landscape) {
1335
1466
  lines.push("ACTIVE CLAIMS (other agents currently working):");
1336
1467
  for (const c of landscape.activeClaims) {
1337
1468
  lines.push(
1338
- ` [${c.agentName} / ${c.human}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
1469
+ ` [${oneLine(c.agentName)} / ${oneLine(c.human)}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
1339
1470
  );
1340
1471
  }
1341
1472
  } else {
@@ -1355,8 +1486,10 @@ function formatLandscape(landscape) {
1355
1486
  if (landscape.announcements.length > 0) {
1356
1487
  lines.push("ANNOUNCEMENTS:");
1357
1488
  for (const a of landscape.announcements) {
1358
- const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
1359
- lines.push(` [${a.fromAgentName}${target}] ${indentContinuation(a.body)}`);
1489
+ const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
1490
+ lines.push(
1491
+ ` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
1492
+ );
1360
1493
  }
1361
1494
  lines.push(REPLY_ROUTING_HINT);
1362
1495
  } else {
@@ -1368,25 +1501,14 @@ function formatAnnouncements(announcements) {
1368
1501
  if (!announcements || announcements.length === 0) return "";
1369
1502
  const lines = ["Messages for you:"];
1370
1503
  for (const a of announcements) {
1371
- const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
1372
- lines.push(` [${a.fromAgentName}${target}] ${indentContinuation(a.body)}`);
1504
+ const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
1505
+ lines.push(
1506
+ ` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
1507
+ );
1373
1508
  }
1374
1509
  lines.push(REPLY_ROUTING_HINT);
1375
1510
  return lines.join("\n");
1376
1511
  }
1377
- function relativeAge(iso) {
1378
- const then = Date.parse(iso);
1379
- if (Number.isNaN(then)) return "recently";
1380
- const ms = Date.now() - then;
1381
- if (ms < 0) return "just now";
1382
- const mins = Math.floor(ms / 6e4);
1383
- if (mins < 1) return "just now";
1384
- if (mins < 60) return `${mins}m ago`;
1385
- const hours = Math.floor(mins / 60);
1386
- if (hours < 24) return `${hours}h ago`;
1387
- const days = Math.floor(hours / 24);
1388
- return `${days}d ago`;
1389
- }
1390
1512
  function presence(rec) {
1391
1513
  return rec.authorIsLive ? "active now" : `offline, last seen ${relativeAge(rec.authorLastActiveAt)}`;
1392
1514
  }
@@ -1402,7 +1524,7 @@ function formatChangeRecords(records, cwd = process.cwd()) {
1402
1524
  const state = present ? "landed, not yet in your branch \u2014 pull/rebase" : "not yet on your base \u2014 unpushed, coordinate";
1403
1525
  const intent = oneLine(rec.message ?? "(work in progress)");
1404
1526
  lines.push(
1405
- ` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
1527
+ ` ${oneLine(rec.agentName)} / ${oneLine(rec.human)} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
1406
1528
  );
1407
1529
  lines.push(` files: ${oneLine(rec.paths.join(", "))}`);
1408
1530
  if (sha && present && lineRangeBudget > 0) {
@@ -1410,7 +1532,9 @@ function formatChangeRecords(records, cwd = process.cwd()) {
1410
1532
  lineRangeBudget -= budgetedPaths.length;
1411
1533
  const ranges = changedLineRanges(cwd, sha, budgetedPaths);
1412
1534
  for (const p of Object.keys(ranges)) {
1413
- const spans = ranges[p].map((r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
1535
+ const spans = ranges[p].map(
1536
+ (r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`
1537
+ );
1414
1538
  if (spans.length > 0) {
1415
1539
  lines.push(` ${p}: lines ${spans.join(", ")} (for context)`);
1416
1540
  }
@@ -1419,7 +1543,7 @@ function formatChangeRecords(records, cwd = process.cwd()) {
1419
1543
  } else {
1420
1544
  const claim = oneLine(rec.message ?? "uncommitted edits in progress");
1421
1545
  lines.push(
1422
- ` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
1546
+ ` ${oneLine(rec.agentName)} / ${oneLine(rec.human)} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
1423
1547
  );
1424
1548
  lines.push(` files: ${oneLine(rec.paths.join(", "))}`);
1425
1549
  }
@@ -1441,6 +1565,19 @@ function degradedResult(err) {
1441
1565
  ]
1442
1566
  };
1443
1567
  }
1568
+ function malformedResponseResult(endpoint) {
1569
+ console.error(
1570
+ `[shepherd] ${endpoint} returned a response that failed contract validation \u2014 proceeding uncoordinated.`
1571
+ );
1572
+ return {
1573
+ content: [
1574
+ {
1575
+ type: "text",
1576
+ text: "Coordination hub returned an invalid response \u2014 proceeding uncoordinated."
1577
+ }
1578
+ ]
1579
+ };
1580
+ }
1444
1581
  function registerTools(server, deps) {
1445
1582
  const { hubClient, config, context, heartbeat, inboxFile } = deps;
1446
1583
  const markerCwd = deps.cwd ?? process.cwd();
@@ -1448,6 +1585,7 @@ function registerTools(server, deps) {
1448
1585
  const repoRoot = findRepoRoot(markerCwd);
1449
1586
  let sessionId = null;
1450
1587
  let agentName = null;
1588
+ let activeWorkspaceSlug = null;
1451
1589
  const isHosted = Boolean(config.SHEPHERD_TOKEN);
1452
1590
  const selfHostMismatch = !isHosted && context.linked && config.WORKSPACE !== void 0 && config.WORKSPACE !== context.workspace;
1453
1591
  let hostedWorkspaceRejected = false;
@@ -1480,6 +1618,9 @@ function registerTools(server, deps) {
1480
1618
  let joinFailure = null;
1481
1619
  let joinInFlight = Promise.resolve();
1482
1620
  async function activate(workspaceSlug) {
1621
+ if (sessionId !== null && activeWorkspaceSlug === workspaceSlug) {
1622
+ return { ok: true };
1623
+ }
1483
1624
  const joinBody = {
1484
1625
  workspace: workspaceSlug,
1485
1626
  repo: context.repo,
@@ -1505,6 +1646,7 @@ function registerTools(server, deps) {
1505
1646
  heartbeat.start(newSessionId);
1506
1647
  sessionId = newSessionId;
1507
1648
  agentName = parsed.data.agentName;
1649
+ activeWorkspaceSlug = workspaceSlug;
1508
1650
  linked = true;
1509
1651
  hostedWorkspaceRejected = false;
1510
1652
  joinFailure = null;
@@ -1598,7 +1740,10 @@ ${body}` : body;
1598
1740
  function withChangeRecords(landscape, body) {
1599
1741
  let section = "";
1600
1742
  try {
1601
- section = formatChangeRecords(landscape.changeRecords ?? [], process.cwd());
1743
+ section = formatChangeRecords(
1744
+ landscape.changeRecords ?? [],
1745
+ process.cwd()
1746
+ );
1602
1747
  } catch {
1603
1748
  section = "";
1604
1749
  }
@@ -1618,8 +1763,16 @@ ${section}` : body;
1618
1763
  if (gated) return gated;
1619
1764
  try {
1620
1765
  const changeReport = await changeReportForBody();
1621
- const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
1622
- const result = await hubClient.post("/work", body);
1766
+ const body = {
1767
+ sessionId,
1768
+ ...args,
1769
+ ...changeReport ? { changeReport } : {}
1770
+ };
1771
+ const parsed = WorkResponse.safeParse(
1772
+ await hubClient.post("/work", body)
1773
+ );
1774
+ if (!parsed.success) return malformedResponseResult("/work");
1775
+ const result = parsed.data;
1623
1776
  result.landscape.announcements = mergeAnnouncements(
1624
1777
  result.landscape.announcements,
1625
1778
  drainLocalInbox()
@@ -1655,17 +1808,19 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
1655
1808
  if (gated) return gated;
1656
1809
  try {
1657
1810
  const body = { sessionId, ...args };
1658
- const result = await hubClient.post("/done", body);
1811
+ const parsed = DoneResponse.safeParse(
1812
+ await hubClient.post("/done", body)
1813
+ );
1814
+ if (!parsed.success) return malformedResponseResult("/done");
1815
+ const result = parsed.data;
1659
1816
  const base = "Work item released. Call work again before your next edit in a new area.";
1660
1817
  const msgs = formatAnnouncements(
1661
1818
  mergeAnnouncements(result.announcements, drainLocalInbox())
1662
1819
  );
1663
1820
  return {
1664
- content: [
1665
- { type: "text", text: msgs ? `${base}
1821
+ content: [{ type: "text", text: msgs ? `${base}
1666
1822
 
1667
- ${msgs}` : base }
1668
- ]
1823
+ ${msgs}` : base }]
1669
1824
  };
1670
1825
  } catch (err) {
1671
1826
  if (err instanceof HubUnreachable || err instanceof HubRequestError) {
@@ -1687,17 +1842,19 @@ ${msgs}` : base }
1687
1842
  if (gated) return gated;
1688
1843
  try {
1689
1844
  const body = { sessionId, ...args };
1690
- const result = await hubClient.post("/announce", body);
1845
+ const parsed = AnnounceResponse.safeParse(
1846
+ await hubClient.post("/announce", body)
1847
+ );
1848
+ if (!parsed.success) return malformedResponseResult("/announce");
1849
+ const result = parsed.data;
1691
1850
  const base = `Announcement sent (id: ${result.announcementId}).`;
1692
1851
  const msgs = formatAnnouncements(
1693
1852
  mergeAnnouncements(result.announcements, drainLocalInbox())
1694
1853
  );
1695
1854
  return {
1696
- content: [
1697
- { type: "text", text: msgs ? `${base}
1855
+ content: [{ type: "text", text: msgs ? `${base}
1698
1856
 
1699
- ${msgs}` : base }
1700
- ]
1857
+ ${msgs}` : base }]
1701
1858
  };
1702
1859
  } catch (err) {
1703
1860
  if (err instanceof HubUnreachable || err instanceof HubRequestError) {
@@ -1720,13 +1877,20 @@ ${msgs}` : base }
1720
1877
  try {
1721
1878
  const changeReport = await changeReportForBody();
1722
1879
  const body = { sessionId, ...changeReport ? { changeReport } : {} };
1723
- const result = await hubClient.post("/sync", body);
1880
+ const parsed = SyncResponse.safeParse(
1881
+ await hubClient.post("/sync", body)
1882
+ );
1883
+ if (!parsed.success) return malformedResponseResult("/sync");
1884
+ const result = parsed.data;
1724
1885
  result.landscape.announcements = mergeAnnouncements(
1725
1886
  result.landscape.announcements,
1726
1887
  drainLocalInbox()
1727
1888
  );
1728
1889
  const text = withIdentity(
1729
- withChangeRecords(result.landscape, formatLandscape(result.landscape))
1890
+ withChangeRecords(
1891
+ result.landscape,
1892
+ formatLandscape(result.landscape)
1893
+ )
1730
1894
  );
1731
1895
  return { content: [{ type: "text", text }] };
1732
1896
  } catch (err) {
@@ -1752,7 +1916,9 @@ ${msgs}` : base }
1752
1916
  tripwire?.stop();
1753
1917
  const result = await activate(slug);
1754
1918
  if (result.ok) {
1755
- return advisory(`Linked this repo to \`${slug}\` \u2014 coordinating in \`${slug}\` now.`);
1919
+ return advisory(
1920
+ `Linked this repo to \`${slug}\` \u2014 coordinating in \`${slug}\` now.`
1921
+ );
1756
1922
  }
1757
1923
  return advisory(
1758
1924
  `Linked this repo to \`${slug}\`, but coordination couldn't start just now (${joinFailureCause(joinFailure)}). It'll connect on your next tool call or session.`
@@ -1764,7 +1930,9 @@ ${msgs}` : base }
1764
1930
  title: "Link this repo to a Shepherd workspace",
1765
1931
  description: "Opt this repository into Shepherd coordination by writing a committed `.shepherd` marker naming the workspace. Call with no argument: if you belong to exactly one workspace it is linked and coordination starts immediately; if you belong to several, the choices are listed for you to confirm one with the agent's user, then call `link` again with that `workspace`. You can only link to a workspace you are a member of. Takes effect immediately \u2014 no restart. Use `unlink` to opt out, or `decline` to stay uncoordinated without linking.",
1766
1932
  inputSchema: z3.object({
1767
- workspace: z3.string().min(1).optional().describe("The workspace slug to link this repo to. Omit to auto-pick or list choices.")
1933
+ workspace: z3.string().min(1).optional().describe(
1934
+ "The workspace slug to link this repo to. Omit to auto-pick or list choices."
1935
+ )
1768
1936
  }).shape
1769
1937
  },
1770
1938
  async (args) => {
@@ -1825,6 +1993,7 @@ ${msgs}` : base }
1825
1993
  await leave();
1826
1994
  sessionId = null;
1827
1995
  agentName = null;
1996
+ activeWorkspaceSlug = null;
1828
1997
  }
1829
1998
  return advisory(
1830
1999
  "Unlinked \u2014 this repo will stay uncoordinated and won't ask again. Run `link` to re-enable."
@@ -1861,7 +2030,14 @@ ${msgs}` : base }
1861
2030
  );
1862
2031
  }
1863
2032
  }
1864
- gatedTools.push(workTool, doneTool, announceTool, syncTool, unlinkTool, declineTool);
2033
+ gatedTools.push(
2034
+ workTool,
2035
+ doneTool,
2036
+ announceTool,
2037
+ syncTool,
2038
+ unlinkTool,
2039
+ declineTool
2040
+ );
1865
2041
  surfaceVisible = true;
1866
2042
  syncToolSurface();
1867
2043
  async function runFirstRunAsk() {
@@ -1889,7 +2065,9 @@ ${msgs}` : base }
1889
2065
  appendAnnouncements(inboxFile, [postLinkGuidance(workspace ?? "")]);
1890
2066
  }
1891
2067
  if (outcome !== "unanswered") {
1892
- console.error(`[shepherd] first-run ask answered by the user: ${outcome}`);
2068
+ console.error(
2069
+ `[shepherd] first-run ask answered by the user: ${outcome}`
2070
+ );
1893
2071
  }
1894
2072
  } catch (err) {
1895
2073
  console.error(
@@ -1910,20 +2088,21 @@ ${msgs}` : base }
1910
2088
  return { ready: joinInFlight, leave };
1911
2089
  }
1912
2090
  function postLinkGuidance(workspace) {
2091
+ const safeWorkspace = workspace.replace(/\s+/g, " ").slice(0, 64);
1913
2092
  return {
1914
2093
  id: -Date.now(),
1915
2094
  fromAgentName: "shepherd",
1916
2095
  fromHuman: "shepherd",
1917
2096
  targetAgentName: null,
1918
2097
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1919
- body: `The user just linked this repository to the \`${workspace}\` workspace \u2014 coordination is NOW ACTIVE. Standing procedure from here on: call \`work\` (one-line intent + pathGlobs) BEFORE changing files in an area; \`done\` when that unit of work is complete; \`announce\` anything teammates need to know; \`sync\` when you resume or switch tasks. Start by calling \`work\` for the files you're editing right now.`
2098
+ body: `The user just linked this repository to the \`${safeWorkspace}\` workspace \u2014 coordination is NOW ACTIVE. Standing procedure from here on: call \`work\` (one-line intent + pathGlobs) BEFORE changing files in an area; \`done\` when that unit of work is complete; \`announce\` anything teammates need to know; \`sync\` when you resume or switch tasks. Start by calling \`work\` for the files you're editing right now.`
1920
2099
  };
1921
2100
  }
1922
2101
 
1923
2102
  // src/identityCache.ts
1924
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
1925
- import { homedir as homedir3, tmpdir as tmpdir3 } from "os";
1926
- import { dirname as dirname4, join as join4 } from "path";
2103
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
2104
+ import { homedir as homedir3, tmpdir as tmpdir3 } from "node:os";
2105
+ import { dirname as dirname4, join as join4 } from "node:path";
1927
2106
  function defaultIdentityCachePath() {
1928
2107
  let base = "";
1929
2108
  try {
@@ -1954,7 +2133,7 @@ function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
1954
2133
  try {
1955
2134
  mkdirSync3(dirname4(filePath), { recursive: true });
1956
2135
  const payload = JSON.stringify({ human });
1957
- writeFileSync3(filePath, payload + "\n", "utf8");
2136
+ writeFileSync4(filePath, payload + "\n", "utf8");
1958
2137
  } catch {
1959
2138
  }
1960
2139
  }
@@ -1984,7 +2163,17 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
1984
2163
  const repoRoot = deps.findRepoRoot(cwd);
1985
2164
  const declined = repoRoot !== null ? deps.isDeclined(repoRoot) : false;
1986
2165
  const linkState = linked ? "linked" : declined ? "declined" : "unanswered";
1987
- return { workspace, repo, branch, human, program, model, linked, declined, linkState };
2166
+ return {
2167
+ workspace,
2168
+ repo,
2169
+ branch,
2170
+ human,
2171
+ program,
2172
+ model,
2173
+ linked,
2174
+ declined,
2175
+ linkState
2176
+ };
1988
2177
  }
1989
2178
  function resolveHuman(config, cwd, deps) {
1990
2179
  if (config.HUMAN) return config.HUMAN;
@@ -2003,13 +2192,20 @@ function createHeartbeat({
2003
2192
  hubClient,
2004
2193
  intervalSeconds,
2005
2194
  buildReport,
2006
- announcementSink
2195
+ announcementSink,
2196
+ liveness
2007
2197
  }) {
2008
2198
  let timer = null;
2009
2199
  function stop() {
2010
2200
  if (timer !== null) {
2011
2201
  clearInterval(timer);
2012
2202
  timer = null;
2203
+ if (liveness) {
2204
+ try {
2205
+ liveness.remove();
2206
+ } catch {
2207
+ }
2208
+ }
2013
2209
  }
2014
2210
  }
2015
2211
  async function beat(sessionId) {
@@ -2023,9 +2219,22 @@ function createHeartbeat({
2023
2219
  }
2024
2220
  const body = { sessionId };
2025
2221
  if (changeReport) body.changeReport = changeReport;
2222
+ if (liveness) {
2223
+ try {
2224
+ liveness.refresh();
2225
+ } catch {
2226
+ }
2227
+ }
2026
2228
  if (announcementSink) body.deliverAnnouncements = true;
2027
- const response = await hubClient.post("/heartbeat", body);
2028
- const delivered = response?.announcements;
2229
+ const parsed = HeartbeatResponse.safeParse(
2230
+ await hubClient.post("/heartbeat", body)
2231
+ );
2232
+ if (!parsed.success) {
2233
+ console.error(
2234
+ "[shepherd] heartbeat returned a response that failed contract validation \u2014 ignoring this beat's announcements."
2235
+ );
2236
+ }
2237
+ const delivered = parsed.success ? parsed.data.announcements : [];
2029
2238
  if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
2030
2239
  try {
2031
2240
  announcementSink(delivered);
@@ -2043,6 +2252,12 @@ function createHeartbeat({
2043
2252
  }
2044
2253
  function start(sessionId) {
2045
2254
  stop();
2255
+ if (liveness) {
2256
+ try {
2257
+ liveness.refresh();
2258
+ } catch {
2259
+ }
2260
+ }
2046
2261
  timer = setInterval(() => {
2047
2262
  void beat(sessionId).catch((err) => {
2048
2263
  console.error(
@@ -2056,10 +2271,13 @@ function createHeartbeat({
2056
2271
  }
2057
2272
 
2058
2273
  // src/instructions.ts
2274
+ function sanitizeWorkspace(workspace) {
2275
+ return workspace.replace(/\s+/g, " ").slice(0, 64);
2276
+ }
2059
2277
  function buildInstructions(state, workspace) {
2060
2278
  switch (state) {
2061
2279
  case "linked":
2062
- return `${INTRO} This repository is linked to the \`${workspace ?? "team"}\` workspace, so coordination is active.
2280
+ return `${INTRO} This repository is linked to the \`${workspace ? sanitizeWorkspace(workspace) : "team"}\` workspace, so coordination is active.
2063
2281
 
2064
2282
  ${PROCEDURE}`;
2065
2283
  case "declined":
@@ -2090,14 +2308,108 @@ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or t
2090
2308
 
2091
2309
  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.`;
2092
2310
 
2311
+ // src/processTree.ts
2312
+ import { execFile as execFile2 } from "node:child_process";
2313
+ import { promisify } from "node:util";
2314
+ var execFileAsync = promisify(execFile2);
2315
+ function pidChainFromMap(startPid, parentOf, maxDepth = 32) {
2316
+ const chain = [];
2317
+ const seen = /* @__PURE__ */ new Set();
2318
+ let pid = startPid;
2319
+ while (chain.length < maxDepth && pid > 0 && !seen.has(pid)) {
2320
+ chain.push(pid);
2321
+ seen.add(pid);
2322
+ const parent = parentOf.get(pid);
2323
+ if (parent === void 0) break;
2324
+ pid = parent;
2325
+ }
2326
+ return chain;
2327
+ }
2328
+ function quickChain() {
2329
+ return [process.pid, process.ppid];
2330
+ }
2331
+ function parseWmicProcessList(text) {
2332
+ const map = /* @__PURE__ */ new Map();
2333
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
2334
+ if (lines.length === 0) return map;
2335
+ const header = lines[0].trimStart();
2336
+ let pidFirst;
2337
+ if (header.startsWith("ParentProcessId")) pidFirst = false;
2338
+ else if (header.startsWith("ProcessId")) pidFirst = true;
2339
+ else return map;
2340
+ for (const line of lines.slice(1)) {
2341
+ const nums = line.trim().split(/\s+/).map(Number);
2342
+ if (nums.length !== 2 || nums.some((n) => !Number.isInteger(n))) continue;
2343
+ const [a, b] = nums;
2344
+ const [pid, ppid] = pidFirst ? [a, b] : [b, a];
2345
+ map.set(pid, ppid);
2346
+ }
2347
+ return map;
2348
+ }
2349
+ function parsePidPpidLines(text) {
2350
+ const map = /* @__PURE__ */ new Map();
2351
+ for (const line of text.split(/\r?\n/)) {
2352
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
2353
+ if (m) map.set(Number(m[1]), Number(m[2]));
2354
+ }
2355
+ return map;
2356
+ }
2357
+ async function snapshotParentMap() {
2358
+ if (process.platform === "win32") {
2359
+ try {
2360
+ const { stdout: stdout3 } = await execFileAsync(
2361
+ "wmic",
2362
+ ["process", "get", "ProcessId,ParentProcessId"],
2363
+ { windowsHide: true, timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
2364
+ );
2365
+ const map = parseWmicProcessList(stdout3);
2366
+ if (map.size > 0) return map;
2367
+ } catch {
2368
+ }
2369
+ const { stdout: stdout2 } = await execFileAsync(
2370
+ "powershell.exe",
2371
+ [
2372
+ "-NoProfile",
2373
+ "-NonInteractive",
2374
+ "-Command",
2375
+ 'Get-CimInstance -Query "SELECT ProcessId,ParentProcessId FROM Win32_Process" | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
2376
+ ],
2377
+ { windowsHide: true, timeout: 15e3, maxBuffer: 8 * 1024 * 1024 }
2378
+ );
2379
+ return parsePidPpidLines(stdout2);
2380
+ }
2381
+ const { stdout } = await execFileAsync(
2382
+ "ps",
2383
+ ["-eo", "pid=,ppid="],
2384
+ { timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
2385
+ );
2386
+ return parsePidPpidLines(stdout);
2387
+ }
2388
+ async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
2389
+ try {
2390
+ const map = await snapshot();
2391
+ const chain = pidChainFromMap(process.pid, map, maxDepth);
2392
+ return chain.length >= 2 ? chain : quickChain();
2393
+ } catch {
2394
+ return quickChain();
2395
+ }
2396
+ }
2397
+
2093
2398
  // src/hookInstall.ts
2094
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, copyFileSync, existsSync as existsSync4, renameSync as renameSync2 } from "fs";
2095
- import { homedir as homedir4 } from "os";
2096
- import { dirname as dirname5, join as join5 } from "path";
2097
- import { fileURLToPath } from "url";
2399
+ import {
2400
+ readFileSync as readFileSync5,
2401
+ writeFileSync as writeFileSync5,
2402
+ mkdirSync as mkdirSync4,
2403
+ copyFileSync,
2404
+ existsSync as existsSync4,
2405
+ renameSync as renameSync2
2406
+ } from "node:fs";
2407
+ import { homedir as homedir4 } from "node:os";
2408
+ import { dirname as dirname5, join as join5 } from "node:path";
2409
+ import { fileURLToPath } from "node:url";
2098
2410
 
2099
2411
  // src/version.ts
2100
- import { createRequire } from "module";
2412
+ import { createRequire } from "node:module";
2101
2413
  var PACKAGE_VERSION = (() => {
2102
2414
  try {
2103
2415
  const req = createRequire(import.meta.url);
@@ -2130,7 +2442,7 @@ function ensureHookScript(homeDir, hookScriptSource) {
2130
2442
  if (current === null || !current.equals(next)) {
2131
2443
  mkdirSync4(dirname5(dest), { recursive: true });
2132
2444
  const tmp = dest + ".tmp";
2133
- writeFileSync4(tmp, next);
2445
+ writeFileSync5(tmp, next);
2134
2446
  renameSync2(tmp, dest);
2135
2447
  }
2136
2448
  return dest;
@@ -2139,7 +2451,7 @@ function ensureHookScript(homeDir, hookScriptSource) {
2139
2451
  }
2140
2452
  }
2141
2453
  function hookCommandFor(scriptPath) {
2142
- return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath}"`;
2454
+ return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath.replace(/\\/g, "/")}"`;
2143
2455
  }
2144
2456
  function codexHookBlock(scriptPath) {
2145
2457
  const command = scriptPath === null ? `["npx", "-y", "--package=@korso/shepherd@${PACKAGE_VERSION}", "shepherd-inbox-hook"]` : `["node", ${JSON.stringify(scriptPath)}]`;
@@ -2179,7 +2491,7 @@ async function autoInstallHooks({
2179
2491
  status = installPi(homeDir, extensionSource, log);
2180
2492
  }
2181
2493
  mkdirSync4(dirname5(recordFile), { recursive: true });
2182
- writeFileSync4(
2494
+ writeFileSync5(
2183
2495
  recordFile,
2184
2496
  JSON.stringify({ status, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
2185
2497
  "utf8"
@@ -2221,14 +2533,18 @@ function installClaude(homeDir, scriptPath, log) {
2221
2533
  }
2222
2534
  const hooks = settings["hooks"] ??= {};
2223
2535
  if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
2224
- log(`[shepherd] ${settingsFile} has an unexpected "hooks" shape \u2014 not touching it.`);
2536
+ log(
2537
+ `[shepherd] ${settingsFile} has an unexpected "hooks" shape \u2014 not touching it.`
2538
+ );
2225
2539
  return "skipped";
2226
2540
  }
2227
2541
  const hooksObj = hooks;
2228
2542
  for (const event of ["SessionStart", "PreToolUse"]) {
2229
2543
  const existing = hooksObj[event] ??= [];
2230
2544
  if (!Array.isArray(existing)) {
2231
- log(`[shepherd] ${settingsFile} has an unexpected hooks.${event} shape \u2014 not touching it.`);
2545
+ log(
2546
+ `[shepherd] ${settingsFile} has an unexpected hooks.${event} shape \u2014 not touching it.`
2547
+ );
2232
2548
  return "skipped";
2233
2549
  }
2234
2550
  }
@@ -2241,7 +2557,7 @@ function installClaude(homeDir, scriptPath, log) {
2241
2557
  hooks: [{ type: "command", command }]
2242
2558
  });
2243
2559
  mkdirSync4(dirname5(settingsFile), { recursive: true });
2244
- writeFileSync4(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2560
+ writeFileSync5(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2245
2561
  return "installed";
2246
2562
  }
2247
2563
  function installCodex(homeDir, scriptPath, log) {
@@ -2250,7 +2566,7 @@ function installCodex(homeDir, scriptPath, log) {
2250
2566
  const hookBlock = codexHookBlock(scriptPath);
2251
2567
  if (!existsSync4(configFile)) {
2252
2568
  mkdirSync4(dirname5(configFile), { recursive: true });
2253
- writeFileSync4(configFile, `[features]
2569
+ writeFileSync5(configFile, `[features]
2254
2570
  hooks = true
2255
2571
  ${hookBlock}`, "utf8");
2256
2572
  return "installed";
@@ -2258,13 +2574,17 @@ ${hookBlock}`, "utf8");
2258
2574
  const toml = readFileSync5(configFile, "utf8");
2259
2575
  if (toml.includes(HOOK_MARKER)) return "already-present";
2260
2576
  if (/^\s*\[hooks\.UserPromptSubmit\]\s*$/m.test(toml)) {
2261
- log(`[shepherd] ${configFile} defines [hooks.UserPromptSubmit] \u2014 not touching it. ${manualHint}`);
2577
+ log(
2578
+ `[shepherd] ${configFile} defines [hooks.UserPromptSubmit] \u2014 not touching it. ${manualHint}`
2579
+ );
2262
2580
  return "skipped";
2263
2581
  }
2264
2582
  if (/^\s*\[features\]/m.test(toml)) {
2265
2583
  const hooksKey = /^\s*hooks\s*=\s*(.+)$/m.exec(toml);
2266
2584
  if (hooksKey && hooksKey[1].trim() !== "true") {
2267
- log(`[shepherd] ${configFile} sets hooks = ${hooksKey[1].trim()} \u2014 respecting it. ${manualHint}`);
2585
+ log(
2586
+ `[shepherd] ${configFile} sets hooks = ${hooksKey[1].trim()} \u2014 respecting it. ${manualHint}`
2587
+ );
2268
2588
  return "skipped";
2269
2589
  }
2270
2590
  let updated = toml;
@@ -2272,13 +2592,17 @@ ${hookBlock}`, "utf8");
2272
2592
  updated = toml.replace(/^(\s*\[features\]\s*)$/m, `$1
2273
2593
  hooks = true`);
2274
2594
  }
2275
- writeFileSync4(configFile, updated + hookBlock, "utf8");
2595
+ writeFileSync5(configFile, updated + hookBlock, "utf8");
2276
2596
  return "installed";
2277
2597
  }
2278
- writeFileSync4(configFile, `${toml}
2598
+ writeFileSync5(
2599
+ configFile,
2600
+ `${toml}
2279
2601
  [features]
2280
2602
  hooks = true
2281
- ${hookBlock}`, "utf8");
2603
+ ${hookBlock}`,
2604
+ "utf8"
2605
+ );
2282
2606
  return "installed";
2283
2607
  }
2284
2608
  function installCursor(homeDir, scriptPath, log) {
@@ -2306,7 +2630,9 @@ function installCursor(homeDir, scriptPath, log) {
2306
2630
  config["version"] ??= 1;
2307
2631
  const hooks = config["hooks"] ??= {};
2308
2632
  if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
2309
- log(`[shepherd] ${hooksFile} has an unexpected "hooks" shape \u2014 not touching it.`);
2633
+ log(
2634
+ `[shepherd] ${hooksFile} has an unexpected "hooks" shape \u2014 not touching it.`
2635
+ );
2310
2636
  return "skipped";
2311
2637
  }
2312
2638
  const hooksObj = hooks;
@@ -2319,7 +2645,7 @@ function installCursor(homeDir, scriptPath, log) {
2319
2645
  }
2320
2646
  entries.push({ command: hookCommandFor(scriptPath) });
2321
2647
  mkdirSync4(dirname5(hooksFile), { recursive: true });
2322
- writeFileSync4(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
2648
+ writeFileSync5(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
2323
2649
  return "installed";
2324
2650
  }
2325
2651
  function installPi(homeDir, extensionSource, log) {
@@ -2327,7 +2653,9 @@ function installPi(homeDir, extensionSource, log) {
2327
2653
  const dest = join5(homeDir, ".pi", "agent", "extensions", "shepherd-inbox.js");
2328
2654
  if (existsSync4(dest)) return "already-present";
2329
2655
  if (!existsSync4(source)) {
2330
- log(`[shepherd] bundled Pi extension not found at ${source} \u2014 skipping auto-install.`);
2656
+ log(
2657
+ `[shepherd] bundled Pi extension not found at ${source} \u2014 skipping auto-install.`
2658
+ );
2331
2659
  return "skipped";
2332
2660
  }
2333
2661
  mkdirSync4(dirname5(dest), { recursive: true });
@@ -2338,10 +2666,27 @@ function installPi(homeDir, extensionSource, log) {
2338
2666
  // src/index.ts
2339
2667
  async function main() {
2340
2668
  const config = loadConfig();
2341
- const hubClient = createHubClient({ hubUrl: config.HUB_URL, token: config.authToken });
2669
+ const hubClient = createHubClient({
2670
+ hubUrl: config.HUB_URL,
2671
+ token: config.authToken
2672
+ });
2342
2673
  const context = await resolveContext(config);
2343
2674
  const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
2344
- const inboxFile = inboxFilePath(inboxDir, process.cwd());
2675
+ const inboxFile = sessionMailboxPath(inboxDir, process.pid);
2676
+ const launchCwd = process.cwd();
2677
+ let serverChain = quickChain();
2678
+ const liveness = {
2679
+ refresh: () => writeMailboxMeta(inboxDir, process.pid, {
2680
+ cwd: launchCwd,
2681
+ chain: serverChain
2682
+ }),
2683
+ remove: () => removeMailboxMeta(inboxDir, process.pid)
2684
+ };
2685
+ void ancestorChain().then((chain) => {
2686
+ serverChain = chain;
2687
+ liveness.refresh();
2688
+ }).catch(() => {
2689
+ });
2345
2690
  const heartbeat = createHeartbeat({
2346
2691
  hubClient,
2347
2692
  intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
@@ -2354,16 +2699,23 @@ async function main() {
2354
2699
  return void 0;
2355
2700
  }
2356
2701
  },
2357
- // A model-visible sink (this working dir's inbox file). Its presence opts
2358
- // the heartbeat into two-phase announcement delivery: append locally, then
2702
+ // A model-visible sink (this session's mailbox). Its presence opts the
2703
+ // heartbeat into two-phase announcement delivery: append locally, then
2359
2704
  // ack the hub. appendAnnouncements is itself fail-open.
2360
- announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements)
2705
+ announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements),
2706
+ liveness
2361
2707
  });
2362
2708
  const server = new McpServer(
2363
2709
  { name: "shepherd", version: PACKAGE_VERSION },
2364
2710
  { instructions: buildInstructions(context.linkState, context.workspace) }
2365
2711
  );
2366
- const tools = registerTools(server, { hubClient, config, context, heartbeat, inboxFile });
2712
+ const tools = registerTools(server, {
2713
+ hubClient,
2714
+ config,
2715
+ context,
2716
+ heartbeat,
2717
+ inboxFile
2718
+ });
2367
2719
  const transport = new StdioServerTransport();
2368
2720
  server.server.oninitialized = () => {
2369
2721
  void autoInstallHooks({