@aron98/load-lock 1.3.0 → 1.5.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 (47) hide show
  1. package/dist/adapters/opencode/plugin.d.ts +2 -1
  2. package/dist/adapters/opencode/plugin.js +100 -6
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +1 -9
  5. package/dist/cli/copies.js +2 -43
  6. package/dist/cli/install.js +1 -1
  7. package/dist/cli/main.js +82 -217
  8. package/dist/cli/service.d.ts +56 -0
  9. package/dist/cli/service.js +261 -0
  10. package/dist/cli/update.d.ts +53 -0
  11. package/dist/cli/update.js +274 -0
  12. package/dist/core/daemon.d.ts +24 -0
  13. package/dist/core/daemon.js +221 -0
  14. package/dist/core/state.d.ts +5 -0
  15. package/dist/core/state.js +16 -0
  16. package/dist/dashboard/feed.d.ts +23 -0
  17. package/dist/dashboard/feed.js +64 -2
  18. package/dist/dashboard/frame.js +2 -0
  19. package/dist/dashboard/view.d.ts +11 -0
  20. package/dist/dashboard/view.js +13 -0
  21. package/dist/mcp/server.d.ts +7 -0
  22. package/dist/mcp/server.js +117 -8
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +6 -0
  25. package/dist/shared/client.js +11 -1
  26. package/dist/shared/drift.d.ts +21 -0
  27. package/dist/shared/drift.js +77 -0
  28. package/dist/shared/installs.d.ts +39 -0
  29. package/dist/shared/installs.js +101 -0
  30. package/dist/shared/pkginfo.d.ts +16 -0
  31. package/dist/shared/pkginfo.js +54 -0
  32. package/dist/shared/protocol.d.ts +32 -0
  33. package/dist/shared/wait-completion.d.ts +67 -0
  34. package/dist/shared/wait-completion.js +505 -0
  35. package/dist/test/mcp.test.js +115 -1
  36. package/dist/test/reconnect.test.d.ts +1 -0
  37. package/dist/test/reconnect.test.js +389 -0
  38. package/dist/test/update-command.test.d.ts +1 -0
  39. package/dist/test/update-command.test.js +608 -0
  40. package/dist/test/wait-completion.test.d.ts +1 -0
  41. package/dist/test/wait-completion.test.js +418 -0
  42. package/docs/ADAPTERS.md +3 -2
  43. package/docs/AGENT_GUIDE.md +61 -3
  44. package/docs/ARCHITECTURE.md +15 -0
  45. package/docs/COMPATIBILITY.md +16 -2
  46. package/docs/OPERATIONS.md +118 -9
  47. package/package.json +1 -1
@@ -44,6 +44,8 @@ export class Daemon {
44
44
  * restart via the event cursor) and the process can exit promptly. */
45
45
  conns = new Set();
46
46
  waiters = new Map();
