@korso/shepherd 0.9.1 → 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.
@@ -4,8 +4,11 @@ import {
4
4
  appendFileSync,
5
5
  mkdirSync as mkdirSync2,
6
6
  readFileSync as readFileSync3,
7
+ readdirSync,
7
8
  renameSync,
8
9
  rmSync as rmSync3,
10
+ statSync,
11
+ writeFileSync as writeFileSync3,
9
12
  existsSync as existsSync3
10
13
  } from "node:fs";
11
14
  import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
@@ -123,11 +126,79 @@ function defaultInboxDir() {
123
126
  if (!base) base = tmpdir2();
124
127
  return join3(base, ".shepherd", "inbox");
125
128
  }
129
+ function cwdHash(cwd) {
130
+ let normalized = resolve3(cwd);
131
+ if (process.platform === "win32") normalized = normalized.toLowerCase();
132
+ return createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
133
+ }
126
134
  function inboxFilePath(dir, cwd) {
135
+ return join3(dir, `${cwdHash(cwd)}.jsonl`);
136
+ }
137
+ var MAILBOX_TTL_MS = 24 * 60 * 60 * 1e3;
138
+ var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
139
+ function sessionMailboxPath(dir, serverPid) {
140
+ return join3(dir, `agent-${serverPid}.jsonl`);
141
+ }
142
+ function normalizeCwd(cwd) {
127
143
  let normalized = resolve3(cwd);
128
144
  if (process.platform === "win32") normalized = normalized.toLowerCase();
129
- const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
130
- return join3(dir, `${hash}.jsonl`);
145
+ return normalized;
146
+ }
147
+ function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()) {
148
+ try {
149
+ const chain = hookChain.slice(0, 3);
150
+ const wantedCwd = hookCwd === null ? null : normalizeCwd(hookCwd);
151
+ const candidates = [];
152
+ for (const name of readdirSync(dir)) {
153
+ const m = /^agent-(\d+)\.json$/.exec(name);
154
+ if (!m) continue;
155
+ const serverPid = Number(m[1]);
156
+ const metaFile = join3(dir, name);
157
+ let mtimeMs;
158
+ try {
159
+ mtimeMs = statSync(metaFile).mtimeMs;
160
+ } catch {
161
+ continue;
162
+ }
163
+ if (nowMs - mtimeMs > MAILBOX_TTL_MS) {
164
+ try {
165
+ rmSync3(metaFile, { force: true });
166
+ rmSync3(sessionMailboxPath(dir, serverPid), { force: true });
167
+ rmSync3(`${sessionMailboxPath(dir, serverPid)}.draining`, {
168
+ force: true
169
+ });
170
+ } catch {
171
+ }
172
+ continue;
173
+ }
174
+ if (nowMs - mtimeMs > staleMs) continue;
175
+ let meta;
176
+ try {
177
+ meta = JSON.parse(readFileSync3(metaFile, "utf8"));
178
+ } catch {
179
+ continue;
180
+ }
181
+ if (!Array.isArray(meta.chain) || typeof meta.cwd !== "string") continue;
182
+ const i = chain.findIndex((pid) => meta.chain.includes(pid));
183
+ if (i === -1) continue;
184
+ const j = meta.chain.indexOf(chain[i]);
185
+ if (i >= 2 && (j > 2 || wantedCwd === null || meta.cwd !== wantedCwd))
186
+ continue;
187
+ candidates.push({ pid: serverPid, i, j, cwd: meta.cwd });
188
+ }
189
+ if (candidates.length === 0) return [];
190
+ const best = candidates.reduce(
191
+ (a, b) => b.i < a.i || b.i === a.i && b.j < a.j ? b : a
192
+ );
193
+ let winners = candidates.filter((c) => c.i === best.i && c.j === best.j);
194
+ if (winners.length > 1 && new Set(winners.map((w) => w.cwd)).size > 1) {
195
+ if (wantedCwd === null) return [];
196
+ winners = winners.filter((w) => w.cwd === wantedCwd);
197
+ }
198
+ return winners.map((w) => sessionMailboxPath(dir, w.pid));
199
+ } catch {
200
+ return [];
201
+ }
131
202
  }
