@aron98/load-lock 1.4.0 → 1.6.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.
Files changed (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -19,35 +19,73 @@ export function stateDir(override) {
19
19
  export function socketPath(override) {
20
20
  return resolveSocketPath(process.platform, process.env, override);
21
21
  }
22
+ // --------------------------------------------------------------------------
23
+ // Format version + compatibility (issue #51). Several runtime copies may share
24
+ // one state dir, so an OLDER daemon can load files written by a NEWER one
25
+ // (downgrade) and vice versa. The promise (docs/ARCHITECTURE.md):
26
+ // - format changes are additive; readers ignore what they don't understand;
27
+ // - unknown fields survive a rewrite (config.json: top level, inside
28
+ // `config`, inside pools; journal: per entry while the entry is live);
29
+ // - unknown journal entry kinds are skipped (reported), never fatal;
30
+ // - an incompatible change would use a NEW file name, never reinterpret
31
+ // these files.
32
+ // Files written before versioning carry no formatVersion (=> 0).
33
+ /** Format version this code writes into config.json and journal entries. */
34
+ export const STATE_FORMAT_VERSION = 1;
35
+ const KNOWN_STATE_KEYS = new Set(["formatVersion", "config", "history"]);
36
+ function isObject(v) {
37
+ return typeof v === "object" && v !== null && !Array.isArray(v);
38
+ }
22
39
  export function loadState(dir) {
23
40
  const path = join(dir, "config.json");
24
41
  if (!existsSync(path))
25
- return { config: defaultConfig(), history: [] };
42
+ return { config: defaultConfig(), history: [], formatVersion: STATE_FORMAT_VERSION };
26
43
  const raw = JSON.parse(readFileSync(path, "utf8"));
27
- const config = { ...defaultConfig(), ...raw.config };
44
+ // Unknown fields inside `config` ride along in memory and are written back
45
+ // by saveState (configure() mutates in place).
46
+ const config = { ...defaultConfig(), ...(isObject(raw?.config) ? raw.config : {}) };
28
47
  // Defensive: always at least the default pool.
29
- if (!config.pools || Object.keys(config.pools).length === 0)
48
+ if (!isObject(config.pools) || Object.keys(config.pools).length === 0)
30
49
  config.pools = { heavy: { capacity: 1 } };
31
- return { config, history: Array.isArray(raw.history) ? raw.history : [] };
50
+ const formatVersion = typeof raw?.formatVersion === "number" ? raw.formatVersion : 0;
51
+ return { config, history: Array.isArray(raw?.history) ? raw.history : [], formatVersion };
32
52
  }
33
53
  export function saveState(dir, config, history) {
34
54
  mkdirSync(dir, { recursive: true });
35
55
  const path = join(dir, "config.json");
56
+ // Preserve top-level fields written by a newer format (never rewrite
57
+ // destructively). Best-effort: an unreadable file has nothing to keep.
58
+ const extra = {};
59
+ try {
60
+ const prev = JSON.parse(readFileSync(path, "utf8"));
61
+ if (isObject(prev))
62
+ for (const [k, v] of Object.entries(prev))
63
+ if (!KNOWN_STATE_KEYS.has(k))
64
+ extra[k] = v;
65
+ }
66
+ catch {
67
+ /* missing or corrupt: nothing to preserve */
68
+ }
36
69
  const tmp = join(dirname(path), ".config.json.tmp-" + process.pid);
37
- writeFileSync(tmp, JSON.stringify({ config, history }, null, 2) + "\n", { mode: 0o644 });
70
+ writeFileSync(tmp, JSON.stringify({ formatVersion: STATE_FORMAT_VERSION, ...extra, config, history }, null, 2) + "\n", { mode: 0o644 });
38
71
  renameSync(tmp, path); // atomic on POSIX
39
72
  }
40
73
  // --------------------------------------------------------------------------
41
- // Journal: held permits, persisted so a daemon restart can conservatively
42
- // reconcile running ownership (tracked jobs keep their permit; confirmed-dead
43
- // jobs release; queued/offered requests are NOT journaled — agents re-acquire).
74
+ // Journal: held permits AND queued/offered requests, persisted so a daemon
75
+ // restart can conservatively reconcile running ownership (tracked jobs keep
76
+ // their permit; confirmed-dead jobs release) and re-queue waiters FIFO with
77
+ // the same requestIds (offered entries come back as queued; issue #34).
44
78
  export function journalPath(dir) {
45
79
  return join(dir, "journal.json");
46
80
  }
81
+ // The journal stays a bare JSON array (what every released daemon reads); the
82
+ // format version is stamped per entry. An object form `{ entries: [...] }` is
83
+ // accepted too, so a future format may wrap the array without breaking us.
47
84
  export function loadJournal(dir) {
48
85
  try {
49
86
  const raw = JSON.parse(readFileSync(journalPath(dir), "utf8"));
50
- return Array.isArray(raw) ? raw : [];
87
+ const list = Array.isArray(raw) ? raw : isObject(raw) && Array.isArray(raw.entries) ? raw.entries : [];
88
+ return list.filter(isObject);
51
89
  }
52
90
  catch {
53
91
  return [];
@@ -56,6 +94,7 @@ export function loadJournal(dir) {
56
94
  export function saveJournal(dir, entries) {
57
95
  const path = journalPath(dir);
58
96
  const tmp = join(dirname(path), ".journal.json.tmp-" + process.pid);
59
- writeFileSync(tmp, JSON.stringify(entries, null, 2) + "\n", { mode: 0o644 });
97
+ const stamped = entries.map((e) => ({ formatVersion: STATE_FORMAT_VERSION, ...e }));
98
+ writeFileSync(tmp, JSON.stringify(stamped, null, 2) + "\n", { mode: 0o644 });
60
99
  renameSync(tmp, path);
61
100
  }
@@ -56,6 +56,8 @@ interface Entry {
56
56
  leaseDeadline?: number;
57
57
  lastSeen?: number;
58
58
  enqueuedAt?: number;
59
+ /** Journal fields this version doesn't know (written by a newer daemon); written back as-is (issue #51). */
60
+ journalExtra?: Record<string, unknown>;
59
61
  }
60
62
  export declare class Machine {
61
63
  config: MachineConfig;
@@ -197,6 +199,12 @@ export declare class Machine {
197
199
  * alive => keep (same token, so a late explicit release still works).
198
200
  * - claimed: keep the start deadline (reclaimed by tick if already past).
199
201
  * - untracked: keep, with a FRESH lease window from restore time.
202
+ * - forward compatibility (issue #51): entries of an unknown `state`
203
+ * (written by a newer daemon) or missing required fields are skipped
204
+ * and reported, never fatal; a held entry with an unknown `phase` is
205
+ * kept as untracked with a fresh lease (capacity stays accounted, but
206
+ * the lease reclaims it if nobody heartbeats). Unknown fields are kept
207
+ * and written back to the journal while the entry lives.
200
208
  */
201
209
  restore(journal: JournalEntry[], liveness: (e: {
202
210
  pid?: number;
@@ -206,6 +214,7 @@ export declare class Machine {
206
214
  kept: number;
207
215
  releasedDead: number;
208
216
  queued: number;
217
+ skipped: number;
209
218
  };
210
219
  }
211
220
  export {};
@@ -48,6 +48,21 @@ export function defaultConfig() {
48
48
  guidance: {},
49
49
  };
50
50
  }
51
+ /** Journal entry fields this version understands; everything else is carried through (issue #51). */
52
+ const KNOWN_JOURNAL_KEYS = new Set([
53
+ "formatVersion", "requestId", "pool", "agentId", "agentLabel", "cwd", "opKey", "reason", "weight",
54
+ "permitToken", "phase", "pid", "pgid", "pidStartTime", "claimedAt", "startedAt", "leaseDeadline", "state", "enqueuedAt",
55
+ ]);
56
+ const KNOWN_PHASES = new Set(["claimed", "running_tracked", "running_untracked"]);
57
+ function journalExtra(j) {
58
+ let out;
59
+ for (const [k, v] of Object.entries(j)) {
60
+ if (KNOWN_JOURNAL_KEYS.has(k))
61
+ continue;
62
+ (out ??= {})[k] = v;
63
+ }
64
+ return out;
65
+ }
51
66
  const rid = () => "req_" + randomBytes(12).toString("hex");
52
67
  const tok = (p) => p + "_" + randomBytes(16).toString("hex");
53
68
  export class Machine {
@@ -793,8 +808,9 @@ export class Machine {
793
808
  }
794
809
  this.config.version += 1;
795
810
  if (c.pools) {
811
+ // Spread the previous pool config so fields from a newer format survive (issue #51).
796
812
  for (const [name, pc] of Object.entries(c.pools))
797
- this.config.pools[name] = { capacity: pc.capacity };
813
+ this.config.pools[name] = { ...this.config.pools[name], capacity: pc.capacity };
798
814
  }
799
815
  if (c.offerTimeoutMs !== undefined)
800
816
  this.config.offerTimeoutMs = c.offerTimeoutMs;
@@ -853,6 +869,7 @@ export class Machine {
853
869
  if (e.state !== "held")
854
870
  continue;
855
871
  out.push({
872
+ ...e.journalExtra,
856
873
  requestId: e.id,
857
874
  pool: e.pool,
858
875
  agentId: e.agentId,
@@ -876,6 +893,7 @@ export class Machine {
876
893
  if (e.state !== "queued" && e.state !== "offered")
877
894
  continue;
878
895
  out.push({
896
+ ...e.journalExtra,
879
897
  requestId: e.id,
880
898
  pool: e.pool,
881
899
  agentId: e.agentId,
@@ -901,12 +919,27 @@ export class Machine {
901
919
  * alive => keep (same token, so a late explicit release still works).
902
920
  * - claimed: keep the start deadline (reclaimed by tick if already past).
903
921
  * - untracked: keep, with a FRESH lease window from restore time.
922
+ * - forward compatibility (issue #51): entries of an unknown `state`
923
+ * (written by a newer daemon) or missing required fields are skipped
924
+ * and reported, never fatal; a held entry with an unknown `phase` is
925
+ * kept as untracked with a fresh lease (capacity stays accounted, but
926
+ * the lease reclaims it if nobody heartbeats). Unknown fields are kept
927
+ * and written back to the journal while the entry lives.
904
928
  */
905
929
  restore(journal, liveness) {
906
930
  let kept = 0;
907
931
  let releasedDead = 0;
932
+ let skipped = 0;
908
933
  const queuedEntries = [];
909
934
  for (const j of journal) {
935
+ const st = j.state;
936
+ if ((st !== undefined && st !== "held" && st !== "queued") ||
937
+ typeof j.requestId !== "string" ||
938
+ typeof j.pool !== "string" ||
939
+ typeof j.agentId !== "string") {
940
+ skipped++;
941
+ continue;
942
+ }
910
943
  if (j.state === "queued") {
911
944
  queuedEntries.push({ j, at: j.enqueuedAt ?? j.claimedAt });
912
945
  continue;
@@ -929,7 +962,10 @@ export class Machine {
929
962
  pidStartTime: j.pidStartTime,
930
963
  startedAt: j.startedAt,
931
964
  lastSeen: this.now(),
965
+ journalExtra: journalExtra(j),
932
966
  };
967
+ if (!KNOWN_PHASES.has(e.phase))
968
+ e.phase = "running_untracked";
933
969
  if (e.phase === "claimed") {
934
970
  e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
935
971
  }
@@ -974,6 +1010,7 @@ export class Machine {
974
1010
  state: "queued",
975
1011
  since: this.now(),
976
1012
  enqueuedAt: j.enqueuedAt ?? j.claimedAt,
1013
+ journalExtra: journalExtra(j),
977
1014
  };
978
1015
  this.entries.set(e.id, e);
979
1016
  this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
@@ -988,8 +1025,9 @@ export class Machine {
988
1025
  this.pushEvent({
989
1026
  type: "daemon_recovered",
990
1027
  targetAgent: "*",
991
- detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)`,
1028
+ detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)` +
1029
+ (skipped ? `; skipped ${skipped} unrecognized journal entr${skipped === 1 ? "y" : "ies"} (newer format?)` : ""),
992
1030
  });
993
- return { kept, releasedDead, queued };
1031
+ return { kept, releasedDead, queued, skipped };
994
1032
  }
995
1033
  }
@@ -18,6 +18,19 @@ export interface FeedOptions {
18
18
  inspectEveryMs?: number;
19
19
  /** Reconnect backoff base (default 250 ms). */
20
20
  reconnectBaseMs?: number;
21
+ /**
22
+ * How often to re-check install drift between this running copy and the
23
+ * PATH-resolved `load-lock` CLI (default 15_000 ms; 0 disables). A harness
24
+ * that loads plugin code once at startup keeps running the OLD copy after
25
+ * an update — this makes that visible on the dashboard (issue #39).
26
+ */
27
+ driftCheckMs?: number;
28
+ /**
29
+ * Wording of the drift hint: "opencode" says "restart opencode to pick up
30
+ * vX" (the plugin cannot hot-reload), "cli" (default) says the running
31
+ * copy is stale relative to the PATH CLI.
32
+ */
33
+ driftSurface?: "cli" | "opencode";
21
34
  }
22
35
  export declare class DashboardFeed {
23
36
  private client;
@@ -33,10 +46,14 @@ export declare class DashboardFeed {
33
46
  private daemonStartedAt;
34
47
  private refreshTimer;
35
48
  private slowTimer;
49
+ private driftTimer;
50
+ private drift;
36
51
  private stopped;
37
52
  private unsub;
38
53
  private connecting;
39
54
  private viewSeq;
55
+ /** daemonVersion seen at the previous resync — powers the "restarted onto" note. */
56
+ private lastDaemonVersion;
40
57
  /** Fired whenever the rendered view might have changed (state or data). */
41
58
  onChange?: () => void;
42
59
  /** Fired on connection-state transitions. */
@@ -69,6 +86,12 @@ export declare class DashboardFeed {
69
86
  * Returns the reclaimed request's one-line description for toasts.
70
87
  */
71
88
  forceRelease(requestId: string): Promise<void>;
89
+ /**
90
+ * Re-probe install drift (running copy vs PATH-resolved CLI) and surface
91
+ * it on the next view. Public so surfaces can trigger it on demand
92
+ * (e.g. right after a plugin's panel regains focus).
93
+ */
94
+ refreshDrift(): void;
72
95
  /** Render-ready snapshot; countdowns are computed at `now`. */
73
96
  view(now?: number): DashboardView;
74
97
  }
@@ -1,10 +1,13 @@
1
1
  import { buildDashboardView } from "./view.js";
2
+ import { detectInstallDrift } from "../shared/installs.js";
2
3
  const DEFAULTS = {
3
4
  eventHistory: 60,
4
5
  slowRefreshMs: 15_000,
5
6
  refreshDebounceMs: 40,
6
7
  inspectEveryMs: 60_000,
7
8
  reconnectBaseMs: 250,
9
+ driftCheckMs: 15_000,
10
+ driftSurface: "cli",
8
11
  };
9
12
  export class DashboardFeed {
10
13
  client;
@@ -20,10 +23,14 @@ export class DashboardFeed {
20
23
  daemonStartedAt = 0;
21
24
  refreshTimer = null;
22
25
  slowTimer = null;
26
+ driftTimer = null;
27
+ drift = null;
23
28
  stopped = false;
24
29
  unsub = null;
25
30
  connecting = null;
26
31
  viewSeq = 0;
32
+ /** daemonVersion seen at the previous resync — powers the "restarted onto" note. */
33
+ lastDaemonVersion;
27
34
  /** Fired whenever the rendered view might have changed (state or data). */
28
35
  onChange;
29
36
  /** Fired on connection-state transitions. */
@@ -63,7 +70,9 @@ export class DashboardFeed {
63
70
  typeof this.client.onDisconnect === "function"
64
71
  ? (this.client.onDisconnect(() => void this.handleDisconnect()) ?? undefined)
65
72
  : null;
66
- await this.connectAndSync();
73
+ // Timers are installed BEFORE the first connect: a failed initial
74
+ // connect must not leave the feed without its slow-refresh/drift
75
+ // timers once the reconnect loop eventually recovers (issue #39 H3).
67
76
  this.slowTimer =
68
77
  this.opts.slowRefreshMs > 0
69
78
  ? setInterval(() => {
@@ -72,6 +81,27 @@ export class DashboardFeed {
72
81
  }, this.opts.slowRefreshMs)
73
82
  : null;
74
83
  this.slowTimer?.unref?.();
84
+ this.driftTimer =
85
+ this.opts.driftCheckMs > 0
86
+ ? setInterval(() => {
87
+ void this.refreshDrift();
88
+ }, this.opts.driftCheckMs)
89
+ : null;
90
+ this.driftTimer?.unref?.();
91
+ void this.refreshDrift();
92
+ try {
93
+ await this.connectAndSync();
94
+ }
95
+ catch (e) {
96
+ // Issue #39 (hypothesis 3): previously a failed initial connect
97
+ // rejected start() with no socket ever attached, so no close event
98
+ // could ever fire handleDisconnect and the feed wedged in
99
+ // "connecting" forever. Enter the standard reconnect loop instead —
100
+ // the loop owns recovery, and start() still reports the failure.
101
+ this.setState("reconnecting");
102
+ void this.reconnectLoop();
103
+ throw e;
104
+ }
75
105
  }
76
106
  async stop() {
77
107
  this.stopped = true;
@@ -79,8 +109,11 @@ export class DashboardFeed {
79
109
  clearTimeout(this.refreshTimer);
80
110
  if (this.slowTimer)
81
111
  clearInterval(this.slowTimer);
112
+ if (this.driftTimer)
113
+ clearInterval(this.driftTimer);
82
114
  this.refreshTimer = null;
83
115
  this.slowTimer = null;
116
+ this.driftTimer = null;
84
117
  this.unsub?.();
85
118
  this.unsub = null;
86
119
  this.client.close();
@@ -112,12 +145,23 @@ export class DashboardFeed {
112
145
  let restartNote;
113
146
  if (daemonStartChanged || evRes.latestEventId < this.latestEventId) {
114
147
  // Daemon restarted: ids restarted from scratch. Reset and re-pull.
115
- restartNote = `daemon restarted (pid ${status.daemon?.pid ?? "?"}); event history re-synced`;
148
+ const pid = status.daemon?.pid ?? "?";
149
+ const newVersion = status.daemon?.daemonVersion;
150
+ // Issue #39 (deliverable d): when the v1.3.0 handshake reports a
151
+ // DIFFERENT version than the daemon we were talking to, say which
152
+ // version the daemon restarted onto (e.g. after an update).
153
+ if (this.lastDaemonVersion && newVersion && newVersion !== this.lastDaemonVersion) {
154
+ restartNote = `daemon restarted onto v${newVersion} (was v${this.lastDaemonVersion}; pid ${pid}); event history re-synced`;
155
+ }
156
+ else {
157
+ restartNote = `daemon restarted (pid ${pid}); event history re-synced`;
158
+ }
116
159
  this.events = [];
117
160
  this.latestEventId = 0;
118
161
  const fresh = await this.client.request("events", { sinceEventId: 0 });
119
162
  this.mergeEvents(fresh.events, fresh.latestEventId);
120
163
  }
164
+ this.lastDaemonVersion = status.daemon?.daemonVersion;
121
165
  this.restartNote = restartNote;
122
166
  if (this.inspectAt === 0 || Date.now() - this.inspectAt > this.opts.inspectEveryMs) {
123
167
  await this.refreshInspect();
@@ -240,6 +284,22 @@ export class DashboardFeed {
240
284
  await this.client.request("admin_release", { requestId });
241
285
  await this.refresh().catch(() => { });
242
286
  }
287
+ /**
288
+ * Re-probe install drift (running copy vs PATH-resolved CLI) and surface
289
+ * it on the next view. Public so surfaces can trigger it on demand
290
+ * (e.g. right after a plugin's panel regains focus).
291
+ */
292
+ refreshDrift() {
293
+ if (this.stopped || this.opts.driftCheckMs === 0)
294
+ return;
295
+ try {
296
+ this.drift = detectInstallDrift();
297
+ }
298
+ catch {
299
+ this.drift = null;
300
+ }
301
+ this.emitChange();
302
+ }
243
303
  /** Render-ready snapshot; countdowns are computed at `now`. */
244
304
  view(now = Date.now()) {
245
305
  return buildDashboardView({
@@ -250,6 +310,8 @@ export class DashboardFeed {
250
310
  state: this.state,
251
311
  lastSyncAt: this.lastSyncAt,
252
312
  daemonRestartNote: this.restartNote,
313
+ installDrift: this.drift,
314
+ driftSurface: this.opts.driftSurface,
253
315
  now,
254
316
  });
255
317
  }
@@ -70,6 +70,8 @@ export function renderFrame(view, width = 100, height = 40) {
70
70
  push(`note: ${view.daemonRestartNote}`);
71
71
  if (view.versionSkewNote)
72
72
  push(`note: ${view.versionSkewNote}`);
73
+ if (view.installDriftNote)
74
+ push(`note: ${view.installDriftNote}`);
73
75
  if (view.machine) {
74
76
  const m = view.machine;
75
77
  const cg = m.cgroupCpuQuota != null ? ` · cgroup ${m.cgroupVersion ?? ""} quota ${m.cgroupCpuQuota / 100} CPU` : "";
@@ -9,6 +9,7 @@
9
9
  * "now" is always an explicit parameter, which makes countdowns testable.
10
10
  */
11
11
  import type * as P from "../shared/protocol.js";
12
+ import type { InstallDrift } from "../shared/installs.js";
12
13
  /** Connection state of the dashboard's daemon feed. */
13
14
  export type FeedState = "connecting" | "live" | "reconnecting" | "down";
14
15
  export interface HolderCard {
@@ -116,6 +117,12 @@ export interface DashboardView {
116
117
  daemonRestartNote?: string;
117
118
  /** Set when the running daemon is older than the installed copy. */
118
119
  versionSkewNote?: string;
120
+ /**
121
+ * Set when the RUNNING copy is older than the PATH-resolved CLI install
122
+ * (issue #39: harnesses like opencode load plugin code once and never
123
+ * hot-reload it, so an update leaves this session running stale code).
124
+ */
125
+ installDriftNote?: string;
119
126
  daemon: DaemonView | null;
120
127
  pools: PoolView[];
121
128
  events: EventLine[];
@@ -148,6 +155,10 @@ export interface DashboardInputs {
148
155
  state: FeedState;
149
156
  lastSyncAt: number;
150
157
  daemonRestartNote?: string;
158
+ /** Running-copy vs PATH-install drift probe result (null = unknown). */
159
+ installDrift?: InstallDrift | null;
160
+ /** Wording hint for the drift note; the TUI plugin passes "opencode". */
161
+ driftSurface?: "cli" | "opencode";
151
162
  now: number;
152
163
  }
153
164
  export declare function buildDashboardView(inps: DashboardInputs): DashboardView;
@@ -186,10 +186,23 @@ export function buildDashboardView(inps) {
186
186
  : null;
187
187
  // Newest first; keep the tail (feed already bounds history length).
188
188
  const evs = [...inps.events].reverse();
189
+ // Issue #39: make in-memory staleness visible. A harness that loads
190
+ // plugin code once (opencode never hot-reloads plugins) keeps running
191
+ // the OLD copy after `npm i -g @aron98/load-lock@latest`; the panel can
192
+ // only hint at the recovery action, which differs per surface.
193
+ const drift = inps.installDrift;
194
+ let installDriftNote;
195
+ if (drift?.stale && drift.pathVersion) {
196
+ installDriftNote =
197
+ inps.driftSurface === "opencode"
198
+ ? `plugin updated — restart opencode to pick up v${drift.pathVersion} (this session still runs v${drift.ownVersion}; opencode does not hot-reload plugins)`
199
+ : `PATH CLI v${drift.pathVersion} is newer than this copy v${drift.ownVersion} — you are running a stale install`;
200
+ }
189
201
  return {
190
202
  now,
191
203
  state: inps.state,
192
204
  daemonRestartNote: inps.daemonRestartNote,
205
+ installDriftNote,
193
206
  versionSkewNote: d && typeof d.daemonVersion === "string" && compareVersions(d.daemonVersion, packageVersion()) < 0
194
207
  ? `daemon v${d.daemonVersion} is older than installed v${packageVersion()} — it self-restarts on newer-client contact`
195
208
  : undefined,
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * load-lock MCP server (stdio) — the universal adapter for harnesses that
3
3
  * speak MCP (Claude Code, Codex CLI, Gemini CLI, Copilot CLI, Cline, Roo,
4
- * Goose, ...). Each harness session spawns one MCP server process; the
5
- * process-scoped agent identity routes offers back through tool results and
6
- * watch pushes.
4
+ * Goose, ...). Each harness session spawns one MCP server process per
5
+ * registration; the session-derived agent identity (issue #50, see
6
+ * shared/identity.ts) routes offers back through tool results and watch
7
+ * pushes, and is shared by every registration in the same session.
7
8
  *
8
9
  * Notification honesty (see docs/COMPATIBILITY.md): MCP transports can
9
10
  * surface server-initiated *messages*, but most harnesses do not inject them
@@ -16,11 +17,18 @@
16
17
  */
17
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
19
  import { LLClient } from "../shared/client.js";
20
+ import { type CliDrift } from "../shared/drift.js";
19
21
  export interface McpServerOptions {
20
22
  client: LLClient;
21
23
  serverName?: string;
22
24
  /** Overridden by OpenCode's per-request session metadata when present. */
23
25
  agentId?: string;
26
+ /**
27
+ * CLI-drift notice to surface (issue #38). Defaults to auto-detection at
28
+ * build time (this adapter copy vs. the PATH-resolved CLI copy);
29
+ * pass null to suppress / a value to inject in tests.
30
+ */
31
+ drift?: CliDrift | null;
24
32
  }
25
33
  export declare function buildServer(opts: McpServerOptions): McpServer;
26
34
  export declare function runStdioMcp(opts?: {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * load-lock MCP server (stdio) — the universal adapter for harnesses that
3
3
  * speak MCP (Claude Code, Codex CLI, Gemini CLI, Copilot CLI, Cline, Roo,
4
- * Goose, ...). Each harness session spawns one MCP server process; the
5
- * process-scoped agent identity routes offers back through tool results and
6
- * watch pushes.
4
+ * Goose, ...). Each harness session spawns one MCP server process per
5
+ * registration; the session-derived agent identity (issue #50, see
6
+ * shared/identity.ts) routes offers back through tool results and watch
7
+ * pushes, and is shared by every registration in the same session.
7
8
  *
8
9
  * Notification honesty (see docs/COMPATIBILITY.md): MCP transports can
9
10
  * surface server-initiated *messages*, but most harnesses do not inject them
@@ -19,10 +20,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
19
20
  import { z } from "zod";
20
21
  import { spawn } from "node:child_process";
21
22
  import { LLClient } from "../shared/client.js";
22
- import { POLICY_BRIEF } from "../policy/text.js";
23
+ import { POLICY_BRIEF, SETUP_HINT } from "../policy/text.js";
23
24
  import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
24
25
  import { waitForLocalJob, waitForTrackedJob } from "../shared/wait-completion.js";
25
26
  import { killProcessGroup } from "../shared/transport.js";
27
+ import { detectCliDrift } from "../shared/drift.js";
28
+ import { packageVersion } from "../shared/version.js";
29
+ import { sessionIdentity } from "../shared/identity.js";
30
+ import { agentGuideText } from "../shared/agent-guide.js";
26
31
  const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
27
32
 
28
33
  Pass a short reason and a stable op_key identifying the pending operation (duplicate acquires for the same op_key return the existing request — do not invent a new key while one is live). Set wait_ms (default a few seconds) to block briefly for capacity instead of returning queued immediately; keep doing light work while queued. You will receive an offer to claim when capacity frees. Never start heavy work while merely queued.`;
@@ -30,8 +35,19 @@ const CLAIM_DESC = `Claim an offered permit within its claim window (offers expi
30
35
  const RELEASE_DESC = `Release a held permit immediately after the heavy work finishes — success, failure, or termination (idempotent: releasing twice is fine). A permit survives the tool call and session that acquired it; automatic cleanup releases it when a tracked job exits or, for untracked permits, when your adapter stops heartbeating — but do not rely on that: release as soon as the work is done.`;
31
36
  export function buildServer(opts) {
32
37
  const client = opts.client;
33
- const defaultAgent = opts.agentId ?? "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8);
34
- const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF });
38
+ // Session-derived (issue #50): two registrations in one harness session
39
+ // (plugin-bundled + standalone) resolve to the same id; the per-process
40
+ // random id remains the fallback when no session can be found.
41
+ const defaultAgent = opts.agentId ??
42
+ sessionIdentity({ fallback: "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8) }).id;
43
+ // Drift notice (issue #38): one clear notice naming the exact command when
44
+ // this adapter copy is newer than the PATH-installed CLI. Notify only —
45
+ // never auto-install. Printed to stderr at init and embedded in the
46
+ // initialize instructions so any MCP client sees it exactly once.
47
+ const drift = opts.drift !== undefined ? opts.drift : detectCliDrift(packageVersion());
48
+ if (drift)
49
+ process.stderr.write(drift.message + "\n");
50
+ const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF + "\n\n" + SETUP_HINT + (drift ? "\n\n" + drift.message : "") });
35
51
  const json = (v) => ({
36
52
  content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
37
53
  });
@@ -205,6 +221,20 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
205
221
  description: "Current config: pools/capacity, offer timeout, heavy-work guidance, and change history with rationale.",
206
222
  inputSchema: {},
207
223
  }, async () => json(await client.request("get_config", {})));
224
+ // Issue #61: a plugin-only install may have neither the skill nor the CLI
225
+ // on PATH, so the policy brief's pointer to the detailed guide lands here.
226
+ server.registerTool("agent_guide", {
227
+ title: "Full agent guide",
228
+ description: "The detailed load-lock playbook (same text as `load-lock agent-guide`): heavy-vs-light heuristics, offer claiming, background jobs, configuration and recovery. Read it when the short policy is not enough. No daemon call.",
229
+ inputSchema: {},
230
+ }, async () => {
231
+ try {
232
+ return { content: [{ type: "text", text: agentGuideText() }] };
233
+ }
234
+ catch {
235
+ return { content: [{ type: "text", text: POLICY_BRIEF }] };
236
+ }
237
+ });
208
238
  server.registerTool("configure", {
209
239
  title: "Update config (CAS)",
210
240
  description: "Compare-and-swap config update so concurrent agents never silently overwrite each other. Read get_config, pass its version as expected_version, describe changes and a reason. Capacity decreases let current holders finish; increases admit queued requests immediately.",
@@ -468,6 +498,8 @@ Input: job_id OR request_id (one of them), timeout_ms (default 600000, clamped t
468
498
  // Automatic adapter heartbeat: while this server process lives, untracked
469
499
  // permits of this agent keep their lease; if the harness session dies, the
470
500
  // lease lapses and the daemon reclaims. The model never has to remember.
501
+ // Servers sharing a session identity each renew the same lease; heartbeats
502
+ // are idempotent, so that is harmless.
471
503
  const heartbeat = setInterval(() => {
472
504
  void client.request("heartbeat", { agentId: defaultAgent }).catch(() => {
473
505
  /* daemon unreachable; next tool call reconnects */
@@ -4,14 +4,24 @@
4
4
  *
5
5
  * - OpenCode plugin: injected via session "context" hook (every model call,
6
6
  * compaction-proof) + registered skill for detail.
7
- * - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork).
7
+ * - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork),
8
+ * from the loose install or the load-lock-plugin Claude Code plugin.
8
9
  * - Codex CLI: SessionStart/SubagentStart hook stdout (needs /hooks trust).
9
10
  * - MCP server: `instructions` field on every harness that surfaces it.
11
+ * - Skill only (ladder rung 0, docs/OPERATIONS.md): no brief is injected;
12
+ * skills/load-lock/SKILL.md carries its own version of these rules plus the
13
+ * MCP -> PATH -> pinned-npx bootstrap (docs/AGENT_GUIDE.md section 0).
10
14
  *
11
15
  * Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
12
- * in the skill / docs/AGENT_GUIDE.md.
16
+ * in docs/AGENT_GUIDE.md, served by the MCP agent_guide tool, the skill and
17
+ * `load-lock agent-guide`.
13
18
  */
14
- export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n Out of light work while it runs? Sleep honestly with wait_for_completion\n (MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit\n with wait:true) or 'load-lock wait --job <request-id>' (CLI) \u2014 a bounded\n sleep that wakes promptly when the job finishes (exit code + output tail;\n the permit is freed before you are woken). On still_running it is a\n checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide`.";
19
+ export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n Out of light work while it runs? Sleep honestly with wait_for_completion\n (MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit\n with wait:true) or 'load-lock wait --job <request-id>' (CLI) \u2014 a bounded\n sleep that wakes promptly when the job finishes (exit code + output tail;\n the permit is freed before you are woken). On still_running it is a\n checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the load-lock agent_guide tool (MCP/plugin), else the\n\"load-lock\" skill or `load-lock agent-guide` (CLI) where installed.";
20
+ /**
21
+ * The one upgrade hint every rung carries (#47 ladder, #59): appended after
22
+ * the brief in the MCP instructions and the session-start hook output.
23
+ */
24
+ export declare const SETUP_HINT = "Wiring: `load-lock setup` lists this machine's load-lock integrations per harness; `load-lock setup --yes` converges them to the recommended layout (hooks for idle wakeups, policy that survives compaction).";
15
25
  export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
16
26
  export declare function statusLine(st: {
17
27
  pools: {