@korso/shepherd 0.4.3 → 0.4.5

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.
Files changed (3) hide show
  1. package/README.md +11 -2
  2. package/dist/index.js +136 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -55,12 +55,21 @@ override only to replace what's detected:
55
55
  | `REPO` | `git remote origin` → `owner/repo`, else repo folder name, else `unknown-repo` | `Korsoai/shepherd` |
56
56
  | `BRANCH` | `git rev-parse --abbrev-ref HEAD`, else `HEAD` | `main` |
57
57
  | `BASE_BRANCH` | `origin/HEAD`, else `origin/main` / `origin/master` (used for the change-awareness heads-up) | `origin/main` |
58
- | `HUMAN` | git `user.name`, else local-part of `user.email`, else a generated name | `daichi` |
58
+ | `HUMAN` | git `user.name`, else local-part of `user.email`, else this device's **cached** last-detected name, else a generated name | `daichi` |
59
59
  | `PROGRAM` | defaults to `claude-code` | `codex` |
60
60
  | `MODEL` | omitted — **never auto-detected**, so set it if you want it shown | `claude-sonnet-4-6` |
61
61
  | `HEARTBEAT_INTERVAL_SECONDS` | defaults to `60` | `30` |
62
62
  | `SHEPHERD_INBOX_DIR` | defaults to `~/.shepherd/inbox`. Override only to relocate the **announcement-push** inbox (see below); the background heartbeat writes incoming announcements here. If you set it, point your client hook/extension at the **same** dir | `~/.shepherd/inbox` |
63
63
 
64
+ **Device-identity cache.** Whenever `HUMAN` is unset and git **does** detect a
65
+ name, that name is cached for your OS user at `~/.shepherd/identity.json`. A
66
+ later launch from a directory where git can't be read (e.g. a multi-repo
67
+ workspace root) then reuses the cached name instead of inventing a fresh random
68
+ one each time. The cache refreshes automatically the next time git reports a
69
+ different name, and an explicit `HUMAN` override always wins and never touches
70
+ the cache. It is best-effort: if the file can't be read or written, resolution
71
+ just falls back to a generated name.
72
+
64
73
  ---
65
74
 
66
75
  ## Announcement push (on by default)
@@ -322,6 +331,6 @@ npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatical
322
331
  |---|---|---|
323
332
  | `Configuration error — missing or invalid env vars` | `HUB_URL` or `TEAM_TOKEN` is absent (only these two are required) | Add the missing var(s) to your client's `env` block |
324
333
  | Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually a stale `TEAM_TOKEN`, or a `WORKSPACE` override the hub doesn't allow | Re-check `TEAM_TOKEN`; leave `WORKSPACE` unset (→ `default`) or match the hub's `ALLOWED_WORKSPACE`; restart |