47
+ /** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
48
+ completionWaiters = new Map();
47
49
  sentEventId;
48
50
  stateDirPath;
49
51
  socketPath;
@@ -115,6 +117,22 @@ export class Daemon {
115
117
  if (this.stopped)
116
118
  return;
117
119
  this.stopped = true;
120
+ // Resolve blocked wait_for_completion calls first so no promise dangles.
121
+ // Responses may not make it out (conns are destroyed below) — that is
122
+ // fine: clients see the disconnect, reconnect, and re-issue the wait.
123
+ for (const [, waiters] of this.completionWaiters) {
124
+ for (const w of waiters) {
125
+ clearTimeout(w.timer);
126
+ w.resolve({
127
+ state: "job_unknown",
128
+ requestId: w.requestId,
129
+ waitedMs: this.machine.now() - w.registeredAt,
130
+ nextAction: "daemon is stopping — call wait_for_completion again after it is back",
131
+ detail: "daemon stopping",
132
+ });
133
+ }
134
+ }
135
+ this.completionWaiters.clear();
118
136
  if (this.sweepTimer)
119
137
  clearInterval(this.sweepTimer);
120
138
  if (this.expiryTimer)
@@ -395,6 +413,24 @@ export class Daemon {
395
413
  m.adminRelease(String(p.requestId));
396
414
  return [{ ok: true }, undefined];
397
415
  }
416
+ case "wait_for_completion": {
417
+ // Bounded "sleep until the job finishes" for a daemon-tracked job
418
+ // (issue #36) — the execution-phase counterpart of wait_for_turn.
419
+ const requestId = String(p.requestId ?? "");
420
+ if (!requestId)
421
+ throw new ProtocolError("invalid", "wait_for_completion: requestId is required");
422
+ const requested = Number(p.timeoutMs ?? 0);
423
+ if (!Number.isFinite(requested) || requested < 0) {
424
+ throw new ProtocolError("invalid", "wait_for_completion: timeoutMs must be a non-negative number of milliseconds");
425
+ }
426
+ // Same bounded-sleep contract as wait_for_turn: one call is capped by
427
+ // maxTurnWaitMs; longer waits are checkpointed (still_running) and re-issued.
428
+ const timeoutMs = Math.min(requested, m.config.maxTurnWaitMs);
429
+ const res = await this.waitForJobCompletion(requestId, timeoutMs);
430
+ if (timeoutMs !== requested)
431
+ res.effectiveWaitMs = timeoutMs;
432
+ return [res, undefined];
433
+ }
398
434
  default:
399
435
  throw new ProtocolError("invalid", `unknown method "${method}"`);
400
436
  }
@@ -414,6 +450,181 @@ export class Daemon {
414
450
  this.waiters.set(requestId, { requestId, resolve, timer });
415
451
  });
416
452
  }
453
+ // ---------------------------------------------- wait_for_completion (issue #36)
454
+ /**
455
+ * Bounded blocking wait for a daemon-tracked job's completion. Mirrors
456
+ * waitForAdmission: the call is held server-side and woken by
457
+ * resolveCompletionWaiters() when the job is observed done — which always
458
+ * happens AFTER the permit has been released (finish() frees capacity
459
+ * before the event is emitted, and waiters are resolved after that).
460
+ * timeoutMs 0 is check-only. Never blocks unbounded.
461
+ */
462
+ async waitForJobCompletion(requestId, timeoutMs) {
463
+ const m = this.machine;
464
+ let entry;
465
+ try {
466
+ entry = m.entryById(requestId);
467
+ }
468
+ catch {
469
+ return {
470
+ state: "job_unknown",
471
+ requestId,
472
+ waitedMs: 0,
473
+ nextAction: "no request with this id exists (yet) — check the id, or acquire first",
474
+ detail: "unknown request id",
475
+ };
476
+ }
477
+ if (entry.state === "held") {
478
+ if (entry.phase !== "running_tracked") {
479
+ const why = entry.phase === "claimed"
480
+ ? "permit is held but no process is attached yet — start the job (run_with_permit / attach_pid / start_job) first"
481
+ : "permit is running untracked (no process association) — completion is not observable; attach a pid or poll your job directly";
482
+ return { state: "job_unknown", requestId, waitedMs: 0, startedAt: entry.startedAt, nextAction: why, detail: `held permit in phase "${entry.phase}"` };
483
+ }
484
+ if (timeoutMs === 0) {
485
+ return {
486
+ state: "still_running",
487
+ requestId,
488
+ waitedMs: 0,
489
+ startedAt: entry.startedAt,
490
+ nextAction: "job still running — call wait_for_completion again (with a timeout) to sleep until it finishes",
491
+ detail: "check-only",
492
+ };
493
+ }
494
+ // Register a completion waiter; woken by resolveCompletionWaiters()
495
+ // when the job ends, or checkpointed by the bounded timer below.
496
+ return await new Promise((resolve) => {
497
+ const registeredAt = m.now();
498
+ const waiter = {
499
+ requestId,
500
+ resolve,
501
+ timer: null,
502
+ registeredAt,
503
+ };
504
+ waiter.timer = setTimeout(() => {
505
+ const arr = this.completionWaiters.get(requestId);
506
+ if (arr) {
507
+ const idx = arr.indexOf(waiter);
508
+ if (idx >= 0)
509
+ arr.splice(idx, 1);
510
+ if (arr.length === 0)
511
+ this.completionWaiters.delete(requestId);
512
+ }
513
+ resolve({
514
+ state: "still_running",
515
+ requestId,
516
+ waitedMs: m.now() - registeredAt,
517
+ startedAt: entry.startedAt,
518
+ nextAction: "job still running — call wait_for_completion again to keep sleeping honestly",
519
+ detail: "wait budget elapsed",
520
+ });
521
+ }, timeoutMs);
522
+ waiter.timer.unref?.();
523
+ const arr = this.completionWaiters.get(requestId) ?? [];
524
+ arr.push(waiter);
525
+ this.completionWaiters.set(requestId, arr);
526
+ });
527
+ }
528
+ // Terminal or pre-execution states: classify from the entry's last event.
529
+ return this.classifyEntryCompletion(requestId, 0);
530
+ }
531
+ /**
532
+ * Classify a request whose entry is no longer held (released, reclaimed,
533
+ * cancelled, or never started) into a structured completion result, using
534
+ * the most recent event for the request.
535
+ */
536
+ classifyEntryCompletion(requestId, waitedMs) {
537
+ const m = this.machine;
538
+ const ev = m.lastEventForRequest(requestId, 0);
539
+ const startedAt = ev?.entry?.startedAt;
540
+ if (!ev) {
541
+ return {
542
+ state: "job_unknown",
543
+ requestId,
544
+ waitedMs,
545
+ nextAction: "no completion is observable for this request id — verify the id or re-acquire",
546
+ detail: "entry is no longer held and no matching event was found",
547
+ };
548
+ }
549
+ switch (ev.type) {
550
+ case "released":
551
+ case "auto_released":
552
+ return {
553
+ state: "finished",
554
+ requestId,
555
+ durationMs: startedAt !== undefined ? ev.ts - startedAt : undefined,
556
+ permitReleased: true,
557
+ waitedMs,
558
+ startedAt,
559
+ nextAction: "job finished and the permit is released — capacity is free; proceed with the next step",
560
+ detail: ev.detail,
561
+ };
562
+ case "reclaimed":
563
+ if ((ev.detail ?? "").includes("admin")) {
564
+ return {
565
+ state: "stopped_by_request",
566
+ requestId,
567
+ waitedMs,
568
+ startedAt,
569
+ nextAction: "the permit was force-released by an admin while you were waiting — re-acquire before running more heavy work",
570
+ detail: ev.detail,
571
+ };
572
+ }
573
+ return {
574
+ state: "job_unknown",
575
+ requestId,
576
+ waitedMs,
577
+ startedAt,
578
+ nextAction: "the permit was reclaimed before completion could be observed — re-acquire before running more heavy work",
579
+ detail: ev.detail,
580
+ };
581
+ case "cancelled":
582
+ return {
583
+ state: "job_unknown",
584
+ requestId,
585
+ waitedMs,
586
+ startedAt,
587
+ nextAction: "the request was cancelled — re-acquire before running more heavy work",
588
+ detail: ev.detail,
589
+ };
590
+ default:
591
+ return {
592
+ state: "job_unknown",
593
+ requestId,
594
+ waitedMs,
595
+ startedAt,
596
+ nextAction: "no completion is observable for this request id — verify the id or re-acquire",
597
+ detail: `entry is in state "${ev.entry?.state ?? "unknown"}" (last event: ${ev.type})`,
598
+ };
599
+ }
600
+ }
601
+ /**
602
+ * Wake blocked wait_for_completion calls whose tracked job has ended.
603
+ * Called after every machine transition (afterOp, sweep, expiry timer) —
604
+ * always AFTER finish() has freed capacity and after the event flush, so
605
+ * a woken agent observes the pool as free. Entries still held are skipped.
606
+ */
607
+ resolveCompletionWaiters() {
608
+ if (this.completionWaiters.size === 0)
609
+ return;
610
+ const m = this.machine;
611
+ for (const [requestId, waiters] of [...this.completionWaiters]) {
612
+ let entry;
613
+ try {
614
+ entry = m.entryById(requestId);
615
+ }
616
+ catch {
617
+ entry = undefined;
618
+ }
619
+ if (entry && entry.state === "held")
620
+ continue; // job still running
621
+ this.completionWaiters.delete(requestId);
622
+ for (const w of waiters) {
623
+ clearTimeout(w.timer);
624
+ w.resolve(this.classifyEntryCompletion(requestId, m.now() - w.registeredAt));
625
+ }
626
+ }
627
+ }
417
628
  // ------------------------------------------------------------- post-op pass
