@promptctl/cc-candybar 1.149.5 → 1.151.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/cc-extra/PROVENANCE +2 -0
- package/dist/cc-extra/package.json +4 -0
- package/dist/cc-extra/src/attach-registrar.js +65 -0
- package/dist/cc-extra/src/boot-channel.js +63 -0
- package/dist/cc-extra/src/bun-attach.js +290 -0
- package/dist/cc-extra/src/bun-graph.js +191 -0
- package/dist/cc-extra/src/inspector-session.js +62 -0
- package/dist/cc-extra/src/launch.js +417 -0
- package/dist/cc-extra/src/seam-plan.js +160 -0
- package/dist/cc-extra/src/seams.js +163 -0
- package/dist/cc-extra/src/statusline-push.js +179 -0
- package/dist/cc-extra/src/ws-unix.js +125 -0
- package/dist/index.mjs +134 -125
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +51 -51
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// seams.js — the fragments of the app's own source the injector anchors on, and what it splices in.
|
|
2
|
+
//
|
|
3
|
+
// This file is the ONLY place in the tree that knows anything about Claude Code's internals, and a
|
|
4
|
+
// seam deliberately knows as little as possible: one fragment of source, and one statement to insert
|
|
5
|
+
// before it. Everything else — which module carries it, at what offset — is resolved from the
|
|
6
|
+
// installed binary at launch by seam-plan.js. There is no chunk name here and no version literal,
|
|
7
|
+
// because both change every release and a map that changes weekly is a map that is already lying.
|
|
8
|
+
//
|
|
9
|
+
// WHY THESE ANCHORS SURVIVE MINIFICATION. Bun renames every module-scope binding — the gate function
|
|
10
|
+
// below was renamed between two releases a week apart — but it cannot rename a PROPERTY, and it
|
|
11
|
+
// cannot rewrite a STRING LITERAL the app sends to a server. Property names and literals come
|
|
12
|
+
// through verbatim. Anchoring on one of those rather than on an offset, a variable, or a line number
|
|
13
|
+
// is what makes this hold across releases without a table someone maintains by hand. No minified
|
|
14
|
+
// name is spelled here: the one place any of them appears is the dated measurement below.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
// The global a spliced statement calls. The host installs it before a single module is evaluated,
|
|
19
|
+
// which is why inserted code can call it unguarded — an absent registrar is unrepresentable rather
|
|
20
|
+
// than survivable. [LAW:no-defensive-null-guards]
|
|
21
|
+
const REGISTRAR = '__CC_EXTRA_SEAM__';
|
|
22
|
+
|
|
23
|
+
// THE PROPOSE-GOAL SEAM — give the model the goal tool the shipped build already carries.
|
|
24
|
+
//
|
|
25
|
+
// `/goal` has two shipped forms. The one a person types is a slash command; the one the MODEL can
|
|
26
|
+
// reach is a real tool, `ProposeGoal`, which enqueues `/goal <condition>` on the session's own
|
|
27
|
+
// message queue — the shipped way a tool runs a slash command. That tool is already built, already
|
|
28
|
+
// in `getAllBaseTools()`'s array, and already excluded from the offered set by its own `isEnabled()`.
|
|
29
|
+
//
|
|
30
|
+
// MEASURED on the shipped 2.1.280 (a dated observation, not an input — nothing below reads it, and
|
|
31
|
+
// every name in it is minified and already different from 2.1.278's). `isEnabled()` is five
|
|
32
|
+
// conjuncts, and on an ordinary interactive local session exactly one of them is false:
|
|
33
|
+
//
|
|
34
|
+
// isEnabled(){ if(Te()||Zn())return!1; if(At())return!1; if(!Pjt())return!1;
|
|
35
|
+
// let e=pze(); if(e==="disabled")return!1; return E(e),!0 }
|
|
36
|
+
//
|
|
37
|
+
// Te() — the session is NOT interactive. false here: it is.
|
|
38
|
+
// Zn() — the workspace is remote. false here: it is local.
|
|
39
|
+
// At() — `s9()==="bg"`, a background job. false here: it is a terminal session.
|
|
40
|
+
// pze() — the `modelProposedGoals` setting, `"auto"` when absent, and only `"disabled"` refuses.
|
|
41
|
+
// Pjt() — `x("tengu_propose_goal",!1)`, a SERVER-SIDE feature gate, default false. This one.
|
|
42
|
+
//
|
|
43
|
+
// WHY THE GATE AND NOT THE TOOL. That gate is the app's single authority on whether the goal feature
|
|
44
|
+
// exists at all: the tool's `isEnabled` reads it, and so does the `/config` panel, which shows the
|
|
45
|
+
// `modelProposedGoals` row only when the gate is on. Splicing the gate turns the feature on once, at
|
|
46
|
+
// its source, and both consumers agree by construction. Splicing `isEnabled` instead would leave a
|
|
47
|
+
// session whose goal tool works and whose settings panel denies the tool exists — two maps of one
|
|
48
|
+
// fact, drifting the moment either is read. [LAW:one-source-of-truth] [LAW:single-enforcer]
|
|
49
|
+
//
|
|
50
|
+
// WHY NOT CONFIGURATION. Feature values resolve environment override -> config override -> remote
|
|
51
|
+
// payload -> disk cache -> default, and on a shipped build the first two are compiled to
|
|
52
|
+
// `return null` / `return`. When the remote payload is non-empty but lacks the key the resolver
|
|
53
|
+
// returns the default and SKIPS the disk cache entirely, so seeding the cache does nothing — and
|
|
54
|
+
// writing it would make this a second writer to a file the gate client owns and rewrites on its own
|
|
55
|
+
// schedule. The gate is a runtime value; the only honest place to change it is at runtime.
|
|
56
|
+
//
|
|
57
|
+
// WHY THE ANCHOR IS SHAPED THE WAY IT IS. `"tengu_propose_goal"` is a key the app sends to a feature
|
|
58
|
+
// service, so it survives minification untouched and occurs exactly ONCE across all 1,991 js modules.
|
|
59
|
+
// The rest of the anchor is not there to find the site — the literal already did that — it is there
|
|
60
|
+
// to PROVE the site is a statement whose value is the gate's answer, because the only insertion that
|
|
61
|
+
// could go wrong here is one absorbed into a larger expression that inverts it:
|
|
62
|
+
//
|
|
63
|
+
// the `function <name>(){` pins the brace to a FUNCTION BODY, so the spliced `return true;`
|
|
64
|
+
// lookbehind is provably the first statement that function runs, and the leading `[;}\n]` pins that
|
|
65
|
+
// declaration to a STATEMENT POSITION — a function declaration, never an expression
|
|
66
|
+
// whose value an enclosing operator consumes. Neither half is ornamental. A lookbehind
|
|
67
|
+
// of just `\{` admits `if(W()){return P(...)}else{...}`: it resolves to one site,
|
|
68
|
+
// splices cleanly, and leaves a gate that still answers the server on the other branch
|
|
69
|
+
// — resolved to the wrong place and still running, which is the one failure this whole
|
|
70
|
+
// anchor exists to prevent. Without `[;}\n]`, `return!function R(){return P(...)}()`
|
|
71
|
+
// admits the same way, and there an operator inverts what the splice returns. The
|
|
72
|
+
// `function ` keyword carries its own weight: `\w+ ` in its place admits the valid class
|
|
73
|
+
// members `get G(){`, `async G(){` and `static G(){`.
|
|
74
|
+
// IT DOES NOT PROVE NESTING DEPTH — no regex counts braces. A declaration inside another
|
|
75
|
+
// function body still matches whenever `;`, `}` or a newline precedes it, and
|
|
76
|
+
// `seams.test.js` pins that as the known limit rather than implying it away. Proving top
|
|
77
|
+
// level would take a parser, and it would refuse the one nesting that actually occurs: a
|
|
78
|
+
// Bun `__esm`/`__commonJS` chunk wrapper, where the nested declaration IS the gate and
|
|
79
|
+
// splicing into it is correct. The residual is a hand-written
|
|
80
|
+
// `function outer(){…function R(){gate}return!R()}`, which minification does not produce
|
|
81
|
+
// from this source. The lookbehind is variable-length, which V8 supports and most
|
|
82
|
+
// engines do not; node is the only host here, so that is a fact rather than a bet.
|
|
83
|
+
// `\s+` a `return` whose operand is a call has to be separated from it, so requiring the
|
|
84
|
+
// space is what tells `return P(...)` apart from a call to a function NAMED
|
|
85
|
+
// `returnP(...)`. `\s*` would match the second and splice a return into a function
|
|
86
|
+
// that never asked the gate anything. (A negated call, `return!P(...)`, is refused by
|
|
87
|
+
// the identifier class that follows, not by this.)
|
|
88
|
+
// the quote the closing `"` pins the exact key, so a future `"tengu_propose_goal_v2"` refuses
|
|
89
|
+
// instead of resolving onto a gate that means something else.
|
|
90
|
+
//
|
|
91
|
+
// Every one of those is a REFUSAL when the shape moves, and a refusal costs the user the hosted
|
|
92
|
+
// session and never a working one: the host reports `no-module-carries-the-seam` and the caller falls
|
|
93
|
+
// back to stock claude. A seam that resolved to the wrong place is the failure worth engineering
|
|
94
|
+
// against, because that one still runs. [LAW:no-silent-failure]
|
|
95
|
+
//
|
|
96
|
+
// A patch enters this list as a VALUE — an anchor and the statement to insert — and every other part
|
|
97
|
+
// of the mechanism is already unconditional: the plan is resolved, the transform is applied and the
|
|
98
|
+
// registrar is installed on every boot, whether this list holds one seam or ten. Adding a patch is
|
|
99
|
+
// adding data, never a new path. [LAW:dataflow-not-control-flow]
|
|
100
|
+
// THE STATUSLINE-PUSH SEAM — draw the status line from frames cc-candybar's daemon pushes
|
|
101
|
+
// (statusline-push.js says why and how).
|
|
102
|
+
//
|
|
103
|
+
// MEASURED on the shipped 2.1.299 (dated, not an input). The status line controller is one class;
|
|
104
|
+
// it draws through a PRIVATE emitter, and runs the command from an arrow inside its own method:
|
|
105
|
+
//
|
|
106
|
+
// #R(e,n,r,s){ … await _Yo({signal:e, executeCommand:()=>{ let M=this.#t, z=kYo({…});
|
|
107
|
+
// if(!e.aborted)this.#b(wYo(z)); return this.#e.execute(z,e,r) },
|
|
108
|
+
// getCommandLength:()=>…, pendingResultLogRef:this.#g, onResult:(M)=>this.#S(M), … }) }
|
|
109
|
+
// #S(e){ if(this.#n.text===e)return; this.#n={text:e}; …; this.#o.emit() }
|
|
110
|
+
//
|
|
111
|
+
// `z` is the statusline JSON, `this.#e.execute(z,e,r)` runs the command, and `#S` is what its output
|
|
112
|
+
// is drawn through. A private name can only be written inside the class body, which is exactly where
|
|
113
|
+
// this splice lands: `this` is the controller and `this.#S` is reachable. The anchor CAPTURES the
|
|
114
|
+
// pieces the insert needs — the deps field, the payload, the run's other two arguments, the emitter —
|
|
115
|
+
// so no minified name is written here; each is read off the release being spliced.
|
|
116
|
+
//
|
|
117
|
+
// THE SHAPE OF THE INSERT. A splice only adds text before its anchor, and the anchor is the command
|
|
118
|
+
// call itself, so the original call stays after the insert as the right operand of `||`:
|
|
119
|
+
//
|
|
120
|
+
// return <registrar>.statusLine(this, z, (t)=>this.#S(t), ()=>this.#e.execute(z,e,r)) || this.#e.execute(z,e,r)
|
|
121
|
+
//
|
|
122
|
+
// `statusLine` is async, so it always returns a promise and the right operand never runs; the run
|
|
123
|
+
// goes through the thunk the registrar is handed, once.
|
|
124
|
+
//
|
|
125
|
+
// WHY THE ANCHOR IS SHAPED THE WAY IT IS. `.execute(` alone occurs all over the graph; what makes
|
|
126
|
+
// this one the status line's is what follows it — the `getCommandLength:` key of the same object
|
|
127
|
+
// literal, then that literal's `onResult:` arrow into a private method — keys the minifier cannot
|
|
128
|
+
// rename. `(?<=return )` pins the anchor to the operand of the arrow's `return`, so the `||` lands in
|
|
129
|
+
// a return expression and nowhere an operator could absorb it. The `[^]{0,400}?` between
|
|
130
|
+
// `getCommandLength:` and `onResult:` spans the two keys between them in this literal (about 120
|
|
131
|
+
// characters in 2.1.299); bounded, so it cannot reach into another object literal's `onResult:`.
|
|
132
|
+
// Resolved to exactly one site in every installed release, 2.1.267 through 2.1.299.
|
|
133
|
+
const STATUSLINE_PUSH = {
|
|
134
|
+
name: 'statusline-push',
|
|
135
|
+
anchor:
|
|
136
|
+
/(?<=return )this\.(?<deps>#[\w$]+)\.execute\((?<payload>[\w$]+),(?<rest>[\w$]+,[\w$]+)\)(?=\},getCommandLength:[^]{0,400}?onResult:\([\w$]+\)=>this\.(?<emit>#[\w$]+)\()/,
|
|
137
|
+
insert: `globalThis.${REGISTRAR}.statusLine(this,$<payload>,(t)=>this.$<emit>(t),()=>this.$<deps>.execute($<payload>,$<rest>))||`,
|
|
138
|
+
// THE BREAKPOINT FORM, for a stock Bun session reached over its inspector (bun-attach.js), where
|
|
139
|
+
// nothing can be spliced. A breakpoint on the `return` the anchor stands after — the lookbehind is
|
|
140
|
+
// why the offset is minus its length — evaluates `action` in the controller's own frame on every
|
|
141
|
+
// command run, without pausing. That frame is inside the class body, so `this.#S` evaluates there
|
|
142
|
+
// (MEASURED on 2.1.299: JSC accepts the private name in-frame), and the action hands the registrar
|
|
143
|
+
// the controller, its deps object and its emitter. The registrar wraps `deps.execute` once, so the
|
|
144
|
+
// run answers with the newest pushed frame exactly as the splice's `||` does. Wrapping deps is
|
|
145
|
+
// per-controller because the app builds a fresh deps object literal for each controller it
|
|
146
|
+
// constructs (2.1.299). A breakpoint cannot replace a return value, which is why the wrap and not
|
|
147
|
+
// the action is what changes the answer.
|
|
148
|
+
attach: {
|
|
149
|
+
offset: -'return '.length,
|
|
150
|
+
action: `globalThis.${REGISTRAR}.attachStatusLine(this,this.$<deps>,(t)=>this.$<emit>(t))`,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const SEAMS = [
|
|
155
|
+
{
|
|
156
|
+
name: 'propose-goal',
|
|
157
|
+
anchor: /(?<=[;}\n]function [A-Za-z_$][\w$]*\(\)\{)return\s+[A-Za-z_$][\w$]*\("tengu_propose_goal"/,
|
|
158
|
+
insert: 'return true;',
|
|
159
|
+
},
|
|
160
|
+
STATUSLINE_PUSH,
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
module.exports = { REGISTRAR, SEAMS };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// statusline-push.js — draw the status line from the frames cc-candybar's daemon pushes, instead of
|
|
2
|
+
// only the output of the command Claude Code reruns.
|
|
3
|
+
//
|
|
4
|
+
// WHY. Claude Code reruns the status line command once a second at best (`refreshInterval` is
|
|
5
|
+
// clamped to a whole second, and every tick passes a 300 ms debounce that restarts itself), so an
|
|
6
|
+
// animated bar moves at one frame a second. cc-candybar's daemon can push a session's frames as it
|
|
7
|
+
// draws them — up to thirty a second while the bar moves, and the moment a click changes it — over a
|
|
8
|
+
// `subscribe` request on its socket (cc-candybar candybar-animation-wbxz.jmm.usl). This module holds
|
|
9
|
+
// one such subscription per status line controller and hands each frame to the controller's own
|
|
10
|
+
// emitter, the one the command's output goes through, so the bar is drawn by the code that always
|
|
11
|
+
// drew it.
|
|
12
|
+
//
|
|
13
|
+
// THE COMMAND KEEPS RUNNING. The daemon draws a session's frames from the latest statusline JSON it
|
|
14
|
+
// was sent, and the command is what sends it: Claude Code builds that JSON and runs the command on
|
|
15
|
+
// its own schedule, as it always has, so the data stays exactly as fresh as a stock session's. What
|
|
16
|
+
// changes is what the command's own answer is used for. While frames arrive, the answer handed back
|
|
17
|
+
// to the controller is the newest pushed frame, not the command's output — that output is the bar
|
|
18
|
+
// as it stood when the command ran, an instant older than the frame already on screen, and showing
|
|
19
|
+
// it would step the animation backwards once a tick. While no frames arrive (no daemon listening,
|
|
20
|
+
// one too old to know `subscribe`, a session it has no data for yet), the command's output is the
|
|
21
|
+
// answer, exactly as in a stock session: both come from the same daemon's one renderer, so this is
|
|
22
|
+
// not a fallback to a different source. [LAW:one-source-of-truth]
|
|
23
|
+
//
|
|
24
|
+
// [LAW:effects-at-boundaries] the socket is passed in (`connect`), so the suite drives this with no
|
|
25
|
+
// daemon. The one thing this module knows of cc-candybar's internals is its wire contract, below.
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
// cc-candybar's wire contract (its src/daemon/protocol.ts): every frame a 4-byte big-endian length
|
|
30
|
+
// and a UTF-8 JSON body; a request names the protocol version it speaks; the daemon listens on the
|
|
31
|
+
// socket `CC_CANDYBAR_SOCKET` names, else the one derived from the user's uid. A daemon on another
|
|
32
|
+
// version answers VERSION_MISMATCH, which this module reports and stops on — never guesses past.
|
|
33
|
+
const PROTOCOL_VERSION = 3;
|
|
34
|
+
const HEADER_BYTES = 4;
|
|
35
|
+
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
const daemonSocketPath = (env, uid) => env.CC_CANDYBAR_SOCKET || `/tmp/cc-candybar-${uid}/socket`;
|
|
38
|
+
|
|
39
|
+
const encodeFrame = (value) => {
|
|
40
|
+
const body = Buffer.from(JSON.stringify(value), 'utf8');
|
|
41
|
+
const header = Buffer.alloc(HEADER_BYTES);
|
|
42
|
+
header.writeUInt32BE(body.length, 0);
|
|
43
|
+
return Buffer.concat([header, body]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Calls `onFrame` with each complete frame's parsed body; `onError` once, on a frame that cannot be
|
|
47
|
+
// one (too large, not JSON), after which it reads nothing more.
|
|
48
|
+
function frameReader(onFrame, onError) {
|
|
49
|
+
let buf = Buffer.alloc(0);
|
|
50
|
+
let broken = false;
|
|
51
|
+
return (chunk) => {
|
|
52
|
+
if (broken) return;
|
|
53
|
+
buf = Buffer.concat([buf, chunk]);
|
|
54
|
+
while (buf.length >= HEADER_BYTES) {
|
|
55
|
+
const len = buf.readUInt32BE(0);
|
|
56
|
+
if (len > MAX_FRAME_BYTES) { broken = true; onError(new Error(`frame too large: ${len}`)); return; }
|
|
57
|
+
if (buf.length < HEADER_BYTES + len) return;
|
|
58
|
+
const body = buf.subarray(HEADER_BYTES, HEADER_BYTES + len).toString('utf8');
|
|
59
|
+
buf = buf.subarray(HEADER_BYTES + len);
|
|
60
|
+
let frame;
|
|
61
|
+
try { frame = JSON.parse(body); } catch (e) { broken = true; onError(e); return; }
|
|
62
|
+
onFrame(frame);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What Claude Code does to a status line command's stdout before it draws it (MEASURED in 2.1.299's
|
|
68
|
+
// command runner: trim, trim each line, drop the empty ones; nothing left is no status line). A
|
|
69
|
+
// pushed frame goes through the same, so it draws exactly as the command's output would.
|
|
70
|
+
const asStatusText = (output) =>
|
|
71
|
+
output.trim().split('\n').flatMap((line) => line.trim() || []).join('\n') || undefined;
|
|
72
|
+
|
|
73
|
+
// The animation lane this subscription is (cc-candybar candybar-animation-wbxz.81o.azo): the daemon
|
|
74
|
+
// streams it only while the session's settings switch it on (`🧪 statusline (B)` in the settings
|
|
75
|
+
// menu, `globals.experimentalStatusline`), and refuses it SWITCHED_OFF otherwise.
|
|
76
|
+
const LANE = 'statusline';
|
|
77
|
+
|
|
78
|
+
// How long a subscription the daemon refused SWITCHED_OFF waits before it asks again: a toggle turned
|
|
79
|
+
// on is picked up within this, and a lane left off costs the daemon one refusal per wait, not one per
|
|
80
|
+
// run of the command.
|
|
81
|
+
const SWITCHED_OFF_RETRY_MS = 5000;
|
|
82
|
+
|
|
83
|
+
// [LAW:types-are-the-program] One controller's subscription, as state: none open; one open and not
|
|
84
|
+
// yet carrying frames; one carrying frames, with the newest; switched off in the session's settings
|
|
85
|
+
// as of `at` (ms), asked again once SWITCHED_OFF_RETRY_MS has passed; or refused for good by the
|
|
86
|
+
// daemon (a version it does not speak, a request it does not know), which is not retried for that
|
|
87
|
+
// session.
|
|
88
|
+
// { kind: 'closed' } | { kind: 'open', socket } | { kind: 'live', socket, text } |
|
|
89
|
+
// { kind: 'off', at } | { kind: 'refused', reason }
|
|
90
|
+
|
|
91
|
+
function createStatusLinePush({ connect, socketPath, report, now = Date.now }) {
|
|
92
|
+
// WEAK, so a controller the app drops takes its subscription's bookkeeping with it. The socket
|
|
93
|
+
// itself is closed by the stream ending or by the session moving; a controller that stops running
|
|
94
|
+
// its command and is collected leaves its socket to close when the daemon or the process does.
|
|
95
|
+
const feeds = new WeakMap();
|
|
96
|
+
|
|
97
|
+
const open = (feed, sessionId) => {
|
|
98
|
+
const socket = connect(socketPath);
|
|
99
|
+
feed.sessionId = sessionId;
|
|
100
|
+
feed.state = { kind: 'open', socket };
|
|
101
|
+
const mine = () => feed.state.socket === socket;
|
|
102
|
+
const end = (why) => {
|
|
103
|
+
if (!mine()) return;
|
|
104
|
+
feed.state = { kind: 'closed' };
|
|
105
|
+
socket.destroy();
|
|
106
|
+
report(`statusline-push sid=${JSON.stringify(sessionId)} ended: ${why}`);
|
|
107
|
+
};
|
|
108
|
+
socket.on('connect', () => {
|
|
109
|
+
socket.write(encodeFrame({ v: PROTOCOL_VERSION, kind: 'subscribe', sessionId, lane: LANE }));
|
|
110
|
+
if (!feed.off) report(`statusline-push sid=${JSON.stringify(sessionId)} subscribed`);
|
|
111
|
+
});
|
|
112
|
+
socket.on('data', frameReader(
|
|
113
|
+
(resp) => {
|
|
114
|
+
if (!mine()) return;
|
|
115
|
+
if (resp && resp.ok === true && typeof resp.output === 'string') {
|
|
116
|
+
const text = asStatusText(resp.output);
|
|
117
|
+
feed.state = { kind: 'live', socket, text };
|
|
118
|
+
feed.off = false;
|
|
119
|
+
feed.emit(text);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const code = resp && resp.code;
|
|
123
|
+
// A daemon that does not know the request, or speaks another version, will refuse it the
|
|
124
|
+
// same way next tick: stop asking for this session, and say why once.
|
|
125
|
+
if (code === 'BAD_REQUEST' || code === 'VERSION_MISMATCH') {
|
|
126
|
+
feed.state = { kind: 'refused', reason: `${code}: ${resp.error}` };
|
|
127
|
+
socket.destroy();
|
|
128
|
+
report(`statusline-push sid=${JSON.stringify(sessionId)} refused: ${code}: ${resp.error}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// The lane is switched off in the session's settings: the command's own answer shows, and
|
|
132
|
+
// the subscription is asked again after a wait. Said once, until a frame streams again.
|
|
133
|
+
if (code === 'SWITCHED_OFF') {
|
|
134
|
+
feed.state = { kind: 'off', at: now() };
|
|
135
|
+
socket.destroy();
|
|
136
|
+
if (!feed.off) report(`statusline-push sid=${JSON.stringify(sessionId)} switched off: ${resp.error}`);
|
|
137
|
+
feed.off = true;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// A frame the daemon could not draw: the command's own answer shows how it failed, as in a
|
|
141
|
+
// stock session, until a frame draws again.
|
|
142
|
+
feed.state = { kind: 'open', socket };
|
|
143
|
+
report(`statusline-push sid=${JSON.stringify(sessionId)} frame failed: ${code}: ${resp && resp.error}`);
|
|
144
|
+
},
|
|
145
|
+
(e) => end(`unreadable frame: ${e.message}`),
|
|
146
|
+
));
|
|
147
|
+
socket.on('error', (e) => end(e.message));
|
|
148
|
+
socket.on('close', () => end('closed by the daemon'));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// What the spliced statement calls each time the controller runs its command: the controller, the
|
|
152
|
+
// statusline JSON it built, its own emitter, and the run itself. Always a promise, so the splice's
|
|
153
|
+
// `||` never reaches the original call it leaves in place.
|
|
154
|
+
async function statusLine(controller, payload, emit, run) {
|
|
155
|
+
// `off`: the last refusal said the lane is switched off, so the retries it causes are not news.
|
|
156
|
+
const feed = feeds.get(controller) || { sessionId: null, state: { kind: 'closed' }, emit, off: false };
|
|
157
|
+
feeds.set(controller, feed);
|
|
158
|
+
feed.emit = emit;
|
|
159
|
+
// [LAW:dataflow-not-control-flow] the session the subscription is for is read off every payload,
|
|
160
|
+
// so a new session id (after /clear) moves it, and a closed one reopens on the next run.
|
|
161
|
+
const sessionId = payload.session_id;
|
|
162
|
+
const moved = feed.sessionId !== sessionId;
|
|
163
|
+
if (moved && feed.state.socket) {
|
|
164
|
+
const old = feed.state.socket;
|
|
165
|
+
feed.state = { kind: 'closed' };
|
|
166
|
+
old.destroy();
|
|
167
|
+
}
|
|
168
|
+
if (moved) feed.off = false;
|
|
169
|
+
const due = feed.state.kind === 'closed' ||
|
|
170
|
+
(feed.state.kind === 'off' && now() - feed.state.at >= SWITCHED_OFF_RETRY_MS);
|
|
171
|
+
if (moved || due) open(feed, sessionId);
|
|
172
|
+
const ran = await run();
|
|
173
|
+
return feed.state.kind === 'live' ? feed.state.text : ran;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { statusLine };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { LANE, PROTOCOL_VERSION, SWITCHED_OFF_RETRY_MS, asStatusText, createStatusLinePush, daemonSocketPath, encodeFrame, frameReader };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ws-unix.js — a WebSocket client over a unix socket, for the one peer this repo talks to that way:
|
|
2
|
+
// the inspector a Bun binary opens when `BUN_INSPECT=ws+unix://<path>` is set.
|
|
3
|
+
//
|
|
4
|
+
// WHY NOT node's WebSocket. Node's global WebSocket dials TCP only, and the inspector is deliberately
|
|
5
|
+
// NOT on TCP: anything that can reach it can run code in the user's session, and a unix socket in a
|
|
6
|
+
// 0700 directory is reachable by this user alone. So the client is the handshake (node's http client
|
|
7
|
+
// already speaks Upgrade over a socketPath) and RFC 6455 framing, which is all this file is.
|
|
8
|
+
//
|
|
9
|
+
// The framing is pure and exported, so the suite checks it against the RFC's own vectors without a
|
|
10
|
+
// server. [LAW:effects-at-boundaries]
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const http = require('http');
|
|
16
|
+
|
|
17
|
+
const OPCODES = { continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa };
|
|
18
|
+
|
|
19
|
+
// One client-to-server frame: FIN set, MASKED (RFC 6455 5.3 requires a client to mask every frame).
|
|
20
|
+
// `mask` is a parameter so the suite can pin it to the RFC's example key.
|
|
21
|
+
function encodeFrame(opcode, payload, mask = crypto.randomBytes(4)) {
|
|
22
|
+
const len = payload.length;
|
|
23
|
+
const lengthBytes = len < 126 ? 0 : len < 0x10000 ? 2 : 8;
|
|
24
|
+
const head = Buffer.alloc(2 + lengthBytes + 4);
|
|
25
|
+
head[0] = 0x80 | opcode;
|
|
26
|
+
head[1] = 0x80 | (lengthBytes === 0 ? len : lengthBytes === 2 ? 126 : 127);
|
|
27
|
+
if (lengthBytes === 2) head.writeUInt16BE(len, 2);
|
|
28
|
+
if (lengthBytes === 8) head.writeBigUInt64BE(BigInt(len), 2);
|
|
29
|
+
mask.copy(head, 2 + lengthBytes);
|
|
30
|
+
const body = Buffer.alloc(len);
|
|
31
|
+
for (let i = 0; i < len; i++) body[i] = payload[i] ^ mask[i % 4];
|
|
32
|
+
return Buffer.concat([head, body]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Reads server-to-client frames from a byte stream: returns a function to feed chunks, and calls
|
|
36
|
+
// `onFrame({ fin, opcode, payload })` for each complete frame. A server must not mask (RFC 6455 5.1);
|
|
37
|
+
// a masked one is a protocol error, reported through `onError` once, after which nothing more is read.
|
|
38
|
+
function frameReader(onFrame, onError) {
|
|
39
|
+
let buf = Buffer.alloc(0);
|
|
40
|
+
let broken = false;
|
|
41
|
+
return (chunk) => {
|
|
42
|
+
if (broken) return;
|
|
43
|
+
buf = Buffer.concat([buf, chunk]);
|
|
44
|
+
while (buf.length >= 2) {
|
|
45
|
+
const fin = (buf[0] & 0x80) !== 0;
|
|
46
|
+
const opcode = buf[0] & 0x0f;
|
|
47
|
+
if (buf[1] & 0x80) { broken = true; onError(new Error('server sent a masked frame')); return; }
|
|
48
|
+
const short = buf[1] & 0x7f;
|
|
49
|
+
const lengthBytes = short === 126 ? 2 : short === 127 ? 8 : 0;
|
|
50
|
+
if (buf.length < 2 + lengthBytes) return;
|
|
51
|
+
const len = lengthBytes === 2 ? buf.readUInt16BE(2) : lengthBytes === 8 ? Number(buf.readBigUInt64BE(2)) : short;
|
|
52
|
+
const start = 2 + lengthBytes;
|
|
53
|
+
if (buf.length < start + len) return;
|
|
54
|
+
onFrame({ fin, opcode, payload: buf.subarray(start, start + len) });
|
|
55
|
+
buf = buf.subarray(start + len);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Joins fragmented text messages and answers the control frames, handing each whole message to
|
|
61
|
+
// `onMessage(text)`. `write` sends raw bytes; `onClose` is told once, with why.
|
|
62
|
+
function messageReader({ onMessage, write, onClose }) {
|
|
63
|
+
let parts = [];
|
|
64
|
+
return (frame) => {
|
|
65
|
+
if (frame.opcode === OPCODES.ping) { write(encodeFrame(OPCODES.pong, frame.payload)); return; }
|
|
66
|
+
if (frame.opcode === OPCODES.pong) return;
|
|
67
|
+
if (frame.opcode === OPCODES.close) { onClose('closed by the peer'); return; }
|
|
68
|
+
parts.push(frame.payload);
|
|
69
|
+
if (!frame.fin) return;
|
|
70
|
+
const text = Buffer.concat(parts).toString('utf8');
|
|
71
|
+
parts = [];
|
|
72
|
+
onMessage(text);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Open a WebSocket to `path` on the server listening at `socketPath`. Resolves once the handshake is
|
|
77
|
+
// accepted with { send(text), close(why?), listen({ onMessage, onClose }) }, or rejects naming why it was
|
|
78
|
+
// not.
|
|
79
|
+
function connect(socketPath, path = '/') {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const key = crypto.randomBytes(16).toString('base64');
|
|
82
|
+
const req = http.request({
|
|
83
|
+
socketPath, path,
|
|
84
|
+
headers: { Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13' },
|
|
85
|
+
});
|
|
86
|
+
req.on('error', reject);
|
|
87
|
+
req.on('response', (res) => reject(new Error(`inspector refused the upgrade: HTTP ${res.statusCode}`)));
|
|
88
|
+
req.on('upgrade', (res, socket, head) => {
|
|
89
|
+
// The accept header proves the peer read OUR key, not a stale or proxied answer (RFC 6455 4.1).
|
|
90
|
+
const expected = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
91
|
+
if (res.headers['sec-websocket-accept'] !== expected) { socket.destroy(); reject(new Error('inspector answered the upgrade with the wrong accept key')); return; }
|
|
92
|
+
let listener = null;
|
|
93
|
+
let endedBecause = null;
|
|
94
|
+
const close = (why) => {
|
|
95
|
+
if (endedBecause !== null) return;
|
|
96
|
+
endedBecause = why;
|
|
97
|
+
socket.destroy();
|
|
98
|
+
if (listener) listener.onClose(why);
|
|
99
|
+
};
|
|
100
|
+
const feed = frameReader(
|
|
101
|
+
// An ended connection delivers nothing, a frame read after its close frame in the same chunk included.
|
|
102
|
+
messageReader({ onMessage: (text) => endedBecause === null && listener.onMessage(text), write: (bytes) => socket.write(bytes), onClose: close }),
|
|
103
|
+
(e) => close(e.message),
|
|
104
|
+
);
|
|
105
|
+
socket.on('error', (e) => close(e.message));
|
|
106
|
+
socket.on('close', () => close('socket closed'));
|
|
107
|
+
resolve({
|
|
108
|
+
send: (text) => socket.write(encodeFrame(OPCODES.text, Buffer.from(text, 'utf8'))),
|
|
109
|
+
close: (why = 'closed by this client') => close(why),
|
|
110
|
+
// Both handlers at once, and only then is anything read — the bytes that came with the upgrade
|
|
111
|
+
// first — so no message or close lands before there is someone to tell it to.
|
|
112
|
+
// [LAW:no-ambient-temporal-coupling] delivery starts at the one moment a listener exists.
|
|
113
|
+
listen: (handlers) => {
|
|
114
|
+
listener = handlers;
|
|
115
|
+
if (endedBecause !== null) { handlers.onClose(endedBecause); return; }
|
|
116
|
+
if (head.length) feed(head);
|
|
117
|
+
socket.on('data', feed);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
req.end();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { OPCODES, encodeFrame, frameReader, messageReader, connect };
|