@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.
- package/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- 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
|
-
}
|