325
- | Agent shows up under a surprising name/repo/branch | Identity auto-detected from git | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2) |
334
+ | Agent shows up under a surprising name/repo/branch | Identity auto-detected from git, or reused from the device-identity cache when launched outside a git work tree | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2); a correct git `user.name` on the next in-repo launch refreshes the cache, or delete `~/.shepherd/identity.json` to clear it |
326
335
  | `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
327
336
  | Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
package/dist/index.js CHANGED
@@ -109,9 +109,17 @@ function createHubClient({
109
109
  clearTimeout(timer);
110
110
  }
111
111
  if (!response.ok) {
112
+ let detail = "";
113
+ try {
114
+ const data = await response.json();
115
+ if (data && typeof data === "object" && "error" in data && typeof data.error === "string") {
116
+ detail = `: ${data.error}`;
117
+ }
118
+ } catch {
119
+ }
112
120
  throw new HubRequestError(
113
121
  response.status,
114
- `Hub returned HTTP ${response.status} for ${path2}`
122
+ `Hub returned HTTP ${response.status} for ${path2}${detail}`
115
123
  );
116
124
  }
117
125
  return response.json();
@@ -461,13 +469,22 @@ var HeartbeatRequest = z2.object({
461
469
  // only when it next calls work/sync). Processed presence-style: it refreshes
462
470
  // change records but, like the rest of heartbeat, does NOT renew claim TTLs.
463
471
  changeReport: ChangeReport.optional(),
464
- // Opt-in: when set, the heartbeat ALSO delivers (and marks delivered) any
465
- // pending announcements for the caller, returned in the response. The MCP
466
- // client only sets this when it has somewhere model-visible to surface them
467
- // (a local inbox file drained by a hook) — otherwise the long-standing
468
- // invariant holds: heartbeat must NOT consume announcements the model can't
469
- // see. Absent for older clients, so default delivery is unchanged.
470
- deliverAnnouncements: z2.boolean().optional()
472
+ // Opt-in: when set, the heartbeat returns any pending announcements for the
473
+ // caller in the response. Delivery is now TWO-PHASE and crash-safe: this fetch
474
+ // phase does NOT mark them delivered — the client persists them to its
475
+ // model-visible sink (the local inbox file drained by a hook) FIRST, then acks
476
+ // via `ackAnnouncementIds` so the hub records the delivery only after the local
477
+ // write is confirmed. The MCP client only sets this when it actually has such a
478
+ // sink. Absent for older clients, so default behaviour (no delivery) is
479
+ // unchanged.
480
+ deliverAnnouncements: z2.boolean().optional(),
481
+ // Phase-two ack of a previous `deliverAnnouncements` fetch: the ids the client
482
+ // has now durably written to its model-visible sink. The hub marks exactly
483
+ // these delivered to the caller's session. Decoupling the mark from the fetch
484
+ // guarantees a message is never recorded delivered before the client holds it
485
+ // (a lost response or a failed local append simply leaves it pending for the
486
+ // next beat). Absent on a plain presence/fetch beat.
487
+ ackAnnouncementIds: z2.array(DbId).optional()
471
488
  });
472
489
  var HeartbeatResponse = z2.object({
473
490
  ok: z2.literal(true),
@@ -810,6 +827,29 @@ function mergeAnnouncements(...lists) {
810
827
  }
811
828
 
812
829
  // src/tools.ts
830
+ function classifyJoinFailure(err) {
831
+ if (err instanceof HubUnreachable) return "unreachable";
832
+ if (err instanceof HubRequestError) {
833
+ if (err.status === 401) return "auth";
834
+ if (err.status === 400) return "validation";
835
+ return "unknown";
836
+ }
837
+ return "unknown";
838
+ }
839
+ function joinFailureCause(reason) {
840
+ switch (reason) {
841
+ case "unreachable":
842
+ return "hub unreachable at startup";
843
+ case "auth":
844
+ return "hub rejected the team token (check SHEPHERD/TEAM token)";
845
+ case "validation":
846
+ return "hub rejected the join (workspace/branch not allowed, or returned an invalid response)";
847
+ case "unknown":
848
+ return "join failed with an unexpected error";
849
+ default:
850
+ return "coordination session not established yet";
851
+ }
852
+ }
813
853
  function formatLandscape(landscape) {
814
854
  const lines = [];
815
855
  if (landscape.conflicts.length > 0) {
@@ -931,6 +971,7 @@ function registerTools(server, deps) {
931
971
  const { hubClient, config, context, heartbeat, inboxFile } = deps;
932
972
  let sessionId = null;
933
973
  let agentName = null;
974
+ let joinFailure = null;
934
975
  const joinBody = {
935
976
  workspace: context.workspace,
936
977
  repo: context.repo,
@@ -941,11 +982,23 @@ function registerTools(server, deps) {
941
982
  if (context.model !== void 0) {
942
983
  joinBody.model = context.model;
943
984
  }
944
- const joinInFlight = hubClient.post("/join", joinBody).then((r) => {
945
- sessionId = r.sessionId;
946
- agentName = r.agentName;
947
- heartbeat.start(r.sessionId);
948
- }).catch(() => {
985
+ const joinInFlight = hubClient.post("/join", joinBody).then((raw) => {
986
+ const parsed = JoinResponse.safeParse(raw);
987
+ if (!parsed.success || !parsed.data.sessionId) {
988
+ joinFailure = "validation";
989
+ console.error(
990
+ "[shepherd] join failed (validation): hub returned a malformed join response (no usable sessionId)"
991
+ );
992
+ return;
993
+ }
994
+ sessionId = parsed.data.sessionId;
995
+ agentName = parsed.data.agentName;
996
+ heartbeat.start(parsed.data.sessionId);
997
+ }).catch((err) => {
998
+ joinFailure = classifyJoinFailure(err);
999
+ console.error(
1000
+ `[shepherd] join failed (${joinFailure}): ${err instanceof Error ? err.message : String(err)}`
1001
+ );
949
1002
  });
950
1003
  async function awaitJoin() {
951
1004
  await joinInFlight;
@@ -955,7 +1008,7 @@ function registerTools(server, deps) {
955
1008
  content: [
956
1009
  {
957
1010
  type: "text",
958
- text: "Shepherd coordination session not ready (hub unreachable at startup) \u2014 proceeding uncoordinated."
1011
+ text: `Shepherd coordination session not ready (${joinFailureCause(joinFailure)}) \u2014 proceeding uncoordinated.`
959
1012
  }
960
1013
  ]
961
1014
  };
@@ -1068,7 +1121,7 @@ ${msgs}` : base }
1068
1121
  "announce",