132
203
  function drainInbox(filePath) {
133
204
  const tmp = `${filePath}.draining`;
@@ -170,21 +241,44 @@ function oneLine(text) {
170
241
  function indentContinuation(text) {
171
242
  return text.replace(/\r?\n/g, "\n ");
172
243
  }
244
+ function relativeAge(iso) {
245
+ const then = Date.parse(iso);
246
+ if (Number.isNaN(then)) return "recently";
247
+ const ms = Date.now() - then;
248
+ if (ms < 0) return "just now";
249
+ const mins = Math.floor(ms / 6e4);
250
+ if (mins < 1) return "just now";
251
+ if (mins < 60) return `${mins}m ago`;
252
+ const hours = Math.floor(mins / 60);
253
+ if (hours < 24) return `${hours}h ago`;
254
+ const days = Math.floor(hours / 24);
255
+ return `${days}d ago`;
256
+ }
173
257
  function formatInboxAnnouncements(announcements) {
174
258
  if (!announcements || announcements.length === 0) return "";
175
259
  const count = announcements.length;
176
260
  const lines = [
177
- `[Shepherd] ${count} new announcement${count === 1 ? "" : "s"} from your teammates:`
261
+ `[Shepherd] ${count} announcement${count === 1 ? "" : "s"} from your teammates:`
178
262
  ];
179
263
  for (const a of announcements) {
180
264
  const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
181
265
  lines.push(
182
- ` [${oneLine(a.fromAgentName)}${target}] ${indentContinuation(a.body)}`
266
+ ` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
183
267
  );
184
268
  }
185
269
  lines.push(REPLY_ROUTING_HINT);
186
270
  return lines.join("\n");
187
271
  }
272
+ function mergeAnnouncements(...lists) {
273
+ const byId = /* @__PURE__ */ new Map();
274
+ for (const list of lists) {
275
+ if (!list) continue;
276
+ for (const a of list) {
277
+ if (!byId.has(a.id)) byId.set(a.id, a);
278
+ }
279
+ }
280
+ return [...byId.values()].sort((x, y) => x.id - y.id);
281
+ }
188
282
 
189
283
  // src/instructions.ts
190
284
  function sanitizeWorkspace(workspace) {
@@ -235,7 +329,10 @@ function shepherdInbox(pi) {
235
329
  try {
236
330
  const dir = process.env["SHEPHERD_INBOX_DIR"] || defaultInboxDir();
237
331
  const cwd = ctx?.cwd ?? process.cwd();
238
- const announcements = drainInbox(inboxFilePath(dir, cwd));
332
+ const announcements = mergeAnnouncements(
333
+ ...selectSessionMailboxes(dir, [process.pid], cwd).map(drainInbox),
334
+ drainInbox(inboxFilePath(dir, cwd))
335
+ );
239
336
  const messageContent = [
240
337
  buildLinkNudge(cwd),
241
338
  formatInboxAnnouncements(announcements)
package/dist/inboxHook.js CHANGED
@@ -6,8 +6,11 @@ import {
6
6
  appendFileSync,
7
7
  mkdirSync as mkdirSync2,
8
8
  readFileSync as readFileSync3,
9
+ readdirSync,
9
10
  renameSync,
10
11
  rmSync as rmSync3,
12
+ statSync,
13
+ writeFileSync as writeFileSync3,
11
14
  existsSync as existsSync3
12
15
  } from "node:fs";
13
16
  import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
@@ -125,11 +128,94 @@ function defaultInboxDir() {
125
128
  if (!base) base = tmpdir2();
126
129
  return join3(base, ".shepherd", "inbox");
127
130
  }
131
+ function cwdHash(cwd) {
132
+ let normalized = resolve3(cwd);
133
+ if (process.platform === "win32") normalized = normalized.toLowerCase();
134
+ return createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
135
+ }
128
136
  function inboxFilePath(dir, cwd) {
137
+ return join3(dir, `${cwdHash(cwd)}.jsonl`);
138
+ }
139
+ var MAILBOX_TTL_MS = 24 * 60 * 60 * 1e3;
140
+ var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
141
+ function sessionMailboxPath(dir, serverPid) {
142
+ return join3(dir, `agent-${serverPid}.jsonl`);
143
+ }
144
+ function normalizeCwd(cwd) {
129
145
  let normalized = resolve3(cwd);
130
146
  if (process.platform === "win32") normalized = normalized.toLowerCase();
131
- const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
132
- return join3(dir, `${hash}.jsonl`);
147
+ return normalized;
148
+ }
149
+ function hasFreshSessionMeta(dir, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()) {
150
+ try {
151
+ for (const name of readdirSync(dir)) {
152
+ if (!/^agent-\d+\.json$/.test(name)) continue;
153
+ try {
154
+ if (nowMs - statSync(join3(dir, name)).mtimeMs <= staleMs) return true;
155
+ } catch {
156
+ continue;
157
+ }
158
+ }
159
+ return false;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+ function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()) {
165
+ try {
166
+ const chain = hookChain.slice(0, 3);
167
+ const wantedCwd = hookCwd === null ? null : normalizeCwd(hookCwd);
168
+ const candidates = [];
169
+ for (const name of readdirSync(dir)) {
170
+ const m = /^agent-(\d+)\.json$/.exec(name);
171
+ if (!m) continue;
172
+ const serverPid = Number(m[1]);
173
+ const metaFile = join3(dir, name);
174
+ let mtimeMs;
175
+ try {
176
+ mtimeMs = statSync(metaFile).mtimeMs;
177
+ } catch {
178
+ continue;
179
+ }
180
+ if (nowMs - mtimeMs > MAILBOX_TTL_MS) {
181
+ try {
182
+ rmSync3(metaFile, { force: true });
183
+ rmSync3(sessionMailboxPath(dir, serverPid), { force: true });
184
+ rmSync3(`${sessionMailboxPath(dir, serverPid)}.draining`, {
185
+ force: true
186
+ });
187
+ } catch {
188
+ }
189
+ continue;
190
+ }
191
+ if (nowMs - mtimeMs > staleMs) continue;
192
+ let meta;
193
+ try {
194
+ meta = JSON.parse(readFileSync3(metaFile, "utf8"));
195
+ } catch {
196
+ continue;
197
+ }
198
+ if (!Array.isArray(meta.chain) || typeof meta.cwd !== "string") continue;
199
+ const i = chain.findIndex((pid) => meta.chain.includes(pid));
200
+ if (i === -1) continue;
201
+ const j = meta.chain.indexOf(chain[i]);
202
+ if (i >= 2 && (j > 2 || wantedCwd === null || meta.cwd !== wantedCwd))
203
+ continue;
204
+ candidates.push({ pid: serverPid, i, j, cwd: meta.cwd });
205
+ }
206
+ if (candidates.length === 0) return [];
207
+ const best = candidates.reduce(
208
+ (a, b) => b.i < a.i || b.i === a.i && b.j < a.j ? b : a
209
+ );
210
+ let winners = candidates.filter((c) => c.i === best.i && c.j === best.j);
211
+ if (winners.length > 1 && new Set(winners.map((w) => w.cwd)).size > 1) {
212
+ if (wantedCwd === null) return [];
213
+ winners = winners.filter((w) => w.cwd === wantedCwd);
214
+ }
215
+ return winners.map((w) => sessionMailboxPath(dir, w.pid));
216
+ } catch {
217
+ return [];
218
+ }
133
219
  }
134
220
  function drainInbox(filePath) {
135
221
  const tmp = `${filePath}.draining`;
@@ -172,26 +258,60 @@ function oneLine(text) {
172
258
  function indentContinuation(text) {
173
259
  return text.replace(/\r?\n/g, "\n ");
174
260
  }
261
+ function relativeAge(iso) {
262
+ const then = Date.parse(iso);
263
+ if (Number.isNaN(then)) return "recently";
264
+ const ms = Date.now() - then;
265
+ if (ms < 0) return "just now";
266
+ const mins = Math.floor(ms / 6e4);
267
+ if (mins < 1) return "just now";
268
+ if (mins < 60) return `${mins}m ago`;
269
+ const hours = Math.floor(mins / 60);
270
+ if (hours < 24) return `${hours}h ago`;
271
+ const days = Math.floor(hours / 24);
272
+ return `${days}d ago`;
273
+ }
175
274
  function formatInboxAnnouncements(announcements) {
176
275
  if (!announcements || announcements.length === 0) return "";
177
276
  const count = announcements.length;
178
277
  const lines = [
179
- `[Shepherd] ${count} new announcement${count === 1 ? "" : "s"} from your teammates:`
278
+ `[Shepherd] ${count} announcement${count === 1 ? "" : "s"} from your teammates:`
180
279
  ];
181
280
  for (const a of announcements) {
182
281
  const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
183
282
  lines.push(
184
- ` [${oneLine(a.fromAgentName)}${target}] ${indentContinuation(a.body)}`
283
+ ` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
185
284
  );
186
285
  }
187
286
  lines.push(REPLY_ROUTING_HINT);
188
287
  return lines.join("\n");
189
288
  }
289
+ function mergeAnnouncements(...lists) {
290
+ const byId = /* @__PURE__ */ new Map();
291
+ for (const list of lists) {
292
+ if (!list) continue;
293
+ for (const a of list) {
294
+ if (!byId.has(a.id)) byId.set(a.id, a);
295
+ }
296
+ }
297
+ return [...byId.values()].sort((x, y) => x.id - y.id);
298
+ }
190
299
  function nativeWorkspacePath(root) {
191
300
  const win = /^\/([A-Za-z]:[/\\].*)$/.exec(root);
192
301
  return win ? win[1] : root;
193
302
  }
194
- function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLinkNudge) {
303
+ function parseHookPairingInput(rawStdin) {
304
+ try {
305
+ const input = JSON.parse(rawStdin.replace(/^\uFEFF/, ""));
306
+ const sessionId = typeof input.session_id === "string" && input.session_id.length > 0 ? input.session_id : void 0;
307
+ const firstRoot = Array.isArray(input.workspace_roots) ? input.workspace_roots[0] : void 0;
308
+ const cwd = typeof input.cwd === "string" && input.cwd.length > 0 ? input.cwd : typeof firstRoot === "string" && firstRoot.length > 0 ? nativeWorkspacePath(firstRoot) : null;
309
+ return { sessionId, cwd };
310
+ } catch {
311
+ return { sessionId: void 0, cwd: null };
312
+ }
313
+ }
314
+ function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLinkNudge, sessionMailboxes = []) {
195
315
  let input;
196
316
  try {
197
317
  input = JSON.parse(rawStdin.replace(/^\uFEFF/, ""));
@@ -210,7 +330,11 @@ function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLi
210
330
  );
211
331
  if (nudgeText) parts.push(nudgeText);
212
332
  if (inboxDir) {
213
- const text = formatInboxAnnouncements(drain(inboxFilePath(inboxDir, cwd)));
333
+ const drained = mergeAnnouncements(
334
+ ...sessionMailboxes.map((box) => drain(box)),
335
+ drain(inboxFilePath(inboxDir, cwd))
336
+ );
337
+ const text = formatInboxAnnouncements(drained);
214
338
  if (text) parts.push(text);
215
339
  }
216
340
  if (parts.length === 0) return "";
@@ -228,6 +352,168 @@ function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLi
228
352
  });
