@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.
- package/README.md +11 -2
- package/dist/index.js +136 -25
- 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
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// (
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
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((
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1296
|
+
if (announcementSink) body.deliverAnnouncements = true;
|
|
1193
1297
|
const response = await hubClient.post("/heartbeat", body);
|
|
1194
1298
|
const delivered = response?.announcements;
|
|
1195
|
-
if (
|
|
1299
|
+
if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
|
|
1196
1300
|
try {
|
|
1197
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
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
|
+
"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",
|