1069
1122
  {
1070
1123
  title: "Broadcast a message to teammates",
1071
- description: "Broadcast a heads-up to the other agents, direct a finding to a specific agent, or reply to the human operator. This is awareness only \u2014 not a task assignment. To direct it to an agent, pass that agent's name (exactly as shown in the landscape) as targetAgentName; to reply to the operator (the dashboard), pass toAdmin: true; omit both to broadcast to everyone in the workspace. targetAgentName and toAdmin are mutually exclusive. Delivery is best-effort: the recipient sees it on their next work/sync, once.",
1124
+ description: "Broadcast a heads-up to the other agents, direct a finding to a specific agent, or reply to the human operator. This is awareness only \u2014 not a task assignment. To direct it to an agent, pass that agent's EXACT name as shown in the landscape \u2014 including its numeric suffix (e.g. 'alex-rivera-2', NOT the bare handle 'alex-rivera') \u2014 as targetAgentName. Several agents can share one handle (alex-rivera-1, alex-rivera-2, \u2026); the suffix is what picks one, so the bare handle is rejected. The hub rejects any targetAgentName that doesn't match a live agent in your repo \u2014 if you mean the whole team, omit it to broadcast. To reply to the operator (the dashboard), pass toAdmin: true. targetAgentName and toAdmin are mutually exclusive. Delivery is best-effort: the recipient sees it on their next work/sync, once.",
1072
1125
  inputSchema: AnnounceAgentInput.shape
1073
1126
  },