229
353
  }
230
354
 
355
+ // src/hookPairing.ts
356
+ import { createHash as createHash3 } from "node:crypto";
357
+ import {
358
+ readFileSync as readFileSync4,
359
+ writeFileSync as writeFileSync4,
360
+ renameSync as renameSync2,
361
+ readdirSync as readdirSync2,
362
+ rmSync as rmSync4,
363
+ statSync as statSync2
364
+ } from "node:fs";
365
+ import { join as join4 } from "node:path";
366
+
367
+ // src/processTree.ts
368
+ import { execFile } from "node:child_process";
369
+ import { promisify } from "node:util";
370
+ var execFileAsync = promisify(execFile);
371
+ function pidChainFromMap(startPid, parentOf, maxDepth = 32) {
372
+ const chain = [];
373
+ const seen = /* @__PURE__ */ new Set();
374
+ let pid = startPid;
375
+ while (chain.length < maxDepth && pid > 0 && !seen.has(pid)) {
376
+ chain.push(pid);
377
+ seen.add(pid);
378
+ const parent = parentOf.get(pid);
379
+ if (parent === void 0) break;
380
+ pid = parent;
381
+ }
382
+ return chain;
383
+ }
384
+ function quickChain() {
385
+ return [process.pid, process.ppid];
386
+ }
387
+ function parseWmicProcessList(text) {
388
+ const map = /* @__PURE__ */ new Map();
389
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
390
+ if (lines.length === 0) return map;
391
+ const header = lines[0].trimStart();
392
+ let pidFirst;
393
+ if (header.startsWith("ParentProcessId")) pidFirst = false;
394
+ else if (header.startsWith("ProcessId")) pidFirst = true;
395
+ else return map;
396
+ for (const line of lines.slice(1)) {
397
+ const nums = line.trim().split(/\s+/).map(Number);
398
+ if (nums.length !== 2 || nums.some((n) => !Number.isInteger(n))) continue;
399
+ const [a, b] = nums;
400
+ const [pid, ppid] = pidFirst ? [a, b] : [b, a];
401
+ map.set(pid, ppid);
402
+ }
403
+ return map;
404
+ }
405
+ function parsePidPpidLines(text) {
406
+ const map = /* @__PURE__ */ new Map();
407
+ for (const line of text.split(/\r?\n/)) {
408
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
409
+ if (m) map.set(Number(m[1]), Number(m[2]));
410
+ }
411
+ return map;
412
+ }
413
+ async function snapshotParentMap() {
414
+ if (process.platform === "win32") {
415
+ try {
416
+ const { stdout: stdout3 } = await execFileAsync(
417
+ "wmic",
418
+ ["process", "get", "ProcessId,ParentProcessId"],
419
+ { windowsHide: true, timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
420
+ );
421
+ const map = parseWmicProcessList(stdout3);
422
+ if (map.size > 0) return map;
423
+ } catch {
424
+ }
425
+ const { stdout: stdout2 } = await execFileAsync(
426
+ "powershell.exe",
427
+ [
428
+ "-NoProfile",
429
+ "-NonInteractive",
430
+ "-Command",
431
+ 'Get-CimInstance -Query "SELECT ProcessId,ParentProcessId FROM Win32_Process" | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
432
+ ],
433
+ { windowsHide: true, timeout: 15e3, maxBuffer: 8 * 1024 * 1024 }
434
+ );
435
+ return parsePidPpidLines(stdout2);
436
+ }
437
+ const { stdout } = await execFileAsync(
438
+ "ps",
439
+ ["-eo", "pid=,ppid="],
440
+ { timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
441
+ );
442
+ return parsePidPpidLines(stdout);
443
+ }
444
+ async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
445
+ try {
446
+ const map = await snapshot();
447
+ const chain = pidChainFromMap(process.pid, map, maxDepth);
448
+ return chain.length >= 2 ? chain : quickChain();
449
+ } catch {
450
+ return quickChain();
451
+ }
452
+ }
453
+
454
+ // src/hookPairing.ts
455
+ function pairingCachePath(dir, sessionId) {
456
+ const key = createHash3("sha256").update(sessionId).digest("hex").slice(0, 16);
457
+ return join4(dir, `pairing-${key}.json`);
458
+ }
459
+ function readCachedChain(file, nowMs) {
460
+ try {
461
+ const parsed = JSON.parse(readFileSync4(file, "utf8"));
462
+ if (!Array.isArray(parsed.chain) || typeof parsed.ts !== "number")
463
+ return null;
464
+ if (nowMs - parsed.ts > MAILBOX_TTL_MS) return null;
465
+ return parsed.chain;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+ function writeCachedChain(dir, file, chain, nowMs) {
471
+ try {
472
+ for (const name of readdirSync2(dir)) {
473
+ if (!name.startsWith("pairing-") || !name.endsWith(".json")) continue;
474
+ try {
475
+ if (nowMs - statSync2(join4(dir, name)).mtimeMs > MAILBOX_TTL_MS) {
476
+ rmSync4(join4(dir, name), { force: true });
477
+ }
478
+ } catch {
479
+ }
480
+ }
481
+ const tmp = `${file}.tmp`;
482
+ writeFileSync4(tmp, JSON.stringify({ chain, ts: nowMs }));
483
+ renameSync2(tmp, file);
484
+ } catch {
485
+ }
486
+ }
487
+ async function resolveHookMailboxes(inboxDir, { sessionId, cwd }, deps = {}) {
488
+ const quick = deps.quick ?? quickChain;
489
+ const full = deps.full ?? (() => ancestorChain());
490
+ const nowMs = deps.nowMs ?? Date.now();
491
+ try {
492
+ let boxes = selectSessionMailboxes(
493
+ inboxDir,
494
+ quick(),
495
+ cwd,
496
+ void 0,
497
+ nowMs
498
+ );
499
+ if (boxes.length > 0) return boxes;
500
+ if (!hasFreshSessionMeta(inboxDir, void 0, nowMs)) return [];
501
+ const cacheFile = sessionId ? pairingCachePath(inboxDir, sessionId) : null;
502
+ if (cacheFile) {
503
+ const cached = readCachedChain(cacheFile, nowMs);
504
+ if (cached) {
505
+ boxes = selectSessionMailboxes(inboxDir, cached, cwd, void 0, nowMs);
506
+ if (boxes.length > 0) return boxes;
507
+ }
508
+ }
509
+ const chain = await full();
510
+ if (cacheFile) writeCachedChain(inboxDir, cacheFile, chain, nowMs);
511
+ return selectSessionMailboxes(inboxDir, chain, cwd, void 0, nowMs);
512
+ } catch {
513
+ return [];
514
+ }
515
+ }
516
+
231
517
  // src/inboxHook.ts
232
518
  async function readStdin() {
233
519
  const chunks = [];
@@ -240,7 +526,15 @@ async function main() {
240
526
  try {
241
527
  const raw = await readStdin();
242
528
  const inboxDir = process.argv[2] || process.env["SHEPHERD_INBOX_DIR"] || defaultInboxDir();
243
- const out = buildHookOutput(raw, inboxDir);
529
+ let mailboxes = [];
530
+ try {
531
+ mailboxes = await resolveHookMailboxes(
532
+ inboxDir,
533
+ parseHookPairingInput(raw)
534
+ );
535
+ } catch {
536
+ }
537
+ const out = buildHookOutput(raw, inboxDir, void 0, void 0, mailboxes);
244
538
  if (out) process.stdout.write(out);
245
539
  } catch {
246
540
  }
package/dist/index.js CHANGED
@@ -712,6 +712,34 @@ var FeedbackResponse = z2.object({
712
712
  // uuid PK (the feedback table, like workspaces, uses gen_random_uuid()).
713
713
  id: z2.string()
714
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
+ });
715
743
  var TrendPoint = z2.object({
716
744
  // `YYYY-MM-DD` (UTC day).
717
745
  date: z2.string(),
@@ -1144,13 +1172,15 @@ async function buildChangeReport(cwd, config) {
1144
1172
  }
1145
1173
 
1146
1174
  // src/inbox.ts
1147
- import { createHash as createHash2 } from "node:crypto";
1148
1175
  import {
1149
1176
  appendFileSync,
1150
1177
  mkdirSync as mkdirSync2,
1151
1178
  readFileSync as readFileSync3,
1179
+ readdirSync,
1152
1180
  renameSync,
1153
1181
  rmSync as rmSync3,
1182
+ statSync,
1183
+ writeFileSync as writeFileSync3,
1154
1184
  existsSync as existsSync3
1155
1185
  } from "node:fs";
1156
1186
  import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
@@ -1165,11 +1195,37 @@ function defaultInboxDir() {
1165
1195
  if (!base) base = tmpdir2();
1166
1196
  return join3(base, ".shepherd", "inbox");
1167
1197
  }
1168
- 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) {
1169
1207
  let normalized = resolve3(cwd);
1170
1208
  if (process.platform === "win32") normalized = normalized.toLowerCase();
1171
- const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
1172
- 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
+ }
1173
1229
  }
1174
1230
  function appendAnnouncements(filePath, announcements) {
1175
1231
  if (!announcements || announcements.length === 0) return;
@@ -1221,6 +1277,19 @@ function oneLine(text) {
1221
1277
  function indentContinuation(text) {
1222
1278
  return text.replace(/\r?\n/g, "\n ");
1223
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
+ }
1224
1293
  function mergeAnnouncements(...lists) {
1225
1294
  const byId = /* @__PURE__ */ new Map();
1226
1295
  for (const list of lists) {
@@ -1419,7 +1488,7 @@ function formatLandscape(landscape) {
1419
1488
  for (const a of landscape.announcements) {
1420
1489
  const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
1421
1490
  lines.push(
1422
- ` [${oneLine(a.fromAgentName)}${target}] ${indentContinuation(a.body)}`
1491
+ ` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
1423
1492
  );
1424
1493
  }
1425
1494
  lines.push(REPLY_ROUTING_HINT);
@@ -1434,25 +1503,12 @@ function formatAnnouncements(announcements) {
1434
1503
  for (const a of announcements) {
1435
1504
  const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
1436
1505
  lines.push(
1437
- ` [${oneLine(a.fromAgentName)}${target}] ${indentContinuation(a.body)}`
1506
+ ` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
1438
1507
  );
1439
1508
  }
1440
1509
  lines.push(REPLY_ROUTING_HINT);
1441
1510
  return lines.join("\n");
1442
1511
  }
1443
- function relativeAge(iso) {
1444
- const then = Date.parse(iso);
1445
- if (Number.isNaN(then)) return "recently";
1446
- const ms = Date.now() - then;
1447
- if (ms < 0) return "just now";
1448
- const mins = Math.floor(ms / 6e4);
1449
- if (mins < 1) return "just now";
1450
- if (mins < 60) return `${mins}m ago`;
1451
- const hours = Math.floor(mins / 60);
1452
- if (hours < 24) return `${hours}h ago`;
1453
- const days = Math.floor(hours / 24);
1454
- return `${days}d ago`;
1455
- }
1456
1512
  function presence(rec) {
1457
1513
  return rec.authorIsLive ? "active now" : `offline, last seen ${relativeAge(rec.authorLastActiveAt)}`;
1458
1514
  }
@@ -1529,6 +1585,7 @@ function registerTools(server, deps) {
1529
1585
  const repoRoot = findRepoRoot(markerCwd);
1530
1586
  let sessionId = null;
1531
1587
  let agentName = null;
1588
+ let activeWorkspaceSlug = null;
1532
1589
  const isHosted = Boolean(config.SHEPHERD_TOKEN);
1533
1590
  const selfHostMismatch = !isHosted && context.linked && config.WORKSPACE !== void 0 && config.WORKSPACE !== context.workspace;
1534
1591
  let hostedWorkspaceRejected = false;
@@ -1561,6 +1618,9 @@ function registerTools(server, deps) {
1561
1618
  let joinFailure = null;
1562
1619
  let joinInFlight = Promise.resolve();
1563
1620
  async function activate(workspaceSlug) {
1621
+ if (sessionId !== null && activeWorkspaceSlug === workspaceSlug) {
1622
+ return { ok: true };
1623
+ }
1564
1624
  const joinBody = {
1565
1625
  workspace: workspaceSlug,
1566
1626
  repo: context.repo,
@@ -1586,6 +1646,7 @@ function registerTools(server, deps) {
1586
1646
  heartbeat.start(newSessionId);
1587
1647
  sessionId = newSessionId;
1588
1648
  agentName = parsed.data.agentName;
1649
+ activeWorkspaceSlug = workspaceSlug;
1589
1650
  linked = true;
1590
1651
  hostedWorkspaceRejected = false;
1591
1652
  joinFailure = null;
@@ -1932,6 +1993,7 @@ ${msgs}` : base }]
1932
1993
  await leave();
1933
1994
  sessionId = null;
1934
1995
  agentName = null;
1996
+ activeWorkspaceSlug = null;
1935
1997
  }
1936
1998
  return advisory(
1937
1999
  "Unlinked \u2014 this repo will stay uncoordinated and won't ask again. Run `link` to re-enable."
@@ -2038,7 +2100,7 @@ function postLinkGuidance(workspace) {
2038
2100
  }
2039
2101
 
2040
2102
  // src/identityCache.ts
2041
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
2103
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
2042
2104
  import { homedir as homedir3, tmpdir as tmpdir3 } from "node:os";
2043
2105
  import { dirname as dirname4, join as join4 } from "node:path";
2044
2106
  function defaultIdentityCachePath() {
@@ -2071,7 +2133,7 @@ function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
2071
2133
  try {
2072
2134
  mkdirSync3(dirname4(filePath), { recursive: true });
2073
2135
  const payload = JSON.stringify({ human });
2074
- writeFileSync3(filePath, payload + "\n", "utf8");
2136
+ writeFileSync4(filePath, payload + "\n", "utf8");
2075
2137
  } catch {
2076
2138
  }
2077
2139
  }
@@ -2130,13 +2192,20 @@ function createHeartbeat({
2130
2192
  hubClient,
2131
2193
  intervalSeconds,
2132
2194
  buildReport,
2133
- announcementSink
2195
+ announcementSink,
2196
+ liveness
2134
2197
  }) {
2135
2198
  let timer = null;
2136
2199
  function stop() {
2137
2200
  if (timer !== null) {
2138
2201
  clearInterval(timer);
2139
2202
  timer = null;
2203
+ if (liveness) {
2204
+ try {
2205
+ liveness.remove();
2206
+ } catch {
2207
+ }
2208
+ }
2140
2209
  }
2141
2210
  }
2142
2211
  async function beat(sessionId) {
@@ -2150,6 +2219,12 @@ function createHeartbeat({
2150
2219
  }
2151
2220
  const body = { sessionId };
2152
2221
  if (changeReport) body.changeReport = changeReport;
2222
+ if (liveness) {
2223
+ try {
2224
+ liveness.refresh();
2225
+ } catch {
2226
+ }
2227
+ }
2153
2228
  if (announcementSink) body.deliverAnnouncements = true;
2154
2229
  const parsed = HeartbeatResponse.safeParse(
2155
2230
  await hubClient.post("/heartbeat", body)
@@ -2177,6 +2252,12 @@ function createHeartbeat({
2177
2252
  }
2178
2253
  function start(sessionId) {
2179
2254
  stop();
2255
+ if (liveness) {
2256
+ try {
2257
+ liveness.refresh();
2258
+ } catch {
2259
+ }
2260
+ }
2180
2261
  timer = setInterval(() => {
2181
2262
  void beat(sessionId).catch((err) => {
2182
2263
  console.error(
@@ -2227,10 +2308,97 @@ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or t
2227
2308
 
2228
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.`;
2229
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
+
2230
2398
  // src/hookInstall.ts
2231
2399
  import {
2232
2400
  readFileSync as readFileSync5,
2233
- writeFileSync as writeFileSync4,
2401
+ writeFileSync as writeFileSync5,
2234
2402
  mkdirSync as mkdirSync4,
2235
2403
  copyFileSync,
2236
2404
  existsSync as existsSync4,
@@ -2274,7 +2442,7 @@ function ensureHookScript(homeDir, hookScriptSource) {
2274
2442
  if (current === null || !current.equals(next)) {
2275
2443
  mkdirSync4(dirname5(dest), { recursive: true });
2276
2444
  const tmp = dest + ".tmp";
2277
- writeFileSync4(tmp, next);
2445
+ writeFileSync5(tmp, next);
2278
2446
  renameSync2(tmp, dest);
2279
2447
  }
2280
2448
  return dest;
@@ -2283,7 +2451,7 @@ function ensureHookScript(homeDir, hookScriptSource) {
2283
2451
  }
2284
2452
  }
2285
2453
  function hookCommandFor(scriptPath) {
2286
- return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath}"`;
2454
+ return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath.replace(/\\/g, "/")}"`;
2287
2455
  }
2288
2456
  function codexHookBlock(scriptPath) {
2289
2457
  const command = scriptPath === null ? `["npx", "-y", "--package=@korso/shepherd@${PACKAGE_VERSION}", "shepherd-inbox-hook"]` : `["node", ${JSON.stringify(scriptPath)}]`;
@@ -2323,7 +2491,7 @@ async function autoInstallHooks({
2323
2491
  status = installPi(homeDir, extensionSource, log);
2324
2492
  }
2325
2493
  mkdirSync4(dirname5(recordFile), { recursive: true });
2326
- writeFileSync4(
2494
+ writeFileSync5(
2327
2495
  recordFile,
2328
2496
  JSON.stringify({ status, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
2329
2497
  "utf8"
@@ -2389,7 +2557,7 @@ function installClaude(homeDir, scriptPath, log) {
2389
2557
  hooks: [{ type: "command", command }]
2390
2558
  });
2391
2559
  mkdirSync4(dirname5(settingsFile), { recursive: true });
2392
- writeFileSync4(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2560
+ writeFileSync5(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2393
2561
  return "installed";
2394
2562
  }
2395
2563
  function installCodex(homeDir, scriptPath, log) {
@@ -2398,7 +2566,7 @@ function installCodex(homeDir, scriptPath, log) {
2398
2566
  const hookBlock = codexHookBlock(scriptPath);
2399
2567
  if (!existsSync4(configFile)) {
2400
2568
  mkdirSync4(dirname5(configFile), { recursive: true });
2401
- writeFileSync4(configFile, `[features]
2569
+ writeFileSync5(configFile, `[features]
2402
2570
  hooks = true
2403
2571
  ${hookBlock}`, "utf8");
2404
2572
  return "installed";
@@ -2424,10 +2592,10 @@ ${hookBlock}`, "utf8");
2424
2592
  updated = toml.replace(/^(\s*\[features\]\s*)$/m, `$1
2425
2593
  hooks = true`);
2426
2594
  }
2427
- writeFileSync4(configFile, updated + hookBlock, "utf8");
2595
+ writeFileSync5(configFile, updated + hookBlock, "utf8");
2428
2596
  return "installed";
2429
2597
  }
2430
- writeFileSync4(
2598
+ writeFileSync5(
2431
2599
  configFile,
2432
2600
  `${toml}
2433
2601
  [features]
@@ -2477,7 +2645,7 @@ function installCursor(homeDir, scriptPath, log) {
2477
2645
  }
2478
2646
  entries.push({ command: hookCommandFor(scriptPath) });
2479
2647
  mkdirSync4(dirname5(hooksFile), { recursive: true });
2480
- writeFileSync4(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
2648
+ writeFileSync5(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
2481
2649
  return "installed";
2482
2650
  }
2483
2651
  function installPi(homeDir, extensionSource, log) {
@@ -2504,7 +2672,21 @@ async function main() {
2504
2672
  });
2505
2673
  const context = await resolveContext(config);
2506
2674
  const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
2507
- 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
+ });
2508
2690
  const heartbeat = createHeartbeat({
2509
2691
  hubClient,
2510
2692
  intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
@@ -2517,10 +2699,11 @@ async function main() {
2517
2699
  return void 0;
2518
2700
  }
2519
2701
  },
2520
- // A model-visible sink (this working dir's inbox file). Its presence opts
2521
- // 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
2522
2704
  // ack the hub. appendAnnouncements is itself fail-open.
2523
- announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements)
2705
+ announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements),
2706
+ liveness
2524
2707
  });
2525
2708
  const server = new McpServer(
2526
2709
  { name: "shepherd", version: PACKAGE_VERSION },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) advisory cross-session coordination tools (work/done/announce/sync, plus link/unlink/decline) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
5
5
  "homepage": "https://github.com/Korso-AI/shepherd#readme",
6
6
  "bugs": {