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