@promptctl/cc-candybar 1.42.1 → 1.43.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 (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,108 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { debug } from "../utils/logger";
4
- import type { DaemonLogger } from "./log";
5
- import type { SessionSnapshot, SessionStorage } from "./session-state";
6
-
7
- // [LAW:locality-or-seam] Logging is injected, not hard-wired to daemon.log.
8
- // The daemon passes `dlog`; tests and non-daemon callers take this quiet
9
- // default, which stays silent unless CC_CANDYBAR_DEBUG is set — so unit tests
10
- // never open the real daemon log stream.
11
- const quietLogger: DaemonLogger = (_level, message) => debug(message);
12
-
13
- // [LAW:no-silent-fallbacks] Corrupt/missing file → empty state is the *defined*
14
- // recovery, not a hidden fallback to different data: an empty store re-rolls
15
- // random picks exactly as a first-ever boot would. Anything that isn't the
16
- // expected sessionId→key→value shape is rejected here so the store never
17
- // hydrates from a half-written or hand-edited file.
18
- function isSnapshot(value: unknown): value is SessionSnapshot {
19
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
20
- return false;
21
- }
22
- for (const kv of Object.values(value)) {
23
- if (kv === null || typeof kv !== "object" || Array.isArray(kv))
24
- return false;
25
- for (const leaf of Object.values(kv)) {
26
- if (typeof leaf !== "string") return false;
27
- }
28
- }
29
- return true;
30
- }
31
-
32
- // [LAW:single-enforcer] The debounce + atomic write lives here, not in
33
- // SessionState. The store calls save() on every mutation; this coalesces the
34
- // bursty 22-session × 1 Hz write load into at most one disk write per window.
35
- export class FileSessionStorage implements SessionStorage {
36
- private timer: ReturnType<typeof setTimeout> | null = null;
37
- private pending: SessionSnapshot | null = null;
38
-
39
- constructor(
40
- private readonly filePath: string,
41
- private readonly debounceMs: number = 500,
42
- private readonly logger: DaemonLogger = quietLogger,
43
- ) {}
44
-
45
- load(): SessionSnapshot {
46
- let raw: string;
47
- try {
48
- raw = fs.readFileSync(this.filePath, "utf8");
49
- } catch (e) {
50
- // [LAW:no-silent-fallbacks] A missing file is the expected first-boot
51
- // recovery (silent → empty). Any other read failure (EACCES, EIO) is an
52
- // anomaly worth surfacing before recovering to empty.
53
- const code = (e as NodeJS.ErrnoException).code;
54
- if (code !== "ENOENT") {
55
- this.logger(
56
- "warn",
57
- `session-state read failed (${code}); starting empty`,
58
- );
59
- }
60
- return {};
61
- }
62
- try {
63
- const parsed: unknown = JSON.parse(raw);
64
- if (isSnapshot(parsed)) return parsed;
65
- this.logger(
66
- "warn",
67
- `session-state load: unexpected shape, starting empty`,
68
- );
69
- return {};
70
- } catch {
71
- this.logger("warn", `session-state load: corrupt JSON, starting empty`);
72
- return {};
73
- }
74
- }
75
-
76
- save(snapshot: SessionSnapshot): void {
77
- this.pending = snapshot;
78
- if (this.timer) return;
79
- this.timer = setTimeout(() => this.flush(), this.debounceMs);
80
- this.timer.unref();
81
- }
82
-
83
- flush(): void {
84
- if (this.timer) {
85
- clearTimeout(this.timer);
86
- this.timer = null;
87
- }
88
- if (this.pending === null) return;
89
- const snapshot = this.pending;
90
- try {
91
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
92
- const tmp = `${this.filePath}.tmp`;
93
- // [LAW:single-enforcer] Daemon runtime files are owner-only (0o600), like
94
- // pid/spawn.lock. Session state carries conversation identifiers, so it
95
- // gets the same perms — chmod defeats umask and re-perms a reused tmp.
96
- fs.writeFileSync(tmp, JSON.stringify(snapshot), { mode: 0o600 });
97
- fs.chmodSync(tmp, 0o600);
98
- fs.renameSync(tmp, this.filePath);
99
- // [LAW:one-source-of-truth] `pending` is "state not yet durably written".
100
- // Clear it only once the rename lands, so a transient EIO/ENOSPC leaves
101
- // the snapshot for a later flush (e.g. shutdown) to retry rather than
102
- // silently dropping the last known state.
103
- this.pending = null;
104
- } catch (e) {
105
- this.logger("warn", `session-state save failed: ${(e as Error).message}`);
106
- }
107
- }
108
- }
@@ -1,237 +0,0 @@
1
- // [LAW:one-type-per-behavior] One generic store for all per-session state.
2
- // Adding a new per-session value is just picking a string key — no new class,
3
- // no DI wiring, no cache invalidation.
4
- //
5
- // [LAW:single-enforcer] Reads are MobX-tracked through a single internal atom.
6
- // Every get() reports observed; every set/clear/prune reports changed. A DSL
7
- // computed that reads SessionState via this object will re-evaluate whenever
8
- // any (sessionId, key) pair mutates — coarse-grained on purpose, since
9
- // session-state mutations are rare (clicks) and computeds are cheap. The
10
- // alternative — per-key atoms — would be lower-cardinality reactivity at the
11
- // cost of a much wider API surface; we don't need it.
12
- //
13
- // Outside a reactive context (the common case: ad-hoc gets from the segments
14
- // renderer), atom.reportObserved is a no-op. Tests that construct SessionState
15
- // without any observer see no change in behavior.
16
-
17
- import { createAtom, type IAtom, runInAction } from "mobx";
18
-
19
- export interface SessionStateReader {
20
- get(sessionId: string, key: string): string | null;
21
- }
22
-
23
- // [LAW:locality-or-seam] Renderer needs to *cache* per-session random picks
24
- // so subsequent renders are stable. Writing them back into the same store
25
- // click verbs use keeps state in one place — no parallel cache to drift.
26
- //
27
- // setBatch commits multiple (key, value) pairs as a single reactive
28
- // transaction: observers fire ONCE after every pair has landed, never
29
- // between pairs. The set-state verb's batched-pair URL (a Menu click that
30
- // writes the chosen value AND collapses the menu) depends on this — if
31
- // observers saw the first write before the second, an autorun could
32
- // render half-applied state. The atomicity contract lives in the seam,
33
- // not in each consumer. [LAW:single-enforcer]
34
- export interface SessionStateRW extends SessionStateReader {
35
- set(sessionId: string, key: string, value: string): void;
36
- setBatch(
37
- sessionId: string,
38
- pairs: ReadonlyArray<{ key: string; value: string }>,
39
- ): void;
40
- clear(sessionId: string, key: string): void;
41
- }
42
-
43
- // Flat, JSON-shaped mirror of the store: sessionId → key → value. This is the
44
- // on-disk representation and the load/save currency between store and storage.
45
- export type SessionSnapshot = Record<string, Record<string, string>>;
46
-
47
- // [LAW:locality-or-seam] The store depends on this seam, not on the filesystem.
48
- // The daemon injects a disk-backed impl; tests and non-daemon callers get the
49
- // ephemeral default. Persistence is a property of the *storage*, not the store.
50
- export interface SessionStorage {
51
- load(): SessionSnapshot;
52
- save(snapshot: SessionSnapshot): void;
53
- flush(): void;
54
- }
55
-
56
- // [LAW:dataflow-not-control-flow] Null-object so the store always calls
57
- // save()/flush() — no "am I persisting?" branch. The ephemeral case is data
58
- // (a storage that discards), not a special control path.
59
- const EPHEMERAL_STORAGE: SessionStorage = {
60
- load: () => ({}),
61
- save: () => {},
62
- flush: () => {},
63
- };
64
-
65
- function hydrate(snapshot: SessionSnapshot): Map<string, Map<string, string>> {
66
- const sessions = new Map<string, Map<string, string>>();
67
- for (const [sessionId, kv] of Object.entries(snapshot)) {
68
- sessions.set(sessionId, new Map(Object.entries(kv)));
69
- }
70
- return sessions;
71
- }
72
-
73
- // Generous headroom over realistic concurrent-session counts; matches the
74
- // render cache's LRU bound. Active sessions stay hot via get-promotion, so only
75
- // genuinely-idle sessions are ever evicted — eviction *is* "drop dead sessions".
76
- const DEFAULT_MAX_SESSIONS = 256;
77
-
78
- export class SessionState implements SessionStateReader, SessionStateRW {
79
- // [LAW:types-are-the-program] Insertion order is recency order: the store
80
- // cannot hold more than maxSessions, so "bounded on disk" is structural, not
81
- // dependent on an external prune caller.
82
- private sessions: Map<string, Map<string, string>>;
83
- private storage: SessionStorage;
84
- // [LAW:single-enforcer] One atom; every read reports observed against it,
85
- // every mutation reports changed. Coarse-grained reactivity is correct for
86
- // session-state's load — mutations are rare and computeds are cheap.
87
- private readonly atom: IAtom = createAtom("SessionState");
88
-
89
- constructor(
90
- storage: SessionStorage = EPHEMERAL_STORAGE,
91
- private readonly maxSessions: number = DEFAULT_MAX_SESSIONS,
92
- ) {
93
- this.storage = storage;
94
- this.sessions = new Map();
95
- this.hydrateFromStorage();
96
- }
97
-
98
- // [LAW:single-enforcer] Bind a persistence backend after construction. Only
99
- // the daemon process calls this (with the disk-backed storage), so importers
100
- // that merely load this module — the CLI relay, subcommands — keep the
101
- // ephemeral default and never read or write the state file. Must run before
102
- // the daemon serves requests, since it replaces in-memory state with disk.
103
- useStorage(storage: SessionStorage): void {
104
- this.storage = storage;
105
- this.hydrateFromStorage();
106
- }
107
-
108
- private hydrateFromStorage(): void {
109
- this.sessions = hydrate(this.storage.load());
110
- this.evictOldest();
111
- // [LAW:dataflow-not-control-flow] The disk mirror always reflects the built
112
- // in-memory state. An over-cap file trimmed by evictOldest is written back
113
- // here, so the on-disk bound holds even if no mutation ever follows.
114
- this.persist();
115
- }
116
-
117
- get(sessionId: string, key: string): string | null {
118
- // [LAW:single-enforcer] reportObserved is the reactive-dep registration —
119
- // outside a tracking context (the common direct-read case) it is a no-op.
120
- this.atom.reportObserved();
121
- const session = this.sessions.get(sessionId);
122
- if (!session) return null;
123
- // [LAW:dataflow-not-control-flow] A read promotes recency but never
124
- // triggers a disk write itself; the reordered insertion order only reaches
125
- // disk if a later mutation persists.
126
- this.touch(sessionId, session);
127
- return session.get(key) ?? null;
128
- }
129
-
130
- // [LAW:one-source-of-truth] set is the degenerate single-pair form of
131
- // setBatch — one write path through the store. The previous shape (a
132
- // standalone set body) split the write semantics across two routes
133
- // once setBatch was introduced; collapsing keeps mutation, persistence,
134
- // and notification in exactly one place.
135
- set(sessionId: string, key: string, value: string): void {
136
- this.setBatch(sessionId, [{ key, value }]);
137
- }
138
-
139
- // [LAW:no-silent-fallbacks] Atomic commit of N pairs: every write
140
- // lands BEFORE the single reportChanged() that scheduler-visibly
141
- // marks the transaction complete. Observers cannot see an
142
- // intermediate "half-applied" snapshot — `runInAction` defers
143
- // reaction scheduling until the outermost call exits, and we hold
144
- // ALL writes inside this one block. Previously, the verb's "loop and
145
- // call set N times" pattern fired reportChanged() N times, which
146
- // scheduled autoruns between pairs (visible to consumers as the menu
147
- // value changing while toolbar-expanded was still old). The batch
148
- // method is the structural fix: there is no way to get half-applied
149
- // state because there is no intermediate scheduler tick.
150
- //
151
- // [LAW:dataflow-not-control-flow] An empty pairs array is no-work-
152
- // to-do, returned without firing reportChanged or persisting. The
153
- // verb body validates that pairs is non-empty before calling, so
154
- // this is the public-API safety net rather than the hot path.
155
- setBatch(
156
- sessionId: string,
157
- pairs: ReadonlyArray<{ key: string; value: string }>,
158
- ): void {
159
- if (pairs.length === 0) return;
160
- runInAction(() => {
161
- const session = this.sessions.get(sessionId) ?? new Map<string, string>();
162
- for (const { key, value } of pairs) session.set(key, value);
163
- this.touch(sessionId, session);
164
- this.evictOldest();
165
- this.persist();
166
- this.atom.reportChanged();
167
- });
168
- }
169
-
170
- clear(sessionId: string, key: string): void {
171
- runInAction(() => {
172
- const session = this.sessions.get(sessionId);
173
- if (session) {
174
- session.delete(key);
175
- // An emptied session is a non-state — drop it so it neither occupies a
176
- // cap slot nor persists as a `{ "sid": {} }` husk. [LAW:one-source-of-truth]
177
- // A surviving session is promoted: every interaction is a recency signal,
178
- // uniform with get()/set(). [LAW:one-type-per-behavior]
179
- if (session.size === 0) this.sessions.delete(sessionId);
180
- else this.touch(sessionId, session);
181
- }
182
- this.persist();
183
- this.atom.reportChanged();
184
- });
185
- }
186
-
187
- // [LAW:one-source-of-truth] Drop state for sessions that no longer exist.
188
- prune(activeSessionIds: Set<string>): void {
189
- runInAction(() => {
190
- for (const id of this.sessions.keys()) {
191
- if (!activeSessionIds.has(id)) this.sessions.delete(id);
192
- }
193
- this.persist();
194
- this.atom.reportChanged();
195
- });
196
- }
197
-
198
- // Move-to-end: re-inserting at the tail makes this the most-recently-used.
199
- private touch(sessionId: string, session: Map<string, string>): void {
200
- this.sessions.delete(sessionId);
201
- this.sessions.set(sessionId, session);
202
- }
203
-
204
- private evictOldest(): void {
205
- let overflow = this.sessions.size - this.maxSessions;
206
- if (overflow <= 0) return;
207
- // Delete the oldest keys in insertion order. Cost is proportional to the
208
- // number of evictions, not the total loaded set — deleting an already-
209
- // yielded key mid-iteration is well-defined for a Map.
210
- for (const id of this.sessions.keys()) {
211
- this.sessions.delete(id);
212
- if (--overflow === 0) break;
213
- }
214
- }
215
-
216
- // Synchronous write of any debounced-pending snapshot. Called on daemon
217
- // shutdown so a pending pick isn't lost when the process exits.
218
- flush(): void {
219
- this.storage.flush();
220
- }
221
-
222
- private persist(): void {
223
- this.storage.save(this.serialize());
224
- }
225
-
226
- private serialize(): SessionSnapshot {
227
- // [LAW:types-are-the-program] sessionIds are external (hook JSON / click
228
- // URLs). A null-prototype root makes "__proto__"/"constructor" ordinary
229
- // own keys instead of prototype-mutation vectors — pollution is
230
- // unrepresentable rather than guarded against.
231
- const snapshot = Object.create(null) as SessionSnapshot;
232
- for (const [sessionId, kv] of this.sessions) {
233
- snapshot[sessionId] = Object.fromEntries(kv);
234
- }
235
- return snapshot;
236
- }
237
- }
@@ -1,209 +0,0 @@
1
- import fs from "node:fs";
2
- import process from "node:process";
3
-
4
- import type { ProcessIdentity } from "./process-fingerprint";
5
-
6
- // ─── Socket ownership lease ──────────────────────────────────────────────────
7
- //
8
- // [LAW:one-source-of-truth] The authority for "who owns this socket path" is a
9
- // lease file DERIVED FROM the socket path (one lease per socket identity), not a
10
- // connect probe. The prior probe (connect + classify the error code) was a
11
- // representation lie on macOS/BSD: connect(2) on a LIVE AF_UNIX listener whose
12
- // accept backlog is full returns ECONNREFUSED — indistinguishable from a dead
13
- // socket — so under load the probe judged healthy-but-slow daemons dead and
14
- // their sockets were stolen, minting immortal orphans (see
15
- // brandon-daemon-lifecycle-2b3).
16
- //
17
- // [FRAMING:representation] The lease records the owner's pid AND its kernel
18
- // start-time (see process-fingerprint.ts). Liveness is "the SAME process is
19
- // still alive" — kill(pid,0) alone lies when a crashed daemon's pid is recycled
20
- // to an unrelated live process (reads `alive` forever → no daemon ever comes up,
21
- // brandon-daemon-lifecycle-2b3.4 RESIDUAL 1). Comparing the start-time makes a
22
- // recycled pid provably a different process. Both signals are the kernel's
23
- // load-independent truth about process identity; a connect sample is
24
- // load-dependent hearsay and must never be the authority that destroys another
25
- // daemon's socket.
26
-
27
- // What a lease read yielded. Distinguishing absent / unreadable / owned keeps
28
- // the arbitration decision a full enumeration over raw inputs (see
29
- // arbitrateSocket) rather than collapsing the ambiguous cases early. `startTime`
30
- // is the owner's kernel start-time fingerprint, or null when the writing host
31
- // could not fingerprint (no `ps`) — the reader then falls back to kill(pid,0).
32
- export type LeaseRead =
33
- | { kind: "absent" }
34
- | { kind: "unreadable"; detail: string }
35
- | ({ kind: "owned" } & ProcessIdentity);
36
-
37
- // The EADDRINUSE arbitration outcome. The path already exists (something bound
38
- // it or a stale file remains); this says whether a LIVE owner holds it.
39
- export type SocketArbitration =
40
- | { kind: "attach-and-exit"; reason: string }
41
- | { kind: "reclaim"; reason: string };
42
-
43
- // Diagnostic-rich lease payload. `(pid, startTime)` is the authority (process
44
- // identity → liveness); the rest is operator diagnostics carried in the same
45
- // file so the lease subsumes the old diagnostic pidfile — one file, one identity
46
- // root. `startTime` is the kernel start-time token (also human-readable, so it
47
- // doubles as the "daemon started at" diagnostic the old `startedAt` gave), or
48
- // null when this host could not fingerprint.
49
- export interface LeaseRecord extends ProcessIdentity {
50
- version: number;
51
- binPath: string | undefined;
52
- }
53
-
54
- // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] The whole
55
- // arbitration is a pure fold: (raw lease read, injected liveness predicate) →
56
- // decision. Reading process identity is the sole effect and it is injected via
57
- // `isSameLiveProcess`, so every branch is exercised by input enumeration with no
58
- // real processes. Full input space:
59
- // owned + same-live → attach-and-exit (the SAME live owner holds the path; we
60
- // are a duplicate — exit so the incumbent keeps serving)
61
- // owned + not-same → reclaim (owner crashed, OR its pid was recycled to an
62
- // unrelated process — either way the socket is stale)
63
- // absent → reclaim (no lease to consult — a pre-lease or crashed
64
- // daemon left a stale socket; prefer availability)
65
- // unreadable → reclaim (can't prove a live owner — same)
66
- //
67
- // [LAW:one-source-of-truth] `isSameLiveProcess(pid, startTime)` consults the
68
- // kernel's process identity ((pid, start-time), see process-fingerprint.ts), not
69
- // a bare pid. This closes RESIDUAL 1: a crashed daemon's pid recycled to a
70
- // long-lived process now reads NOT-same (different start-time) → reclaim, so a
71
- // daemon comes up instead of every start attaching to a ghost forever.
72
- //
73
- // Failure directions:
74
- // false-dead (steal a live socket) — made self-healing by the ownership
75
- // self-check (brandon-daemon-lifecycle-2b3.2), and now far rarer: the
76
- // start-time match will not false-negative a live owner unless the host
77
- // cannot fingerprint at all, in which case sameLiveProcess falls back to
78
- // kill(pid,0) — the prior behavior, no worse.
79
- // Reclaiming on missing/unreadable is the availability-preferring direction — a
80
- // stale socket with no reclaimer is a hard no-service stall, strictly worse than
81
- // a transient double-serve that self-heals.
82
- export function arbitrateSocket(
83
- read: LeaseRead,
84
- isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
85
- ): SocketArbitration {
86
- if (read.kind === "owned") {
87
- return isSameLiveProcess(read.pid, read.startTime)
88
- ? {
89
- kind: "attach-and-exit",
90
- reason: `live owner pid=${read.pid} holds the socket`,
91
- }
92
- : {
93
- kind: "reclaim",
94
- reason: `owner pid=${read.pid} is gone or recycled`,
95
- };
96
- }
97
- if (read.kind === "absent") {
98
- return {
99
- kind: "reclaim",
100
- reason: "no lease — stale socket, no live owner",
101
- };
102
- }
103
- return {
104
- kind: "reclaim",
105
- reason: `unreadable lease (${read.detail}) — cannot prove a live owner`,
106
- };
107
- }
108
-
109
- // [LAW:no-silent-failure] Every non-happy path becomes a typed `unreadable`
110
- // with the reason inline, never a swallowed default that pretends the lease said
111
- // something. ENOENT is the one benign case → `absent`.
112
- export function readLease(leasePath: string): LeaseRead {
113
- let raw: string;
114
- try {
115
- raw = fs.readFileSync(leasePath, "utf8");
116
- } catch (e) {
117
- const code = (e as NodeJS.ErrnoException).code;
118
- if (code === "ENOENT") return { kind: "absent" };
119
- return {
120
- kind: "unreadable",
121
- detail: `read failed: ${(e as Error).message}`,
122
- };
123
- }
124
- let parsed: unknown;
125
- try {
126
- parsed = JSON.parse(raw);
127
- } catch (e) {
128
- return { kind: "unreadable", detail: `bad JSON: ${(e as Error).message}` };
129
- }
130
- const record = parsed as { pid?: unknown; startTime?: unknown } | null;
131
- const pid = record?.pid;
132
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
133
- return {
134
- kind: "unreadable",
135
- detail: `no valid pid (got ${JSON.stringify(pid)})`,
136
- };
137
- }
138
- // startTime is the process fingerprint. A present value must be a string;
139
- // absent/null means "unfingerprinted" (an older lease, or a host without
140
- // `ps`) and the reader falls back to kill(pid,0). A present-but-non-string is
141
- // malformed → unreadable (never silently coerce a lie into a fingerprint).
142
- const rawStartTime = record?.startTime;
143
- if (
144
- rawStartTime !== undefined &&
145
- rawStartTime !== null &&
146
- typeof rawStartTime !== "string"
147
- ) {
148
- return {
149
- kind: "unreadable",
150
- detail: `invalid startTime (got ${JSON.stringify(rawStartTime)})`,
151
- };
152
- }
153
- return { kind: "owned", pid, startTime: rawStartTime ?? null };
154
- }
155
-
156
- // Write our lease after we win the bind. Overwrite-on-write: whoever holds the
157
- // socket right now replaces whatever stale lease a dead owner left.
158
- //
159
- // [LAW:no-ambient-temporal-coupling] Atomic publish: write a uniquely-named temp
160
- // sibling then rename onto the lease path. POSIX rename within a directory is
161
- // atomic, so a concurrent reader (a second daemon's EADDRINUSE arbitration) sees
162
- // either the old lease or the complete new one — never a truncated/empty file
163
- // mid-write, which would read as `unreadable` and force a false reclaim of this
164
- // daemon's live socket. The unique temp name means it is always freshly created,
165
- // so its 0600 mode always applies (0600 has no group/world bits for any umask to
166
- // need re-tightening) and rename carries that mode across. Returns null on
167
- // success, a reason on failure.
168
- export function writeLease(
169
- leasePath: string,
170
- record: LeaseRecord,
171
- ): string | null {
172
- const tmp = `${leasePath}.${process.pid}.tmp`;
173
- try {
174
- fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
175
- fs.renameSync(tmp, leasePath);
176
- return null;
177
- } catch (e) {
178
- const reason = (e as Error).message;
179
- // [LAW:no-silent-failure] Best-effort temp cleanup, but don't swallow a real
180
- // failure: ENOENT means the temp was never created (write failed first) —
181
- // benign; any other unlink error means the temp WAS created and now leaks,
182
- // so append it to the reason rather than hiding it behind the primary error.
183
- try {
184
- fs.unlinkSync(tmp);
185
- } catch (cleanupErr) {
186
- if ((cleanupErr as NodeJS.ErrnoException).code !== "ENOENT") {
187
- return `${reason}; also failed to remove temp ${tmp}: ${(cleanupErr as Error).message}`;
188
- }
189
- }
190
- return reason;
191
- }
192
- }
193
-
194
- // [LAW:one-source-of-truth] Only the owner mutates its lease. A daemon that was
195
- // displaced (its socket stolen, the thief wrote a new lease) must NOT delete the
196
- // current owner's lease on its way out — that would make the next EADDRINUSE
197
- // read `absent` and reclaim the live thief's socket, cascading the theft. So
198
- // remove only when the lease still names us.
199
- export function removeLeaseIfOwned(leasePath: string, myPid: number): void {
200
- const read = readLease(leasePath);
201
- if (read.kind === "owned" && read.pid === myPid) {
202
- try {
203
- fs.unlinkSync(leasePath);
204
- } catch {
205
- // Best-effort cleanup; a leftover lease naming a dead pid is harmless
206
- // (the next daemon reads it, kill(pid,0)→ESRCH, reclaims).
207
- }
208
- }
209
- }