418
629
  /** Runs after every dispatch: sweep deadlines, auto-claim for waiters, flush events, journal. */
419
630
  afterOp() {
@@ -448,6 +659,10 @@ export class Daemon {
448
659
  for (const ev of fresh)
449
660
  this.broadcast(ev);
450
661
  }
662
+ // Wake completion waiters whose job ended in this op — after the events
663
+ // flushed, i.e. after finish() already freed the capacity (ordering
664
+ // guarantee: a woken agent sees the pool as free).
665
+ this.resolveCompletionWaiters();
451
666
  // Persist held permits for restart reconciliation.
452
667
  try {
453
668
  saveJournal(this.stateDirPath, m.journalEntries());
@@ -474,6 +689,8 @@ export class Daemon {
474
689
  for (const ev of fresh)
475
690
  this.broadcast(ev);
476
691
  }
692
+ // Reclaims performed by the tick above must also wake waiters.
693
+ this.resolveCompletionWaiters();
477
694
  try {
478
695
  saveJournal(this.stateDirPath, this.machine.journalEntries());
479
696
  }
@@ -533,6 +750,10 @@ export class Daemon {
533
750
  for (const ev of fresh)
534
751
  this.broadcast(ev);
535
752
  }
753
+ // Wake completion waiters woken by this sweep (tracked job confirmed
754
+ // dead => auto-released above) — after capacity was freed and events
755
+ // flushed, per the wake ordering guarantee.
756
+ this.resolveCompletionWaiters();
536
757
  }
537
758
  }
