@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,62 @@
|
|
|
1
|
+
// inspector-session.js — the request/response and event halves of the WebKit inspector protocol, over
|
|
2
|
+
// any transport that carries text messages (ws-unix.js in use; a fake in the suite).
|
|
3
|
+
//
|
|
4
|
+
// Every request is answered or rejected: by its own response, by the protocol error that answers it,
|
|
5
|
+
// or — when the transport closes first — by the close, so no caller waits on a peer that has gone.
|
|
6
|
+
// A message that cannot be handled — not JSON, a response to nothing asked, a listener that throws —
|
|
7
|
+
// is a `fault`: told to the session's `fault` listeners, then the session closes naming it, the one
|
|
8
|
+
// way every caller already hears of an end. Thrown from the transport's handler instead, it would kill
|
|
9
|
+
// the launcher and leave the TUI on a terminal the shell has taken back. A fault is its own event
|
|
10
|
+
// because a close alone cannot say whether the peer ended or this side broke. [LAW:no-silent-failure]
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
function createInspectorSession(transport) {
|
|
15
|
+
let nextId = 1;
|
|
16
|
+
const pending = new Map();
|
|
17
|
+
const listeners = new Map();
|
|
18
|
+
let closedBecause = null;
|
|
19
|
+
|
|
20
|
+
const dispatch = (text) => {
|
|
21
|
+
const msg = JSON.parse(text);
|
|
22
|
+
if (msg.id !== undefined) {
|
|
23
|
+
const waiter = pending.get(msg.id);
|
|
24
|
+
if (!waiter) throw new Error(`a response to request ${msg.id}, which nothing is waiting on`);
|
|
25
|
+
pending.delete(msg.id);
|
|
26
|
+
if (msg.error) waiter.reject(new Error(`${waiter.method}: ${msg.error.message}`));
|
|
27
|
+
else waiter.resolve(msg.result);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const fn of listeners.get(msg.method) || []) fn(msg.params);
|
|
31
|
+
};
|
|
32
|
+
const onMessage = (text) => {
|
|
33
|
+
try { dispatch(text); } catch (e) {
|
|
34
|
+
const why = `unhandled inspector message: ${e.message}`;
|
|
35
|
+
for (const fn of listeners.get('fault') || []) fn(why);
|
|
36
|
+
transport.close(why);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const onClose = (why) => {
|
|
41
|
+
closedBecause = why;
|
|
42
|
+
for (const waiter of pending.values()) waiter.reject(new Error(`${waiter.method}: inspector closed (${why})`));
|
|
43
|
+
pending.clear();
|
|
44
|
+
for (const fn of listeners.get('close') || []) fn(why);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
48
|
+
if (closedBecause !== null) { reject(new Error(`${method}: inspector closed (${closedBecause})`)); return; }
|
|
49
|
+
const id = nextId++;
|
|
50
|
+
pending.set(id, { method, resolve, reject });
|
|
51
|
+
transport.send(JSON.stringify({ id, method, params }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// `close` and `fault` are events of this session too: the transport ending, and a message this side
|
|
55
|
+
// could not handle, each with why.
|
|
56
|
+
const on = (method, fn) => { listeners.set(method, [...(listeners.get(method) || []), fn]); };
|
|
57
|
+
|
|
58
|
+
transport.listen({ onMessage, onClose });
|
|
59
|
+
return { send, on, close: () => transport.close() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { createInspectorSession };
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// launch.js — start a Claude Code session, hosted if the graph can be hosted and stock if it cannot.
|
|
3
|
+
//
|
|
4
|
+
// A user who opts into the hosted session must never end up with a broken one. So this launcher does
|
|
5
|
+
// not decide between two code paths; it runs a LIST OF PLANS in order and keeps the first that boots.
|
|
6
|
+
// Every plan is the same operation — spawn this argv, watch the boot channel — and they differ only
|
|
7
|
+
// in their data, which is what keeps the fail-safe from being a special case that skips setup.
|
|
8
|
+
// [LAW:dataflow-not-control-flow] Adding a plan is adding a value.
|
|
9
|
+
//
|
|
10
|
+
// The last plan is stock `claude`, which is declared booted the moment it starts: it is the floor,
|
|
11
|
+
// not a candidate, and there is nothing after it to fall back to.
|
|
12
|
+
//
|
|
13
|
+
// WHY THE VERDICT IS FORMED HERE. bun-host.mjs reports only what it can see — `painted` the first
|
|
14
|
+
// time the hosted graph writes to the terminal, or a named refusal — and never decides. The failures
|
|
15
|
+
// this guards against are not visible from inside: a HANG, where a boot-critical Bun API returns
|
|
16
|
+
// undefined and the first render throws a TypeError the app swallows, so nothing further happens at
|
|
17
|
+
// all; and its louder cousin, where the app prints a notice and then dies on that same TypeError.
|
|
18
|
+
// Neither is distinguishable from inside the host — one needs a clock and the other needs to watch
|
|
19
|
+
// the process afterwards — so both belong to whoever can act on them.
|
|
20
|
+
// [LAW:no-ambient-temporal-coupling] one owner for the timing, named here.
|
|
21
|
+
//
|
|
22
|
+
// Once a plan has become the session it OWNS it: a later crash is that session's business, and is
|
|
23
|
+
// never answered by starting a second session on top of whatever the first one already did.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const childProcess = require('child_process');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { StringDecoder } = require('string_decoder');
|
|
30
|
+
const { readReport, RECORD_SEPARATOR, BOOT_FD_ENV } = require('./boot-channel.js');
|
|
31
|
+
const { constants: { signals: SIGNAL_NUMBERS } } = require('os');
|
|
32
|
+
|
|
33
|
+
// Measured on 2.1.258: ~800ms to link 1,640 modules and ~700ms more to the first frame. The deadline
|
|
34
|
+
// is an order of magnitude of headroom over that, because the cost of waiting too long is a slow
|
|
35
|
+
// start and the cost of waiting too little is falling back on a session that was about to work.
|
|
36
|
+
const BOOT_DEADLINE_MS = 15000;
|
|
37
|
+
|
|
38
|
+
// How long a plan must survive its first paint before it counts as the session. Long enough that the
|
|
39
|
+
// app's own fatal-error path (which prints, then exits) lands inside it, short enough to be invisible.
|
|
40
|
+
const BOOT_SETTLE_MS = 5000;
|
|
41
|
+
|
|
42
|
+
const BOOT_FD = 3;
|
|
43
|
+
const HOST = path.join(__dirname, 'bun-host.mjs');
|
|
44
|
+
|
|
45
|
+
// Sent before the next plan takes the terminal, never after a boot. The escape sequence puts the
|
|
46
|
+
// screen back; raw mode is not a screen state and has to be cleared through the tty itself, which
|
|
47
|
+
// `resetTerminal` below does — the sequence alone would leave the keyboard in the dead plan's mode.
|
|
48
|
+
const TERMINAL_RESET = '\u001b[?1049l\u001b[?25h\u001b[?2004l\u001b[0m';
|
|
49
|
+
|
|
50
|
+
// Put the terminal back the way a plan that died mid-render did not. Raw mode is the half an escape
|
|
51
|
+
// sequence cannot reach: a dead child's stdin mode outlives it, and the next plan would inherit a
|
|
52
|
+
// keyboard that echoes nothing.
|
|
53
|
+
function resetTerminal() {
|
|
54
|
+
if (!process.stdout.isTTY) return;
|
|
55
|
+
process.stdout.write(TERMINAL_RESET);
|
|
56
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A TUI needs BOTH ends: a keyboard to read and a screen to draw on.
|
|
60
|
+
const attachedToTerminal = (streams = process) => Boolean(streams.stdin.isTTY && streams.stdout.isTTY);
|
|
61
|
+
|
|
62
|
+
// Whether the kernel will deliver a terminal's signals to this process's whole group — which is what
|
|
63
|
+
// decides, below, whether the child already has its own copy. What this actually tests is narrower
|
|
64
|
+
// than that question: whether ANY of the three standard descriptors still points at a tty. Keying on
|
|
65
|
+
// stdin alone answered "no terminal" for `claude -p "..." < prompt.txt`, which is still in the
|
|
66
|
+
// foreground group, so the launcher forwarded a SIGINT the kernel had already delivered — and
|
|
67
|
+
// widening it to all three is what closed that case.
|
|
68
|
+
//
|
|
69
|
+
// [LAW:single-enforcer] exception: the real question is "does this process's group own the terminal",
|
|
70
|
+
// and node exposes no `tcgetpgrp` to ask it — nor, underneath, any way to tell a SIGINT the terminal
|
|
71
|
+
// raised for the whole group from one addressed to this pid alone, which would need the sender from
|
|
72
|
+
// `siginfo` that node never surfaces. So this boundary approximates, and two gaps stay open:
|
|
73
|
+
// - all three descriptors redirected while still in the foreground group (`claude -p "..." < in
|
|
74
|
+
// > out 2> err`) reads as "no terminal" and forwards a second SIGINT. No tty means no TUI, and
|
|
75
|
+
// print mode is already dying of the first one, so the duplicate lands on a process on its way
|
|
76
|
+
// out rather than becoming the second press that quits a session.
|
|
77
|
+
// - a backgrounded job keeps its descriptors on the tty and reads as "shared", so nothing is
|
|
78
|
+
// forwarded, while the kernel delivers terminal signals only to the foreground group. The
|
|
79
|
+
// shell's own `kill -INT %1` addresses the job's process GROUP, which contains the child, so it
|
|
80
|
+
// still arrives; only a signal aimed at this launcher's pid alone is lost.
|
|
81
|
+
// It errs toward holding on purpose. A duplicate Ctrl+C inside a TUI is the two presses that make
|
|
82
|
+
// Claude Code exit — visible and unrecoverable — where a signal not forwarded leaves the child
|
|
83
|
+
// running and the user free to press again.
|
|
84
|
+
const hasControllingTerminal = (streams = process) =>
|
|
85
|
+
Boolean(streams.stdin.isTTY || streams.stdout.isTTY || streams.stderr.isTTY);
|
|
86
|
+
|
|
87
|
+
// What makes an invocation something other than a TUI. Print mode renders no frame and
|
|
88
|
+
// can legitimately think for minutes before its first byte of stdout, which is exactly the silence
|
|
89
|
+
// the deadline below reads as a hang. Nearly every other one-shot — a version, a help — prints within
|
|
90
|
+
// moments and exits 0, and an exit of 0 is already a finished command to the verdict, so print mode
|
|
91
|
+
// earns a table of one rather than a list of subcommands to chase.
|
|
92
|
+
// `claude mcp serve` is the known exception: a stdio server that paints nothing and stays up until a
|
|
93
|
+
// client speaks. Typed at a terminal it is read as a TUI owing a first frame, and the deadline kills
|
|
94
|
+
// it — into the fallback, which runs the same command under stock claude and works. Its ordinary
|
|
95
|
+
// invocation is a client handing it a pipe, where there is no tty and `tui` is already false. A row
|
|
96
|
+
// for it would buy a mode [LAW:no-mode-explosion] to save a restart the fail-safe already covers.
|
|
97
|
+
// The short flags the CLI declares as plain booleans, which is what decides how far a CLUSTER is
|
|
98
|
+
// read. Commander splits `-cp` into `-c -p`, so it is a real print-mode invocation, while `-d`,
|
|
99
|
+
// `-n`, `-r` and `-w` each take a value and swallow the rest of their cluster — the `p` in `-np` is
|
|
100
|
+
// the session NAME "p" and not print at all, and reading it as print would retire the hang check for
|
|
101
|
+
// a run that really does render a TUI. Measured against 2.1.280: `claude -cx` answers
|
|
102
|
+
// `unknown option '-x'` (the cluster was split), where `claude -nx` takes `x` as the name and
|
|
103
|
+
// `claude -zp` answers `unknown option '-zp'` (an unknown first letter is never split).
|
|
104
|
+
// [LAW:one-source-of-truth] this mirrors a grammar this repo does not own, so it stops at the first
|
|
105
|
+
// letter it does not recognise rather than guessing past it.
|
|
106
|
+
const BOOLEAN_SHORT_FLAGS = new Set(['c', 'h', 'p', 'v']);
|
|
107
|
+
|
|
108
|
+
// Does this one argument ask for print mode? A whole-argument comparison answers no for `-cp`, and
|
|
109
|
+
// that gap is the double execution below arriving by a spelling: the run is print mode, is judged a
|
|
110
|
+
// TUI, owes a first frame it will never paint, and is killed at the deadline for the floor to run
|
|
111
|
+
// again.
|
|
112
|
+
const requestsPrint = (arg) => {
|
|
113
|
+
if (arg === '--print') return true;
|
|
114
|
+
if (arg.startsWith('--') || !arg.startsWith('-') || arg === '-') return false;
|
|
115
|
+
for (const letter of arg.slice(1)) {
|
|
116
|
+
if (letter === 'p') return true;
|
|
117
|
+
if (!BOOLEAN_SHORT_FLAGS.has(letter)) return false;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Does this invocation render a TUI? Two questions in this launcher turn on the answer, and both
|
|
123
|
+
// used to be answered with "is stdout a terminal" — the same answer for `claude` and for
|
|
124
|
+
// `claude -p "commit this"` typed at the same prompt. It is the wrong answer for the second: print
|
|
125
|
+
// mode owes no first frame, so the deadline killed it at 15s and the floor then ran the prompt a
|
|
126
|
+
// SECOND time, repeating every edit and commit the killed run had already made.
|
|
127
|
+
// [LAW:one-source-of-truth] one fact about the invocation, parsed once, read in both places.
|
|
128
|
+
const rendersTui = (userArgs, hasTerminal) => {
|
|
129
|
+
// Everything after a bare `--` is positional, so a `-p` standing there is the user's own text.
|
|
130
|
+
const positional = userArgs.indexOf('--');
|
|
131
|
+
const flags = positional === -1 ? userArgs : userArgs.slice(0, positional);
|
|
132
|
+
return hasTerminal && !flags.some(requestsPrint);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Signals the terminal generates are delivered by the kernel to the whole foreground process group,
|
|
136
|
+
// and the child is in this launcher's group: it already has its own copy. Forwarding a second one
|
|
137
|
+
// would make a single Ctrl+C read as the two presses that make Claude Code exit. A signal addressed
|
|
138
|
+
// to this process alone never reached the child, and passing it on is the only way it hears.
|
|
139
|
+
// [LAW:single-enforcer] the kernel delivers where it delivers; the launcher delivers only where it
|
|
140
|
+
// does not.
|
|
141
|
+
const TERMINAL_SIGNALS = ['SIGINT', 'SIGQUIT', 'SIGHUP'];
|
|
142
|
+
const ADDRESSED_SIGNALS = ['SIGTERM'];
|
|
143
|
+
const SUPERVISED_SIGNALS = [...TERMINAL_SIGNALS, ...ADDRESSED_SIGNALS];
|
|
144
|
+
|
|
145
|
+
// Stand between the signal and the child for as long as the child is the session. Without this the
|
|
146
|
+
// launcher took node's default disposition and simply died on the first Ctrl+C, leaving the session
|
|
147
|
+
// running on a terminal the shell had already taken back — a live TUI drawing into a fresh prompt,
|
|
148
|
+
// and a wrapper exit code that was the launcher's death rather than the session's.
|
|
149
|
+
//
|
|
150
|
+
// `onSignal` is told about every supervised signal whether or not it was forwarded, because the fact
|
|
151
|
+
// the verdict needs is that the USER ended this session, not which process delivered the signal.
|
|
152
|
+
// [LAW:dataflow-not-control-flow] holding and forwarding are one operation over a different list of
|
|
153
|
+
// recipients, never two code paths.
|
|
154
|
+
function superviseSignals(child, { sharesTerminalGroup, onSignal, on, off }) {
|
|
155
|
+
const recipients = (signal) => (sharesTerminalGroup && TERMINAL_SIGNALS.includes(signal) ? [] : [child]);
|
|
156
|
+
const installed = SUPERVISED_SIGNALS.map((signal) => [signal, () => {
|
|
157
|
+
onSignal(signal);
|
|
158
|
+
for (const target of recipients(signal)) target.kill(signal);
|
|
159
|
+
}]);
|
|
160
|
+
for (const [signal, handler] of installed) on(signal, handler);
|
|
161
|
+
// The undo, because the next plan gets its own child: a handler still aimed at the dead one would
|
|
162
|
+
// swallow the user's Ctrl+C into nothing.
|
|
163
|
+
return () => { for (const [signal, handler] of installed) off(signal, handler); };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A child's ending as this wrapper's exit code: its own code, or the conventional encoding for "killed
|
|
167
|
+
// by a signal", so a SIGKILL and a SIGTERM stay distinguishable to whatever reads it.
|
|
168
|
+
const exitStatus = (code, signal) => (code === null ? 128 + (SIGNAL_NUMBERS[signal] ?? 0) : code);
|
|
169
|
+
|
|
170
|
+
// The plans, in order. `reports` says whether a plan speaks on the boot channel: the hosted one does,
|
|
171
|
+
// and stock claude is the floor — it is the session by definition and there is nothing after it.
|
|
172
|
+
function plans(binaryPath, userArgs) {
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
name: 'hosted',
|
|
176
|
+
command: process.execPath,
|
|
177
|
+
// --experimental-vm-modules: the host links Bun's module graph itself (bun-host.mjs explains
|
|
178
|
+
// why node's own loader cannot). The warning is silenced because this process shares the
|
|
179
|
+
// user's terminal with a TUI.
|
|
180
|
+
args: ['--experimental-vm-modules', '--disable-warning=ExperimentalWarning', HOST, binaryPath, ...userArgs],
|
|
181
|
+
env: { ...process.env, [BOOT_FD_ENV]: String(BOOT_FD) },
|
|
182
|
+
reports: true,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'stock',
|
|
186
|
+
command: binaryPath,
|
|
187
|
+
args: userArgs,
|
|
188
|
+
env: process.env,
|
|
189
|
+
reports: false,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Run one plan to a verdict. Resolves { booted: true, code } once the plan became the session and
|
|
195
|
+
// that session has finished, or { booted: false, reason } if it never became one.
|
|
196
|
+
//
|
|
197
|
+
// A plan that reports a NAMED refusal without painting never reached the user's command, so it is
|
|
198
|
+
// always replaced. Past that, a plan became the session when it painted the terminal AND was still
|
|
199
|
+
// there a moment later. Painting alone is not enough — a boot-critical API that returns undefined lets the app print a notice and
|
|
200
|
+
// then die on the TypeError, which reads as output but is not a session. Two exits are booted
|
|
201
|
+
// whatever else happened, and neither may ever be re-run: a clean exit, which is what a one-shot
|
|
202
|
+
// command does (silently, if all its output went to stderr), and any exit at all from an invocation
|
|
203
|
+
// that renders no TUI, where the run may already have done real work.
|
|
204
|
+
//
|
|
205
|
+
// The child is given the real terminal on stdio 0-2 — a pipe would make isTTY false and there would
|
|
206
|
+
// be no TUI to check — so its reports travel on a descriptor of their own.
|
|
207
|
+
function run(plan, {
|
|
208
|
+
deadlineMs = BOOT_DEADLINE_MS,
|
|
209
|
+
settleMs = BOOT_SETTLE_MS,
|
|
210
|
+
tui,
|
|
211
|
+
spawn = childProcess.spawn,
|
|
212
|
+
signals = process,
|
|
213
|
+
sharesTerminalGroup = hasControllingTerminal(),
|
|
214
|
+
} = {}) {
|
|
215
|
+
// Whether this invocation renders a TUI is the INVOCATION's fact, parsed once by the caller and
|
|
216
|
+
// handed down. There is no defensible default for it here: guessing false retires the hang check,
|
|
217
|
+
// and guessing true kills one-shots that were working. [LAW:no-silent-failure]
|
|
218
|
+
if (typeof tui !== 'boolean') throw new TypeError('run needs tui: whether this invocation renders a TUI');
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
// The array index IS the descriptor number, so BOOT_FD owns its own position instead of being a
|
|
221
|
+
// name for a 3 that is also written out positionally beside it. [LAW:one-source-of-truth]
|
|
222
|
+
const stdio = Array.from({ length: BOOT_FD + 1 }, (_, fd) => (fd < 3 ? 'inherit' : 'ignore'));
|
|
223
|
+
stdio[BOOT_FD] = plan.reports ? 'pipe' : 'ignore';
|
|
224
|
+
const child = spawn(plan.command, plan.args, { stdio, env: plan.env });
|
|
225
|
+
|
|
226
|
+
// Which signal the user ended this session with, if any. Tracked because a session the user
|
|
227
|
+
// stopped is not a plan that failed to start, and the difference is invisible from the exit
|
|
228
|
+
// status alone: both arrive as "killed by SIGINT before painting".
|
|
229
|
+
let interrupted = null;
|
|
230
|
+
const stopSupervising = superviseSignals(child, {
|
|
231
|
+
sharesTerminalGroup,
|
|
232
|
+
onSignal: (signal) => { interrupted = interrupted || signal; },
|
|
233
|
+
on: (signal, handler) => signals.on(signal, handler),
|
|
234
|
+
off: (signal, handler) => signals.off(signal, handler),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let paintedAt = 0;
|
|
238
|
+
// Whether the hosted graph ever handed control to the app. Reported, not inferred: before it,
|
|
239
|
+
// nothing the user asked for has run and a failure is always safe to replace; after it, the app
|
|
240
|
+
// owns whatever it did and re-running would repeat it.
|
|
241
|
+
let started = !plan.reports;
|
|
242
|
+
let refusal = '';
|
|
243
|
+
const absentApis = [];
|
|
244
|
+
let timer = null;
|
|
245
|
+
// The verdict needs both halves: how the process ended, and everything it reported. 'exit' fires
|
|
246
|
+
// when the OS reaps the child, which can be BEFORE the last chunk buffered on the boot channel
|
|
247
|
+
// has been delivered — so a plan that paints and immediately exits would read as never having
|
|
248
|
+
// painted, and get re-run. [LAW:no-ambient-temporal-coupling] the verdict waits on both facts
|
|
249
|
+
// rather than on which event happened to land first.
|
|
250
|
+
let ended = null;
|
|
251
|
+
let channelDone = !plan.reports;
|
|
252
|
+
// Set when the deadline fired. The plan is killed then, but the verdict is not delivered until
|
|
253
|
+
// the process is actually gone: handing the terminal to the next plan while the old one still
|
|
254
|
+
// holds it is two sessions writing to one screen.
|
|
255
|
+
let deadlineReason = null;
|
|
256
|
+
const settle = () => {
|
|
257
|
+
if (!ended || !channelDone) return;
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
stopSupervising();
|
|
260
|
+
resolve(verdict(ended));
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Whatever went wrong, say which Bun APIs this shim did not have. On a hang that list is the
|
|
264
|
+
// whole diagnosis, and it is the one thing the killed host can no longer tell anyone itself.
|
|
265
|
+
const because = (reason) => reason + (absentApis.length ? ` (Bun APIs the shim does not have: ${absentApis.join(', ')})` : '');
|
|
266
|
+
|
|
267
|
+
// How an ended plan is judged. The floor has no successor, so there is no decision to make: it
|
|
268
|
+
// IS the session, whatever it did. Every rule after it answers one question — should the next
|
|
269
|
+
// plan be tried instead? — and the exits that are never re-tried come FIRST, because they are
|
|
270
|
+
// the ones where re-trying would repeat work the user already got: a clean exit is a one-shot
|
|
271
|
+
// command finishing (silently, if all its output went to stderr), and where no TUI was ever
|
|
272
|
+
// rendered there is no session to salvage, only a command that already ran.
|
|
273
|
+
const verdict = ({ code, signal, at }) => {
|
|
274
|
+
const exit = exitStatus(code, signal);
|
|
275
|
+
if (!plan.reports) return { booted: true, code: exit };
|
|
276
|
+
// The user ending this session is not a plan failing to start, and it comes first because
|
|
277
|
+
// every rule below it asks whether to try the next plan instead — a question a Ctrl+C has
|
|
278
|
+
// already answered. Without this, the interrupt that cancelled a hosted boot would be met by
|
|
279
|
+
// starting a stock session on top of the one the user had just stopped.
|
|
280
|
+
if (interrupted) return { booted: true, code: exit };
|
|
281
|
+
// The deadline fired and this plan was killed for it. It is a rule among the others rather
|
|
282
|
+
// than an exit formed above them, because an exit formed outside `verdict` cannot be outranked
|
|
283
|
+
// by anything written inside it — and the interrupt above is exactly the rule that has to
|
|
284
|
+
// outrank it. A user who gave up on a hang and pressed Ctrl+C was answered by a whole new
|
|
285
|
+
// stock session ten seconds later, started on top of the one they had just stopped.
|
|
286
|
+
// [LAW:single-enforcer] one place forms the verdict, so the order written here is the order
|
|
287
|
+
// that runs.
|
|
288
|
+
if (deadlineReason) return { booted: false, reason: deadlineReason };
|
|
289
|
+
// The app never ran, so there is nothing to preserve and nothing to weigh — replace it,
|
|
290
|
+
// terminal or no terminal. Inferring this from "did stdout see a byte" is what let a host that
|
|
291
|
+
// crashed before it could report anything be treated as a finished command in a pipe.
|
|
292
|
+
if (!started) return { booted: false, reason: because(refusal || `exited ${signal || code} before the app started`) };
|
|
293
|
+
// Past `started` and outside a TUI the command RAN, and what it printed, edited or committed
|
|
294
|
+
// is done. It comes before the refusal rule because a refusal names how a run ended, never
|
|
295
|
+
// whether the app owned work — and `paintedAt` answers that question only by inference from
|
|
296
|
+
// what stdout happened to see, which is the inference `started` was reported to replace.
|
|
297
|
+
// Below the refusal rule, print mode's silent tool-using run (nothing on stdout until the
|
|
298
|
+
// answer) was re-run, repeating every edit and commit — the exact double execution `rendersTui`
|
|
299
|
+
// exists to prevent, reintroduced by the ORDER rather than by the parse.
|
|
300
|
+
// [LAW:one-source-of-truth] one fact about whether the app owned work, reported once.
|
|
301
|
+
if (!tui) return { booted: true, code: exit };
|
|
302
|
+
// A TUI that started, refused, and never drew a frame: nothing was ever put in front of the
|
|
303
|
+
// user, so the named refusal is the better answer than the exit status. It earns its place
|
|
304
|
+
// above the clean exit for the one case that rule would otherwise pass as success — a host
|
|
305
|
+
// that refuses and still manages to exit 0, which switched the fail-safe off silently.
|
|
306
|
+
if (refusal && !paintedAt) return { booted: false, reason: because(refusal) };
|
|
307
|
+
if (exit === 0) return { booted: true, code: exit };
|
|
308
|
+
if (!paintedAt) return { booted: false, reason: because(`exited ${signal || code} before painting anything`) };
|
|
309
|
+
// Clamped: a paint report can arrive AFTER the exit, because the pipe outlives the process.
|
|
310
|
+
// The paint really did happen first, so the honest reading of that ordering is a session that
|
|
311
|
+
// lasted no time at all — not a negative one.
|
|
312
|
+
const lived = Math.max(0, at - paintedAt);
|
|
313
|
+
if (lived >= settleMs) return { booted: true, code: exit };
|
|
314
|
+
const died = refusal ? `: ${refusal}` : '';
|
|
315
|
+
return { booted: false, reason: because(`painted, then exited ${exit} after ${lived}ms — never became a session${died}`) };
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (plan.reports) {
|
|
319
|
+
timer = setTimeout(() => {
|
|
320
|
+
// The reason is fixed HERE, before the kill, so the exit it causes cannot answer for the
|
|
321
|
+
// deadline that caused it. [LAW:no-ambient-temporal-coupling]
|
|
322
|
+
deadlineReason = because(`nothing painted within ${deadlineMs}ms`);
|
|
323
|
+
child.kill('SIGKILL');
|
|
324
|
+
}, deadlineMs);
|
|
325
|
+
const channel = child.stdio[BOOT_FD];
|
|
326
|
+
// Decoded across chunk boundaries: a multi-byte character split between two reads would
|
|
327
|
+
// otherwise become two replacement characters.
|
|
328
|
+
const decoder = new StringDecoder('utf8');
|
|
329
|
+
let pending = '';
|
|
330
|
+
const consume = (text) => {
|
|
331
|
+
pending += text;
|
|
332
|
+
const lines = pending.split(RECORD_SEPARATOR);
|
|
333
|
+
pending = lines.pop();
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
// What a line MEANS is the protocol's business, not this loop's. [LAW:one-source-of-truth]
|
|
336
|
+
const report = readReport(line);
|
|
337
|
+
// Once the app is running, only a TUI still owes a first frame: it paints within a second
|
|
338
|
+
// and a half, so silence past the deadline is the hang this check exists for. Print mode
|
|
339
|
+
// owes no frame — it can legitimately think for minutes before writing a byte — and
|
|
340
|
+
// killing it at the deadline would end work that was going fine, then re-run it.
|
|
341
|
+
if (report.kind === 'started') { started = true; if (!tui) clearTimeout(timer); }
|
|
342
|
+
else if (report.kind === 'painted') { paintedAt = paintedAt || Date.now(); clearTimeout(timer); }
|
|
343
|
+
else if (report.kind === 'absent-api') absentApis.push(report.name);
|
|
344
|
+
else if (report.kind === 'refusal') refusal = refusal || report.reason; // the FIRST: the root cause
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
// The last report a dying host writes is the one most worth having, and it is the one most
|
|
348
|
+
// likely to arrive without its separator — a SIGKILL at the deadline, or a partial flush on
|
|
349
|
+
// the way down. Flushing the decoder and treating what is left as a final record is what keeps
|
|
350
|
+
// that reason from being replaced by the launcher's own poorer guess. A second flush after the
|
|
351
|
+
// first reads an empty line, which the protocol already answers as `blank`.
|
|
352
|
+
// [LAW:no-silent-failure]
|
|
353
|
+
const drain = () => consume(decoder.end() + RECORD_SEPARATOR);
|
|
354
|
+
const finish = () => { channelDone = true; settle(); };
|
|
355
|
+
channel.on('data', (chunk) => consume(decoder.write(chunk)));
|
|
356
|
+
// A boot channel that fails to read is a lost diagnosis, not a non-event: without this the
|
|
357
|
+
// failure reason would silently be the poorer one. The drain comes first so a refusal the host
|
|
358
|
+
// did manage to write outranks this reader's account of its own trouble.
|
|
359
|
+
// [LAW:no-silent-failure]
|
|
360
|
+
channel.on('error', (e) => { drain(); refusal = refusal || `boot channel unreadable: ${e.message}`; finish(); });
|
|
361
|
+
channel.on('end', () => { drain(); finish(); });
|
|
362
|
+
channel.on('close', () => { drain(); finish(); });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Every way out of this promise detaches the handlers, this one included: a plan that never
|
|
366
|
+
// started still installed them, and one left aimed at a child that does not exist would swallow
|
|
367
|
+
// the next plan's Ctrl+C.
|
|
368
|
+
child.on('error', (e) => { clearTimeout(timer); stopSupervising(); resolve({ booted: false, reason: `could not start ${plan.command}: ${e.message}` }); });
|
|
369
|
+
// The instant of the exit, not the instant the verdict is formed: settle() also waits on the
|
|
370
|
+
// boot channel closing, and a slow close would otherwise be counted as time the session lived.
|
|
371
|
+
child.on('exit', (code, signal) => { ended = { code, signal, at: Date.now() }; settle(); });
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Run the plans in order and return the exit code of the one that booted.
|
|
376
|
+
async function launch(binaryPath, userArgs, io = {}) {
|
|
377
|
+
const log = io.log || ((line) => process.stderr.write(line + '\n'));
|
|
378
|
+
const reset = io.reset || resetTerminal;
|
|
379
|
+
const runPlan = io.run || run;
|
|
380
|
+
|
|
381
|
+
// The invocation is parsed into its one fact HERE, at the entry point, and carried down as a
|
|
382
|
+
// value — so nothing downstream re-derives it from whatever it happens to be able to see.
|
|
383
|
+
// [LAW:parse-dont-validate]
|
|
384
|
+
const tui = rendersTui(userArgs, io.terminal ?? attachedToTerminal());
|
|
385
|
+
|
|
386
|
+
const candidates = io.plans || plans(binaryPath, userArgs);
|
|
387
|
+
for (const plan of candidates) {
|
|
388
|
+
const result = await runPlan(plan, { ...io, tui });
|
|
389
|
+
if (result.booted) return result.code;
|
|
390
|
+
// The terminal goes back BEFORE the reason is written into it. TERMINAL_RESET leads with the
|
|
391
|
+
// escape that DISCARDS the alternate screen buffer, so a line logged first lands in the buffer
|
|
392
|
+
// the next call throws away — and the failure that most needs explaining is exactly the one
|
|
393
|
+
// where a plan painted a TUI and then died. [LAW:no-silent-failure] the degrade is never quiet:
|
|
394
|
+
// the next session is not the one the user asked for, and the reason has to be findable.
|
|
395
|
+
reset();
|
|
396
|
+
log(`cc-extra: ${plan.name} session did not start — ${result.reason}; falling back`);
|
|
397
|
+
}
|
|
398
|
+
// Unreachable while the last plan is the floor; if the list ever ends without one, say so loudly
|
|
399
|
+
// rather than exiting 0 on a session that never ran.
|
|
400
|
+
log('cc-extra: no plan produced a session');
|
|
401
|
+
return 70;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
module.exports = {
|
|
405
|
+
BOOT_DEADLINE_MS, BOOT_SETTLE_MS, BOOT_FD, TERMINAL_RESET,
|
|
406
|
+
TERMINAL_SIGNALS, ADDRESSED_SIGNALS, SUPERVISED_SIGNALS,
|
|
407
|
+
resetTerminal, attachedToTerminal, hasControllingTerminal, rendersTui, superviseSignals, exitStatus, plans, run, launch,
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
if (require.main === module) (async function main() {
|
|
411
|
+
const [binaryPath, ...userArgs] = process.argv.slice(2);
|
|
412
|
+
if (!binaryPath) {
|
|
413
|
+
process.stderr.write('usage: launch.js <installed-claude-binary> [claude args...]\n');
|
|
414
|
+
process.exit(2);
|
|
415
|
+
}
|
|
416
|
+
process.exitCode = await launch(binaryPath, userArgs);
|
|
417
|
+
})();
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// seam-plan.js — resolve each declared seam to exactly one splice point in the graph, or refuse.
|
|
2
|
+
//
|
|
3
|
+
// WHY A SPLICE AND NOT A DEBUGGER. The live conversation lives on an object the graph never exports,
|
|
4
|
+
// so reaching it means either pausing the process on a call frame that happens to be inside that
|
|
5
|
+
// object's class, or having the object announce itself when it is built. The second needs no
|
|
6
|
+
// inspector, no breakpoint and no paused process, and it is a discrete event the host owns rather
|
|
7
|
+
// than a listener the session's later timing depends on. [LAW:no-ambient-temporal-coupling]
|
|
8
|
+
//
|
|
9
|
+
// WHY RESOLUTION IS EAGER AND GLOBAL. A seam is only trustworthy if it matches ONCE — in one module,
|
|
10
|
+
// at one offset. That is not knowable while transforming module-by-module on demand, so the whole
|
|
11
|
+
// graph is resolved up front, before anything is compiled. Zero matches and several matches are
|
|
12
|
+
// different facts from one match, and both are refusals: a seam that resolved to the wrong place is
|
|
13
|
+
// worse than one that did not resolve, because the first still runs. [LAW:parse-dont-validate] what
|
|
14
|
+
// comes back is a PROVEN plan or a typed absence, so the runtime never re-asks whether a seam landed.
|
|
15
|
+
//
|
|
16
|
+
// WHY NO MODULE NAME APPEARS IN A SEAM. Chunk names are content hashes that change every release, so
|
|
17
|
+
// naming one would be the version->seam table the epic exists to avoid. A seam names only a fragment
|
|
18
|
+
// of the app's own source, and which module carries it is an ANSWER, not an input.
|
|
19
|
+
//
|
|
20
|
+
// [LAW:effects-at-boundaries] pure throughout: sources in, plan out. No binary, no disk, no process.
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
// Every way a seam can fail to resolve, named. The launcher logs the reason it fell back to stock
|
|
25
|
+
// claude, so these are contract, not debug strings. [LAW:no-silent-failure]
|
|
26
|
+
const UNRESOLVED = {
|
|
27
|
+
noModule: 'no-module-carries-the-seam',
|
|
28
|
+
severalModules: 'several-modules-carry-the-seam',
|
|
29
|
+
severalSites: 'seam-matches-more-than-once-in-its-module',
|
|
30
|
+
absentCapture: 'seam-insert-names-a-capture-its-anchor-did-not-make',
|
|
31
|
+
noBreakpointForm: 'no-seam-declares-a-breakpoint-form',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const unresolved = (reason, seam, detail) => ({ ok: false, reason, seam: seam.name, detail });
|
|
35
|
+
|
|
36
|
+
// Every site in `text` the seam's anchor matches: its offset, and the named groups it captured. The declared anchor is deliberately NOT global:
|
|
37
|
+
// a global regex carries `lastIndex` between calls, which is shared mutable state that would make
|
|
38
|
+
// the second scan of a module disagree with the first. [LAW:no-shared-mutable-globals]
|
|
39
|
+
function matchesIn(text, anchor) {
|
|
40
|
+
// `g` and `y` are stripped before `g` is added: a seam declared /.../g would otherwise build
|
|
41
|
+
// 'gg' and throw a SyntaxError, which escapes this module's named refusals entirely and takes the
|
|
42
|
+
// host down instead of falling back to stock claude — the one outcome eager resolution exists to
|
|
43
|
+
// prevent. [LAW:no-silent-failure]
|
|
44
|
+
const scan = new RegExp(anchor.source, anchor.flags.replace(/[gy]/g, '') + 'g');
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const m of text.matchAll(scan)) out.push({ index: m.index, groups: m.groups || {} });
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The offsets alone.
|
|
51
|
+
const sitesIn = (text, anchor) => matchesIn(text, anchor).map((m) => m.index);
|
|
52
|
+
|
|
53
|
+
// A seam's code — the statement a splice inserts, or the expression a breakpoint evaluates — with each
|
|
54
|
+
// `$<name>` replaced by what the anchor captured there. A seam that must call into the class it
|
|
55
|
+
// reaches — a private method, a local the minifier named — names that piece in its anchor and refers
|
|
56
|
+
// to it here, so no minified name is ever written into seams.js: it is read off the release in hand.
|
|
57
|
+
// A reference to a group the anchor did not capture is a refusal, never code with the literal
|
|
58
|
+
// `$<name>` left in it, which would compile and throw at its first run. [LAW:no-silent-failure]
|
|
59
|
+
function fillCaptures(template, groups) {
|
|
60
|
+
let missing = null;
|
|
61
|
+
const text = template.replace(/\$<([A-Za-z_$][\w$]*)>/g, (ref, name) => {
|
|
62
|
+
if (typeof groups[name] === 'string') return groups[name];
|
|
63
|
+
missing = missing || name;
|
|
64
|
+
return ref;
|
|
65
|
+
});
|
|
66
|
+
return missing === null ? { ok: true, text } : { ok: false, missing };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Resolve one seam against the whole graph.
|
|
70
|
+
// sources — name -> record, as bun-graph produces (loader + text())
|
|
71
|
+
// seam — { name, anchor, insert } from seams.js
|
|
72
|
+
function resolveSeam(sources, seam) {
|
|
73
|
+
const carriers = [];
|
|
74
|
+
for (const record of sources.values()) {
|
|
75
|
+
if (record.loader !== 'js') continue;
|
|
76
|
+
const sites = matchesIn(record.text(), seam.anchor);
|
|
77
|
+
if (sites.length) carriers.push({ module: record.name, sites });
|
|
78
|
+
}
|
|
79
|
+
if (carriers.length === 0) return unresolved(UNRESOLVED.noModule, seam);
|
|
80
|
+
// Naming the modules is what makes this diagnosable: "several" without which ones sends whoever
|
|
81
|
+
// reads the log back to the binary to find out.
|
|
82
|
+
if (carriers.length > 1) return unresolved(UNRESOLVED.severalModules, seam, carriers.map((c) => c.module).join(', '));
|
|
83
|
+
const [carrier] = carriers;
|
|
84
|
+
if (carrier.sites.length > 1) return unresolved(UNRESOLVED.severalSites, seam, `${carrier.module} at ${carrier.sites.map((s) => s.index).join(', ')}`);
|
|
85
|
+
const [site] = carrier.sites;
|
|
86
|
+
const insert = fillCaptures(seam.insert, site.groups);
|
|
87
|
+
if (!insert.ok) return unresolved(UNRESOLVED.absentCapture, seam, insert.missing);
|
|
88
|
+
return { ok: true, seam: seam.name, module: carrier.module, index: site.index, groups: site.groups, insert: insert.text };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Where `index` falls in `text`, zero-based, as the inspector protocol counts lines and columns: lines
|
|
92
|
+
// end where the language says they do — CRLF, CR, LF, U+2028, U+2029 — as JSC's lexer counts them.
|
|
93
|
+
const LINE_TERMINATOR = /\r\n|[\n\r\u2028\u2029]/g;
|
|
94
|
+
function lineColumnAt(text, index) {
|
|
95
|
+
let lineNumber = 0, lineStart = 0;
|
|
96
|
+
for (const m of text.slice(0, index).matchAll(LINE_TERMINATOR)) { lineNumber++; lineStart = m.index + m[0].length; }
|
|
97
|
+
return { lineNumber, columnNumber: index - lineStart };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The breakpoint form of every seam that declares one (`attach` in seams.js), for a host that cannot
|
|
101
|
+
// splice — a stock Bun binary, reached over its inspector. A point is proof the seam resolved once,
|
|
102
|
+
// at one site, with every capture its action names: the URL the module is parsed under, the position
|
|
103
|
+
// the breakpoint goes, and the expression it evaluates there on every hit. Resolution is the splice
|
|
104
|
+
// form's own (`resolveSeam`), so the two forms cannot disagree about where a seam is.
|
|
105
|
+
// [LAW:one-source-of-truth] Refuses on the first seam that does not resolve, as `resolveSeams` does,
|
|
106
|
+
// and when no seam has a breakpoint form at all: an attach with nothing to set is a stock session
|
|
107
|
+
// claiming otherwise. [LAW:no-silent-failure]
|
|
108
|
+
function resolveBreakpoints(sources, seams) {
|
|
109
|
+
const points = [];
|
|
110
|
+
for (const seam of seams.filter((s) => s.attach)) {
|
|
111
|
+
const site = resolveSeam(sources, seam);
|
|
112
|
+
if (!site.ok) return site;
|
|
113
|
+
const action = fillCaptures(seam.attach.action, site.groups);
|
|
114
|
+
if (!action.ok) return unresolved(UNRESOLVED.absentCapture, seam, action.missing);
|
|
115
|
+
points.push({ seam: seam.name, url: site.module, ...lineColumnAt(sources.get(site.module).text(), site.index + seam.attach.offset), action: action.text });
|
|
116
|
+
}
|
|
117
|
+
if (points.length === 0) return { ok: false, reason: UNRESOLVED.noBreakpointForm };
|
|
118
|
+
return { ok: true, points };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Resolve every seam, or refuse on the FIRST that does not. A partial plan is a plan that patches
|
|
122
|
+
// some of the app and not the rest, which is a state no caller has any use for.
|
|
123
|
+
function resolveSeams(sources, seams) {
|
|
124
|
+
const sites = [];
|
|
125
|
+
for (const seam of seams) {
|
|
126
|
+
const site = resolveSeam(sources, seam);
|
|
127
|
+
if (!site.ok) return site;
|
|
128
|
+
sites.push(site);
|
|
129
|
+
}
|
|
130
|
+
const splices = new Map();
|
|
131
|
+
for (const site of sites) {
|
|
132
|
+
const forModule = splices.get(site.module) || [];
|
|
133
|
+
forModule.push({ index: site.index, insert: site.insert });
|
|
134
|
+
splices.set(site.module, forModule);
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, sites, splices };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Apply one module's insertions. HIGHEST OFFSET FIRST, so each splice leaves every offset below it
|
|
140
|
+
// where the plan measured it. Front-to-back would shift every later index by the length of every
|
|
141
|
+
// earlier insertion, which is a plan that is right about the first seam and wrong about the rest.
|
|
142
|
+
function spliceSource(text, inserts) {
|
|
143
|
+
const ordered = [...inserts].sort((a, b) => b.index - a.index);
|
|
144
|
+
let out = text;
|
|
145
|
+
for (const { index, insert } of ordered) out = out.slice(0, index) + insert + out.slice(index);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The plan's own knowledge of how it is consumed: the one function bun-runtime applies in moduleFor.
|
|
150
|
+
// Modules with no seam come back byte-identical, so the recovered source still runs verbatim
|
|
151
|
+
// everywhere the plan does not reach. [LAW:dataflow-not-control-flow] every module goes through the
|
|
152
|
+
// same call; whether anything changes is decided by the plan's data, not by a branch at the callsite.
|
|
153
|
+
function transformFor(plan) {
|
|
154
|
+
return (name, text) => {
|
|
155
|
+
const inserts = plan.splices.get(name);
|
|
156
|
+
return inserts ? spliceSource(text, inserts) : text;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { UNRESOLVED, sitesIn, lineColumnAt, resolveSeam, resolveSeams, resolveBreakpoints, spliceSource, transformFor };
|