1074
1127
  async (args) => {
@@ -1144,11 +1197,52 @@ ${msgs}` : base }
1144
1197
  return { ready: joinInFlight, leave };
1145
1198
  }
1146
1199
 
1200
+ // src/identityCache.ts
1201
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
1202
+ import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
1203
+ import { dirname as dirname2, join as join2 } from "path";
1204
+ function defaultIdentityCachePath() {
1205
+ let base = "";
1206
+ try {
1207
+ base = homedir2();
1208
+ } catch {
1209
+ base = "";
1210
+ }
1211
+ if (!base) base = tmpdir2();
1212
+ return join2(base, ".shepherd", "identity.json");
1213
+ }
1214
+ function readCachedHuman(filePath = defaultIdentityCachePath()) {
1215
+ let raw;
1216
+ try {
1217
+ raw = readFileSync2(filePath, "utf8");
1218
+ } catch {
1219
+ return null;
1220
+ }
1221
+ try {
1222
+ const parsed = JSON.parse(raw);
1223
+ const human = typeof parsed?.human === "string" ? parsed.human.trim() : "";
1224
+ return human.length > 0 ? human : null;
1225
+ } catch {
1226
+ return null;
1227
+ }
1228
+ }
1229
+ function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
1230
+ if (typeof human !== "string" || human.trim().length === 0) return;
1231
+ try {
1232
+ mkdirSync2(dirname2(filePath), { recursive: true });
1233
+ const payload = JSON.stringify({ human });
1234
+ writeFileSync(filePath, payload + "\n", "utf8");
1235
+ } catch {
1236
+ }
1237
+ }
1238
+
1147
1239
  // src/resolveContext.ts
1148
1240
  var defaultDeps = {
1149
1241
  detectRepo,
1150
1242
  detectBranch,
1151
- detectHuman
1243
+ detectHuman,
1244
+ readCachedHuman,
1245
+ writeCachedHuman
1152
1246
  };
1153
1247
  var DEFAULT_WORKSPACE = "default";
1154
1248
  async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
@@ -1156,20 +1250,30 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
1156
1250
  config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo"
1157
1251
  );
1158
1252
  const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
1159
- const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
1253
+ const human = resolveHuman(config, cwd, deps);
1160
1254
  const program = config.PROGRAM ?? "claude-code";
1161
1255
  const model = config.MODEL ?? void 0;
1162
1256
  const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
1163
1257
  return { workspace, repo, branch, human, program, model };
1164
1258
  }
1259
+ function resolveHuman(config, cwd, deps) {
1260
+ if (config.HUMAN) return config.HUMAN;
1261
+ const detected = deps.detectHuman(cwd);
1262
+ if (detected) {
1263
+ deps.writeCachedHuman(detected);
1264
+ return detected;
1265
+ }
1266
+ const cached = deps.readCachedHuman();
1267
+ if (cached) return cached;
1268
+ return generateName();
1269
+ }
1165
1270
 
1166
1271
  // src/heartbeat.ts
1167
1272
  function createHeartbeat({
1168
1273
  hubClient,
1169
1274
  intervalSeconds,
1170
1275
  buildReport,
1171
- deliverAnnouncements = false,
1172
- onAnnouncements
1276
+ announcementSink
1173
1277
  }) {
1174
1278
  let timer = null;
1175
1279
  function stop() {
@@ -1189,17 +1293,22 @@ function createHeartbeat({
1189
1293
  }
1190
1294
  const body = { sessionId };
1191
1295
  if (changeReport) body.changeReport = changeReport;
1192
- if (deliverAnnouncements) body.deliverAnnouncements = true;
1296
+ if (announcementSink) body.deliverAnnouncements = true;
1193
1297
  const response = await hubClient.post("/heartbeat", body);
1194
1298
  const delivered = response?.announcements;
1195
- if (onAnnouncements && Array.isArray(delivered) && delivered.length > 0) {
1299
+ if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
1196
1300
  try {
1197
- onAnnouncements(delivered);
1301
+ announcementSink(delivered);
1198
1302
  } catch (err) {
1199
1303
  console.error(
1200
- `[shepherd] inbox delivery failed: ${err instanceof Error ? err.message : String(err)}`
1304
+ `[shepherd] inbox delivery failed (not acking, will retry): ${err instanceof Error ? err.message : String(err)}`
1201
1305
  );
1306
+ return;
1202
1307
  }
1308
+ await hubClient.post("/heartbeat", {
1309
+ sessionId,
1310
+ ackAnnouncementIds: delivered.map((a) => a.id)
1311
+ });
1203
1312
  }
1204
1313
  }
1205
1314
  function start(sessionId) {
@@ -1254,8 +1363,10 @@ async function main() {
1254
1363
  return void 0;
1255
1364
  }
1256
1365
  },
1257
- deliverAnnouncements: true,
1258
- onAnnouncements: (announcements) => appendAnnouncements(inboxFile, announcements)
1366
+ // A model-visible sink (this working dir's inbox file). Its presence opts
1367
+ // the heartbeat into two-phase announcement delivery: append locally, then
1368
+ // ack the hub. appendAnnouncements is itself fail-open.
1369
+ announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements)
1259
1370
  });
1260
1371
  const server = new McpServer(
1261
1372
  { name: "shepherd", version: "0.1.0" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory cross-session coordination tools (work/done/announce/sync) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",