538
759
  // --------------------------------------------------------------------- helpers
@@ -75,6 +75,11 @@ export declare class Machine {
75
75
  /** Events with id > since, newest last. */
76
76
  eventsSince(since: number, agentId?: string, limit?: number): MachineEvent[];
77
77
  latestEventId(): number;
78
+ /**
79
+ * Newest event with id > `since` whose entry is the given request, or null.
80
+ * Scans backwards, so the first match is the most recent one.
81
+ */
82
+ lastEventForRequest(requestId: string, since: number): MachineEvent | null;
78
83
  private emit;
79
84
  snapshot(e: Entry): EntrySnapshot;
80
85
  private ownerSnapshot;
@@ -87,6 +87,20 @@ export class Machine {
87
87
  latestEventId() {
88
88
  return this.nextEventId - 1;
89
89
  }
90
+ /**
91
+ * Newest event with id > `since` whose entry is the given request, or null.
92
+ * Scans backwards, so the first match is the most recent one.
93
+ */
94
+ lastEventForRequest(requestId, since) {
95
+ for (let i = this.events.length - 1; i >= 0; i--) {
96
+ const ev = this.events[i];
97
+ if (ev.id <= since)
98
+ break;
99
+ if (ev.entry?.requestId === requestId)
100
+ return ev;
101
+ }
102
+ return null;
103
+ }
90
104
  emit(type, entry, extra) {
91
105
  const targetAgent = entry ? entry.agentId : "*";
92
106
  const ev = {
@@ -134,6 +148,8 @@ export class Machine {
134
148
  s.leaseDeadline = e.leaseDeadline;
135
149
  if (e.lastSeen)
136
150
  s.lastSeen = e.lastSeen;
151
+ if (e.startedAt !== undefined)
152
+ s.startedAt = e.startedAt;
137
153
  return s;
138
154
  }
139
155
  ownerSnapshot(e) {
@@ -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,
@@ -16,11 +16,18 @@
16
16
  */
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { LLClient } from "../shared/client.js";
19
+ import { type CliDrift } from "../shared/drift.js";
19
20
  export interface McpServerOptions {
20
21
  client: LLClient;
21
22
  serverName?: string;
22
23
  /** Overridden by OpenCode's per-request session metadata when present. */
23
24
  agentId?: string;
25
+ /**
26
+ * CLI-drift notice to surface (issue #38). Defaults to auto-detection at
27
+ * build time (this adapter copy vs. the PATH-resolved CLI copy);
28
+ * pass null to suppress / a value to inject in tests.
29
+ */
30
+ drift?: CliDrift | null;
24
31
  }
25
32
  export declare function buildServer(opts: McpServerOptions): McpServer;
26
33
  export declare function runStdioMcp(opts?: {