@promptctl/cc-candybar 1.0.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/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] The variable schema: a VariableDecl is discriminated
|
|
2
|
+
// by `kind` (literal / input / env / file / shell / template / time / git / state),
|
|
3
|
+
// declared as DATA (VARIABLE_SCHEMA) and interpreted by the tag-by-field-value
|
|
4
|
+
// engine (taggedUnion). Each arm is a `fields` schema over its member's non-`kind`
|
|
5
|
+
// fields, except `input`, whose `default` must match its `type` — a genuine
|
|
6
|
+
// cross-field invariant carried as a closure. This file changes when a source
|
|
7
|
+
// kind's shape changes; adding a kind is one new arm here plus its runtime impl.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
GIT_FIELDS,
|
|
11
|
+
type GitField,
|
|
12
|
+
type EnvVarDecl,
|
|
13
|
+
type FileVarDecl,
|
|
14
|
+
type InputVarDecl,
|
|
15
|
+
type GitVarDecl,
|
|
16
|
+
type LiteralVarDecl,
|
|
17
|
+
type ShellVarDecl,
|
|
18
|
+
type StateVarDecl,
|
|
19
|
+
type TemplateVarDecl,
|
|
20
|
+
type TimeVarDecl,
|
|
21
|
+
type VariableDecl,
|
|
22
|
+
} from "../dsl-types.js";
|
|
23
|
+
import { findKeyLine } from "./diagnostics.js";
|
|
24
|
+
import {
|
|
25
|
+
describeType,
|
|
26
|
+
fields,
|
|
27
|
+
isPlainObject,
|
|
28
|
+
objectJson,
|
|
29
|
+
optionalStringSpec,
|
|
30
|
+
optionalTypedDefault,
|
|
31
|
+
requireStringSpec,
|
|
32
|
+
optionalEnumSpec,
|
|
33
|
+
taggedUnion,
|
|
34
|
+
taggedUnionJson,
|
|
35
|
+
withConst,
|
|
36
|
+
type FieldSpec,
|
|
37
|
+
type FieldSpecMap,
|
|
38
|
+
type JsonNode,
|
|
39
|
+
type TaggedArm,
|
|
40
|
+
type TaggedUnionSchema,
|
|
41
|
+
type ValidateCtx,
|
|
42
|
+
} from "./validate-core.js";
|
|
43
|
+
import {
|
|
44
|
+
optionalCacheSpec,
|
|
45
|
+
requireCacheSpec,
|
|
46
|
+
ttlOnlyCacheSpec,
|
|
47
|
+
} from "./cache.js";
|
|
48
|
+
|
|
49
|
+
export function validateVariables(
|
|
50
|
+
ctx: ValidateCtx,
|
|
51
|
+
pathPrefix: string,
|
|
52
|
+
raw: unknown,
|
|
53
|
+
): Record<string, VariableDecl> {
|
|
54
|
+
if (raw === undefined) return {};
|
|
55
|
+
if (!isPlainObject(raw)) {
|
|
56
|
+
ctx.issues.push({
|
|
57
|
+
path: pathPrefix,
|
|
58
|
+
message: `${pathPrefix} must be an object, got ${describeType(raw)}`,
|
|
59
|
+
line: findKeyLine(ctx.source, pathPrefix.split(".")),
|
|
60
|
+
});
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const out: Record<string, VariableDecl> = {};
|
|
65
|
+
for (const [name, decl] of Object.entries(raw)) {
|
|
66
|
+
const parsed = taggedUnion(
|
|
67
|
+
ctx,
|
|
68
|
+
VARIABLE_SCHEMA,
|
|
69
|
+
`${pathPrefix}.${name}`,
|
|
70
|
+
decl,
|
|
71
|
+
);
|
|
72
|
+
if (parsed !== null) out[name] = parsed;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// [LAW:types-are-the-program] `value` is a required union literal with a bespoke
|
|
78
|
+
// message whose line points at the variable (not `.value`) — a custom spec, since
|
|
79
|
+
// the generic string/enum specs encode different line behavior.
|
|
80
|
+
function literalValueSpec(): FieldSpec<string | number | boolean> {
|
|
81
|
+
return {
|
|
82
|
+
required: true,
|
|
83
|
+
json: { type: ["string", "number", "boolean"] },
|
|
84
|
+
parse: (ctx, path, _field, raw) => {
|
|
85
|
+
const value = raw.value;
|
|
86
|
+
if (
|
|
87
|
+
typeof value !== "string" &&
|
|
88
|
+
typeof value !== "number" &&
|
|
89
|
+
typeof value !== "boolean"
|
|
90
|
+
) {
|
|
91
|
+
ctx.issues.push({
|
|
92
|
+
path: `${path}.value`,
|
|
93
|
+
message: `literal value must be string|number|boolean, got ${describeType(value)}`,
|
|
94
|
+
line: findKeyLine(ctx.source, path.split(".")),
|
|
95
|
+
});
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// [LAW:types-are-the-program] `field` is a required member of the closed GitField
|
|
104
|
+
// set with a bespoke one-of message — a custom spec for the same reason.
|
|
105
|
+
function gitFieldSpec(): FieldSpec<GitField> {
|
|
106
|
+
return {
|
|
107
|
+
required: true,
|
|
108
|
+
json: { enum: [...GIT_FIELDS] },
|
|
109
|
+
parse: (ctx, path, _field, raw) => {
|
|
110
|
+
const field = raw.field;
|
|
111
|
+
if (
|
|
112
|
+
typeof field !== "string" ||
|
|
113
|
+
!GIT_FIELDS.includes(field as GitField)
|
|
114
|
+
) {
|
|
115
|
+
ctx.issues.push({
|
|
116
|
+
path: `${path}.field`,
|
|
117
|
+
message: `git field must be one of: ${GIT_FIELDS.join(", ")}, got ${JSON.stringify(field)}`,
|
|
118
|
+
line: findKeyLine(ctx.source, [...path.split("."), "field"]),
|
|
119
|
+
});
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return field as GitField;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// [LAW:types-are-the-program] `input`'s `default` carries the cross-field
|
|
128
|
+
// invariant — it must match the declared `type` (an absent or invalid `type`
|
|
129
|
+
// defaults the check to "string"). A field spec receives the WHOLE record, so it
|
|
130
|
+
// reads its sibling `raw.type` to pick the expected type WITHOUT re-reporting a
|
|
131
|
+
// bad type (the `type` field spec owns that error — reading raw here avoids the
|
|
132
|
+
// duplicate issue). This is what lets `input` derive BOTH `parse` and `json` from
|
|
133
|
+
// one field map via `arm()`, like every other arm — closing the last spot where
|
|
134
|
+
// the two interpreters were authored independently [LAW:one-source-of-truth].
|
|
135
|
+
function inputDefaultSpec(): FieldSpec<string | number | boolean> {
|
|
136
|
+
return {
|
|
137
|
+
required: false,
|
|
138
|
+
json: { type: ["string", "number", "boolean"] },
|
|
139
|
+
parse: (ctx, path, _field, raw) => {
|
|
140
|
+
const t = raw.type;
|
|
141
|
+
const expected =
|
|
142
|
+
t === "number" || t === "boolean" || t === "string" ? t : "string";
|
|
143
|
+
return optionalTypedDefault(ctx, path, raw, expected);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// [LAW:dataflow-not-control-flow] Each arm's field set is DATA over the member's
|
|
149
|
+
// non-`kind` fields; the engine supplies the discriminator. `fields` runs every
|
|
150
|
+
// spec (reporting all issues) and fails the arm when a required field is absent
|
|
151
|
+
// or invalid — the conditional-spread + result-threading the old switch hand-rolled.
|
|
152
|
+
const LITERAL_FIELDS: FieldSpecMap<Omit<LiteralVarDecl, "kind">> = {
|
|
153
|
+
value: literalValueSpec(),
|
|
154
|
+
default: optionalStringSpec(),
|
|
155
|
+
};
|
|
156
|
+
const INPUT_FIELDS: FieldSpecMap<Omit<InputVarDecl, "kind">> = {
|
|
157
|
+
path: requireStringSpec(),
|
|
158
|
+
type: optionalEnumSpec(["string", "number", "boolean"] as const),
|
|
159
|
+
default: inputDefaultSpec(),
|
|
160
|
+
};
|
|
161
|
+
const ENV_FIELDS: FieldSpecMap<Omit<EnvVarDecl, "kind">> = {
|
|
162
|
+
name: requireStringSpec(),
|
|
163
|
+
default: optionalStringSpec(),
|
|
164
|
+
};
|
|
165
|
+
const FILE_FIELDS: FieldSpecMap<Omit<FileVarDecl, "kind">> = {
|
|
166
|
+
path: requireStringSpec(),
|
|
167
|
+
readMode: optionalEnumSpec(["whole", "first-line"] as const),
|
|
168
|
+
regex: optionalStringSpec(),
|
|
169
|
+
cache: requireCacheSpec("file"),
|
|
170
|
+
default: optionalStringSpec(),
|
|
171
|
+
};
|
|
172
|
+
const SHELL_FIELDS: FieldSpecMap<Omit<ShellVarDecl, "kind">> = {
|
|
173
|
+
command: requireStringSpec(),
|
|
174
|
+
regex: optionalStringSpec(),
|
|
175
|
+
cache: requireCacheSpec("shell"),
|
|
176
|
+
default: optionalStringSpec(),
|
|
177
|
+
};
|
|
178
|
+
const TEMPLATE_FIELDS: FieldSpecMap<Omit<TemplateVarDecl, "kind">> = {
|
|
179
|
+
template: requireStringSpec(),
|
|
180
|
+
cache: optionalCacheSpec(),
|
|
181
|
+
default: optionalStringSpec(),
|
|
182
|
+
};
|
|
183
|
+
const TIME_FIELDS: FieldSpecMap<Omit<TimeVarDecl, "kind">> = {
|
|
184
|
+
layout: requireStringSpec(),
|
|
185
|
+
// [LAW:no-silent-failure] ttl-only: the runtime honors no other invalidation
|
|
186
|
+
// on a clock-driven var, so the loader rejects what it would otherwise have
|
|
187
|
+
// had to silently coerce.
|
|
188
|
+
cache: ttlOnlyCacheSpec(),
|
|
189
|
+
default: optionalStringSpec(),
|
|
190
|
+
};
|
|
191
|
+
const GIT_VAR_FIELDS: FieldSpecMap<Omit<GitVarDecl, "kind">> = {
|
|
192
|
+
field: gitFieldSpec(),
|
|
193
|
+
cache: requireCacheSpec("git"),
|
|
194
|
+
default: optionalStringSpec(),
|
|
195
|
+
};
|
|
196
|
+
const STATE_FIELDS: FieldSpecMap<Omit<StateVarDecl, "kind">> = {
|
|
197
|
+
key: requireStringSpec(),
|
|
198
|
+
default: optionalStringSpec(),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// [LAW:decomposition] A regular arm parses its non-`kind` fields via `fields` and
|
|
202
|
+
// re-attaches the tag the engine already validated; null threading is preserved.
|
|
203
|
+
function arm<
|
|
204
|
+
K extends VariableDecl["kind"],
|
|
205
|
+
M extends Omit<Extract<VariableDecl, { kind: K }>, "kind">,
|
|
206
|
+
>(
|
|
207
|
+
kind: K,
|
|
208
|
+
fieldMap: FieldSpecMap<M>,
|
|
209
|
+
): TaggedArm<Extract<VariableDecl, { kind: K }>> {
|
|
210
|
+
return {
|
|
211
|
+
// [LAW:one-source-of-truth] The arm's emit facet: the member object schema
|
|
212
|
+
// with its `kind` discriminator baked in — `objectJson` over the SAME field
|
|
213
|
+
// map `fields` validates, plus `{ kind: { const } }`. taggedUnionJson collects
|
|
214
|
+
// these verbatim into the union's anyOf.
|
|
215
|
+
json: withConst(objectJson(fieldMap), "kind", kind),
|
|
216
|
+
parse: (ctx: ValidateCtx, path: string, raw: Record<string, unknown>) => {
|
|
217
|
+
const body = fields(ctx, fieldMap, path, raw);
|
|
218
|
+
// [LAW:types-are-the-program] `fieldMap: FieldSpecMap<M>` is checked against
|
|
219
|
+
// the member's non-`kind` fields at each call site, so {kind, ...body} IS the
|
|
220
|
+
// member; TS can't relate the reconstruction to the distributed Extract for a
|
|
221
|
+
// generic K, hence the cast — the call-site check carries the real guarantee.
|
|
222
|
+
return body === null
|
|
223
|
+
? null
|
|
224
|
+
: ({ kind, ...body } as unknown as Extract<VariableDecl, { kind: K }>);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const VARIABLE_SCHEMA: TaggedUnionSchema<VariableDecl, "kind"> = {
|
|
230
|
+
tag: "kind",
|
|
231
|
+
noun: "source kind",
|
|
232
|
+
arms: {
|
|
233
|
+
literal: arm("literal", LITERAL_FIELDS),
|
|
234
|
+
// [LAW:one-source-of-truth] `input`'s `default`/`type` cross-field invariant
|
|
235
|
+
// lives in `inputDefaultSpec` (a field spec reading its sibling), so `input`
|
|
236
|
+
// is one field map like every other arm — `arm()` derives both `parse` and
|
|
237
|
+
// `json` from INPUT_FIELDS, no hand-authored schema to keep in sync.
|
|
238
|
+
input: arm("input", INPUT_FIELDS),
|
|
239
|
+
env: arm("env", ENV_FIELDS),
|
|
240
|
+
file: arm("file", FILE_FIELDS),
|
|
241
|
+
shell: arm("shell", SHELL_FIELDS),
|
|
242
|
+
template: arm("template", TEMPLATE_FIELDS),
|
|
243
|
+
time: arm("time", TIME_FIELDS),
|
|
244
|
+
git: arm("git", GIT_VAR_FIELDS),
|
|
245
|
+
state: arm("state", STATE_FIELDS),
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// [LAW:one-source-of-truth] One VariableDecl's schema, derived from the SAME
|
|
250
|
+
// VARIABLE_SCHEMA the validator interprets — the tag-by-kind anyOf.
|
|
251
|
+
export function variableDeclJson(): JsonNode {
|
|
252
|
+
return taggedUnionJson(VARIABLE_SCHEMA);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// [LAW:one-source-of-truth] The `variables` block (and a segment's nested `vars`)
|
|
256
|
+
// is a name → VariableDecl map; both surfaces emit this one shape, symmetric to
|
|
257
|
+
// both calling `validateVariables`.
|
|
258
|
+
export function variablesMapJson(): JsonNode {
|
|
259
|
+
return { type: "object", additionalProperties: variableDeclJson() };
|
|
260
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { launchDetachedSync } from "../proc/launch";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { socketPath, spawnLockPath, daemonDir } from "./paths";
|
|
6
|
+
|
|
7
|
+
// [LAW:single-enforcer] One primitive per runtime that owns the entire
|
|
8
|
+
// "obtain a daemon" verb. Every spawn site in the Node runtime now flows
|
|
9
|
+
// through this one path; the Rust client mirrors it in its own
|
|
10
|
+
// obtain-daemon primitive. Both runtimes agree on socketPath() and
|
|
11
|
+
// spawnLockPath() *and* on the lock mechanism: open(path, O_CREAT | O_EXCL)
|
|
12
|
+
// — existence == held; release by unlinking. A Rust kick and a Node kick
|
|
13
|
+
// are mutually recognizable.
|
|
14
|
+
//
|
|
15
|
+
// [LAW:dataflow-not-control-flow] Callers do not get to choose whether to
|
|
16
|
+
// spawn. They request a daemon; this function returns one of three typed
|
|
17
|
+
// outcomes. The decision lives in the data (connect result, lock acquisition,
|
|
18
|
+
// re-check after lock).
|
|
19
|
+
|
|
20
|
+
export type ObtainResult =
|
|
21
|
+
| { kind: "attached" }
|
|
22
|
+
| { kind: "started" }
|
|
23
|
+
| { kind: "failed"; reason: string };
|
|
24
|
+
|
|
25
|
+
interface ObtainOpts {
|
|
26
|
+
// Total deadline for the entire obtain operation. After this elapses we
|
|
27
|
+
// return { kind: "failed" } even if a daemon could come up shortly. Default
|
|
28
|
+
// 2000ms — generous for cold start, tight enough to bound user-visible
|
|
29
|
+
// latency on a busted state directory.
|
|
30
|
+
totalTimeoutMs?: number;
|
|
31
|
+
// Per-connect probe timeout. AF_UNIX connect to a live listener is sub-ms;
|
|
32
|
+
// anything slower implies "no listener" in practice.
|
|
33
|
+
connectTimeoutMs?: number;
|
|
34
|
+
// How long to wait after spawning before giving up on the daemon coming up.
|
|
35
|
+
spawnReadyTimeoutMs?: number;
|
|
36
|
+
// After this much continuous contention on spawn.lock without a daemon
|
|
37
|
+
// appearing, give up on the lock and spawn anyway. The bind() inside the
|
|
38
|
+
// daemon arbitrates duplicates, so a stuck lock degrades to bind-arbitrated
|
|
39
|
+
// contention instead of a multi-second availability gap. Default
|
|
40
|
+
// totalTimeoutMs / 2.
|
|
41
|
+
lockFallbackMs?: number;
|
|
42
|
+
// Test hook: replace the actual spawn call. Returning false simulates
|
|
43
|
+
// "spawn failed"; default spawns the real daemon.
|
|
44
|
+
spawn?: () => boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_OPTS: Required<Omit<ObtainOpts, "spawn" | "lockFallbackMs">> = {
|
|
48
|
+
totalTimeoutMs: 2000,
|
|
49
|
+
connectTimeoutMs: 50,
|
|
50
|
+
spawnReadyTimeoutMs: 1500,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function obtainDaemon(
|
|
54
|
+
opts: ObtainOpts = {},
|
|
55
|
+
): Promise<ObtainResult> {
|
|
56
|
+
const settings = { ...DEFAULT_OPTS, ...opts };
|
|
57
|
+
const spawnFn = opts.spawn ?? spawnDaemonDetachedReal;
|
|
58
|
+
const deadline = Date.now() + settings.totalTimeoutMs;
|
|
59
|
+
const lockFallbackMs =
|
|
60
|
+
opts.lockFallbackMs ?? Math.floor(settings.totalTimeoutMs / 2);
|
|
61
|
+
|
|
62
|
+
// [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>
|
|
63
|
+
// — synchronous filesystem failures (read-only FS, permission denial) must
|
|
64
|
+
// become typed failure outcomes, not throws that surprise the caller.
|
|
65
|
+
const setupErr = ensureStateDir();
|
|
66
|
+
if (setupErr) return { kind: "failed", reason: setupErr };
|
|
67
|
+
|
|
68
|
+
// Fast path: is a daemon already listening?
|
|
69
|
+
if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
|
|
70
|
+
return { kind: "attached" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// No daemon yet. Try to win the spawn-lock so we are the one to bring it up.
|
|
74
|
+
const contentionStart = Date.now();
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
const lock = tryAcquireSpawnLock();
|
|
77
|
+
if (lock.kind === "error") {
|
|
78
|
+
// [LAW:no-silent-fallbacks] Unrecoverable errors (EACCES, ENOTDIR,
|
|
79
|
+
// broken state dir) must not silently degrade into a contention loop
|
|
80
|
+
// + timeout. The caller gets an actionable reason.
|
|
81
|
+
return { kind: "failed", reason: `spawn-lock: ${lock.reason}` };
|
|
82
|
+
}
|
|
83
|
+
if (lock.kind === "held") {
|
|
84
|
+
try {
|
|
85
|
+
// Re-check: another caller may have spawned a daemon between our
|
|
86
|
+
// initial connect and our lock acquisition.
|
|
87
|
+
if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
|
|
88
|
+
return { kind: "attached" };
|
|
89
|
+
}
|
|
90
|
+
return await spawnAndWaitForReady(
|
|
91
|
+
spawnFn,
|
|
92
|
+
settings.spawnReadyTimeoutMs,
|
|
93
|
+
settings.connectTimeoutMs,
|
|
94
|
+
deadline,
|
|
95
|
+
"",
|
|
96
|
+
);
|
|
97
|
+
} finally {
|
|
98
|
+
releaseSpawnLock();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Lock contended. Another caller is in the spawn window — brief wait,
|
|
102
|
+
// then re-check for the socket they're bringing up.
|
|
103
|
+
await sleep(20);
|
|
104
|
+
if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
|
|
105
|
+
return { kind: "attached" };
|
|
106
|
+
}
|
|
107
|
+
// [LAW:dataflow-not-control-flow] spawn.lock is an optimization; bind()
|
|
108
|
+
// is the load-bearing exclusion. If we've been contended past the
|
|
109
|
+
// fallback threshold (e.g. crashed lock holder, slow staleness reclaim),
|
|
110
|
+
// bypass the lock and let bind() inside the daemon arbitrate duplicates.
|
|
111
|
+
// Same shape as a fresh spawn — caller pays one extra Node startup cost
|
|
112
|
+
// in the worst case, which is the right trade for availability.
|
|
113
|
+
if (Date.now() - contentionStart > lockFallbackMs) {
|
|
114
|
+
return await spawnAndWaitForReady(
|
|
115
|
+
spawnFn,
|
|
116
|
+
settings.spawnReadyTimeoutMs,
|
|
117
|
+
settings.connectTimeoutMs,
|
|
118
|
+
deadline,
|
|
119
|
+
" (lock-fallback)",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { kind: "failed", reason: "timeout obtaining daemon" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Spawn the daemon, then poll for it to bind. Returns the typed outcome.
|
|
128
|
+
// Shared by the lock-held path and the lock-fallback path so they don't drift.
|
|
129
|
+
async function spawnAndWaitForReady(
|
|
130
|
+
spawnFn: () => boolean,
|
|
131
|
+
spawnReadyTimeoutMs: number,
|
|
132
|
+
connectTimeoutMs: number,
|
|
133
|
+
outerDeadline: number,
|
|
134
|
+
reasonSuffix: string,
|
|
135
|
+
): Promise<ObtainResult> {
|
|
136
|
+
// [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>.
|
|
137
|
+
// A synchronous throw from child_process.spawn (ENOENT, invalid options)
|
|
138
|
+
// must become a typed failure, not a rejected promise.
|
|
139
|
+
let didSpawn = false;
|
|
140
|
+
try {
|
|
141
|
+
didSpawn = spawnFn();
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return {
|
|
144
|
+
kind: "failed",
|
|
145
|
+
reason: `spawn threw${reasonSuffix}: ${(e as Error).message}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!didSpawn) {
|
|
149
|
+
return {
|
|
150
|
+
kind: "failed",
|
|
151
|
+
reason: `spawn returned false${reasonSuffix}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const readyDeadline = Math.min(
|
|
155
|
+
Date.now() + spawnReadyTimeoutMs,
|
|
156
|
+
outerDeadline,
|
|
157
|
+
);
|
|
158
|
+
while (Date.now() < readyDeadline) {
|
|
159
|
+
if (await canConnect(socketPath(), connectTimeoutMs)) {
|
|
160
|
+
return { kind: "started" };
|
|
161
|
+
}
|
|
162
|
+
await sleep(20);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
kind: "failed",
|
|
166
|
+
reason: `daemon did not bind in time${reasonSuffix}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Synchronous fire-and-forget kick — used for "daemon-miss" recovery where
|
|
171
|
+
// the current render is already lost and we just want to warm the daemon for
|
|
172
|
+
// the next refresh. Mirrors the Rust client's obtain_daemon_kick at the same
|
|
173
|
+
// shape: lock + spawn + release, all synchronous, no await.
|
|
174
|
+
//
|
|
175
|
+
// [LAW:one-type-per-behavior] This is the Node mirror of Rust's
|
|
176
|
+
// obtain_daemon_kick in rust-client/src/main.rs. Both runtimes use the same
|
|
177
|
+
// existence-as-lock semantics so a Rust kick and a Node kick are mutually
|
|
178
|
+
// recognizable. The bind() inside the daemon arbitrates any duplicate spawns
|
|
179
|
+
// that slip past the lock.
|
|
180
|
+
//
|
|
181
|
+
// Why synchronous: callers (src/index.ts, src/install/index.ts) call this
|
|
182
|
+
// immediately before process.exit(). An async variant would suspend on the
|
|
183
|
+
// first await and never resume — process.exit would kill the process before
|
|
184
|
+
// child_process.spawn ever runs. The lock+spawn must complete in synchronous
|
|
185
|
+
// turn for the daemon to actually start.
|
|
186
|
+
// If the spawn.lock has existed longer than this, the kick path assumes the
|
|
187
|
+
// holder crashed mid-spawn and overrides to preserve availability.
|
|
188
|
+
// Calibrated to be much larger than any legitimate hold:
|
|
189
|
+
// - A healthy kick holds for <10ms (fork + release).
|
|
190
|
+
// - obtainDaemon's lock-held path can hold up to spawnReadyTimeoutMs
|
|
191
|
+
// (1500ms default) while polling for the new daemon to bind.
|
|
192
|
+
// 2s leaves a comfortable margin above the slowest legitimate holder while
|
|
193
|
+
// still recovering from a crashed-mid-spawn holder well before STALE_LOCK_MS.
|
|
194
|
+
const KICK_CONTENDED_OVERRIDE_MS = 2_000;
|
|
195
|
+
|
|
196
|
+
export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
|
|
197
|
+
if (ensureStateDir() !== null) return;
|
|
198
|
+
const spawnFn = opts.spawn ?? spawnDaemonDetachedReal;
|
|
199
|
+
const lock = tryAcquireSpawnLock();
|
|
200
|
+
|
|
201
|
+
// [LAW:dataflow-not-control-flow] Lock outcome is data, not control flow.
|
|
202
|
+
// - "contended": typically means another caller is in the spawn window;
|
|
203
|
+
// trust them and return. BUT: if the lock has been held suspiciously
|
|
204
|
+
// long, the holder is likely crashed mid-spawn — override and spawn
|
|
205
|
+
// unlocked. bind() arbitrates any duplicates.
|
|
206
|
+
// - "error": spawn-lock unavailable (broken state dir, perms). Per the
|
|
207
|
+
// architecture, spawn.lock is an *optimization* on top of bind()'s
|
|
208
|
+
// load-bearing exclusion — a lock error should NOT make this kick a
|
|
209
|
+
// hard stop on availability. Fall through to an unlocked spawn.
|
|
210
|
+
// - "held": normal path — spawn under the lock.
|
|
211
|
+
if (lock.kind === "contended") {
|
|
212
|
+
const ageMs = spawnLockAgeMs();
|
|
213
|
+
if (ageMs !== null && ageMs > KICK_CONTENDED_OVERRIDE_MS) {
|
|
214
|
+
process.stderr.write(
|
|
215
|
+
`cc-candybar: spawn-lock held ${ageMs}ms (likely crashed holder) — spawning unlocked\n`,
|
|
216
|
+
);
|
|
217
|
+
safeSpawn(spawnFn);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (lock.kind === "error") {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`cc-candybar: spawn-lock unavailable (${lock.reason}) — spawning unlocked\n`,
|
|
224
|
+
);
|
|
225
|
+
safeSpawn(spawnFn);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
safeSpawn(spawnFn);
|
|
230
|
+
} finally {
|
|
231
|
+
releaseSpawnLock();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function spawnLockAgeMs(): number | null {
|
|
236
|
+
try {
|
|
237
|
+
const st = fs.statSync(spawnLockPath());
|
|
238
|
+
return Date.now() - st.mtimeMs;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// [LAW:no-defensive-null-guards] Kick path is fire-and-forget; a spawn
|
|
245
|
+
// failure here is best-effort. Swallowing prevents an uncaught throw from
|
|
246
|
+
// crashing the calling process at the wrong moment (right before its own
|
|
247
|
+
// exit). Both failure modes (throw and false-return) are logged via stderr
|
|
248
|
+
// so kick failures stay visible — silent failure is the worst outcome.
|
|
249
|
+
function safeSpawn(spawnFn: () => boolean): void {
|
|
250
|
+
try {
|
|
251
|
+
if (!spawnFn()) {
|
|
252
|
+
process.stderr.write(
|
|
253
|
+
"cc-candybar: daemon spawn returned false (unable to resolve script path?)\n",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
process.stderr.write(
|
|
258
|
+
`cc-candybar: daemon spawn failed: ${(e as Error).message}\n`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── Spawn-lock (Node side) ──────────────────────────────────────────────────
|
|
264
|
+
//
|
|
265
|
+
// O_EXLOCK / fcntl(F_SETLK) aren't reliably exposed across Node platforms, so
|
|
266
|
+
// we use the simplest portable atomic primitive: open(path, "wx"). The file
|
|
267
|
+
// records owner pid + timestamp. Staleness is time-based — if the file is
|
|
268
|
+
// older than STALE_LOCK_MS we forcibly claim it. The spawn window is
|
|
269
|
+
// sub-second in practice; 10s is a wide tolerance.
|
|
270
|
+
//
|
|
271
|
+
// [LAW:no-defensive-null-guards] The staleness reclaim is not a "the holder
|
|
272
|
+
// might be dead, let me check" guard — it is the bounded-staleness policy of
|
|
273
|
+
// the lock. The bind() in the daemon is the real correctness boundary; this
|
|
274
|
+
// lock is a thundering-herd optimization, so a missed dedup just means one
|
|
275
|
+
// extra Node process eats a bind() race and exits.
|
|
276
|
+
|
|
277
|
+
const STALE_LOCK_MS = 10_000;
|
|
278
|
+
|
|
279
|
+
let heldLock: { fd: number; path: string } | null = null;
|
|
280
|
+
|
|
281
|
+
type LockOutcome =
|
|
282
|
+
| { kind: "held" }
|
|
283
|
+
| { kind: "contended" }
|
|
284
|
+
| { kind: "error"; reason: string };
|
|
285
|
+
|
|
286
|
+
function tryAcquireSpawnLock(): LockOutcome {
|
|
287
|
+
const path = spawnLockPath();
|
|
288
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
289
|
+
try {
|
|
290
|
+
const fd = fs.openSync(path, "wx", 0o600);
|
|
291
|
+
try {
|
|
292
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
293
|
+
} catch {}
|
|
294
|
+
heldLock = { fd, path };
|
|
295
|
+
return { kind: "held" };
|
|
296
|
+
} catch (e) {
|
|
297
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
298
|
+
if (code !== "EEXIST") {
|
|
299
|
+
// EACCES, ENOTDIR, ENOSPC, etc. are not contention — they are
|
|
300
|
+
// unrecoverable. Surface upward instead of pretending we lost a race.
|
|
301
|
+
return {
|
|
302
|
+
kind: "error",
|
|
303
|
+
reason: `openSync(${path}): ${code ?? (e as Error).message}`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!isLockStale(path)) return { kind: "contended" };
|
|
308
|
+
try {
|
|
309
|
+
fs.unlinkSync(path);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
// ENOENT means someone (the rightful holder, or another reclaimer)
|
|
312
|
+
// already removed the file — that's the desired post-condition, so
|
|
313
|
+
// continue to the retry. Other failures (EACCES, ENOSPC) are real.
|
|
314
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
315
|
+
if (code !== "ENOENT") {
|
|
316
|
+
return {
|
|
317
|
+
kind: "error",
|
|
318
|
+
reason: `unlink stale spawn.lock: ${(e as Error).message}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { kind: "contended" };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isLockStale(path: string): boolean {
|
|
327
|
+
try {
|
|
328
|
+
const st = fs.statSync(path);
|
|
329
|
+
return Date.now() - st.mtimeMs > STALE_LOCK_MS;
|
|
330
|
+
} catch {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function releaseSpawnLock(): void {
|
|
336
|
+
if (!heldLock) return;
|
|
337
|
+
const { fd, path } = heldLock;
|
|
338
|
+
heldLock = null;
|
|
339
|
+
try {
|
|
340
|
+
fs.closeSync(fd);
|
|
341
|
+
} catch {}
|
|
342
|
+
try {
|
|
343
|
+
fs.unlinkSync(path);
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─── State-dir setup ────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
// Returns null on success, or a reason string on unrecoverable failure. Used
|
|
350
|
+
// by both obtainDaemon (which converts to a `failed` result) and
|
|
351
|
+
// obtainDaemonKick (which silently gives up — there is no caller to report to).
|
|
352
|
+
function ensureStateDir(): string | null {
|
|
353
|
+
try {
|
|
354
|
+
fs.mkdirSync(daemonDir(), { recursive: true });
|
|
355
|
+
return null;
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return `mkdir ${daemonDir()}: ${(e as Error).message}`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Connect probe ──────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function canConnect(sockPath: string, timeoutMs: number): Promise<boolean> {
|
|
364
|
+
return new Promise((resolve) => {
|
|
365
|
+
const sock = net.connect(sockPath);
|
|
366
|
+
let settled = false;
|
|
367
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
368
|
+
const done = (result: boolean): void => {
|
|
369
|
+
if (settled) return;
|
|
370
|
+
settled = true;
|
|
371
|
+
if (timer) clearTimeout(timer);
|
|
372
|
+
sock.removeAllListeners();
|
|
373
|
+
sock.destroy();
|
|
374
|
+
resolve(result);
|
|
375
|
+
};
|
|
376
|
+
sock.once("connect", () => done(true));
|
|
377
|
+
sock.once("error", () => done(false));
|
|
378
|
+
timer = setTimeout(() => done(false), timeoutMs);
|
|
379
|
+
timer.unref();
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sleep(ms: number): Promise<void> {
|
|
384
|
+
return new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Default spawn implementation ───────────────────────────────────────────
|
|
388
|
+
//
|
|
389
|
+
// Cap V8 old-generation at 400 MB so GC fires before RSS hits the 512 MB hard
|
|
390
|
+
// limit. The Rust client mirrors this in rust-client/src/main.rs
|
|
391
|
+
// (spawn_daemon_detached) — keep the two in sync when changing this value.
|
|
392
|
+
//
|
|
393
|
+
// [LAW:single-enforcer] Routes through src/proc/launch so daemon-spawn shows
|
|
394
|
+
// up in subprocess metering (category "daemon-spawn"). The launch primitive
|
|
395
|
+
// owns the only child_process import in this file.
|
|
396
|
+
function spawnDaemonDetachedReal(): boolean {
|
|
397
|
+
const node = process.execPath;
|
|
398
|
+
const script = process.argv[1];
|
|
399
|
+
if (!script) return false;
|
|
400
|
+
// [LAW:no-silent-fallbacks] launchDetachedSync returns the typed outcome
|
|
401
|
+
// synchronously, so the spawn-failure case (ENOENT, EACCES, EAGAIN under
|
|
402
|
+
// process-table pressure) propagates as `false` instead of being silently
|
|
403
|
+
// reported as success. The previous `void launch({detached:true})` form
|
|
404
|
+
// discarded the Promise and unconditionally returned true.
|
|
405
|
+
const result = launchDetachedSync({
|
|
406
|
+
bin: node,
|
|
407
|
+
args: ["--max-old-space-size=400", script, "daemon"],
|
|
408
|
+
category: "daemon-spawn",
|
|
409
|
+
});
|
|
410
|
+
return result.ok;
|
|
411
|
+
}
|