ktn-linter's findings beside the prompt, as a mod (function hooks, Claude Code 2.1.299 or later). It completes the hooks ktn-linter installs and replaces none…

ktn-linter, seen from inside Claude Code: a mod (function hooks) that tells whether the session is in a Go project and, from there, shows what the ktn-linter daemon knows about it.
It completes ktn-linter's hooks and replaces none. The command hooks that ktn-linter installs keep delivering the findings to the model through the daemon's socket, everywhere — Kepler, claude -p and the Agent SDK included. This mod registers no tool hook: it can neither block nor rewrite a call, and if it does not load, nothing the model receives changes.
It needs Claude Code 2.1.299 or later (the oldest version measured by the spike of kodflow/ktn-linter#774; CI pins 2.1.301). Below that it shows one status line and does nothing else.
/plugin install kodflow-ktn@kodflow
claude plugin disable kodflow-ktn@kodflow turns it off without touching the rest; the hooks go on as before.
Only where something draws — the terminal. In a -p run, under the Agent SDK and in Kepler's launch mode $.session.surfaces() is empty: the mod is inert there, with no probe and no daemon call.
Only in a Go project, decided by the same predicate as ktn-linter's own resolver (daemonaddr.ResolveProject): walking up from the session's root to the nearest .git (a folder, or a file in a linked worktree), a regular go.mod or go.work on the way makes it Go; crossing a vendor/ folder discards what was found below it; a go.mod above the repository does not count. A repository whose modules all sit in subfolders is not Go from its root — open the session in the module, or set activation: always.
The probe is bounded by what the spike measured: at most three listings and 250 ms, upward only (nothing below the root is read, so no link is ever followed). A walk cut short says undetermined, which is drawn as inactive, never guessed.
activation | Effect |
|---|---|
auto (default) | active in a Go project only |
always | active everywhere — the net for an undetermined probe |
off | never active |
The verdict is reconciled at one point only: the end of each main-loop turn, and the SessionStart hook after a /clear, /resume or fork. That catches a /cd, a worktree switch, a go mod init, and a $.state that came back empty.
/ktn-view — /ktn belongs to kodflow-devops. It answers from what the mod already holds and never waits on the daemon (a command that waits holds the prompt for up to 30 s); it sends a refresh in the background.
The mod reads the per-user daemon of ktn-linter (KTN_USER_DAEMON=1, kodflow/ktn-linter#757) and nothing else. It finds it through endpoint.json alone, the pointer ktn-linter writes under HOME (~/.local/state/ktn-linter/, macOS ~/Library/Application Support/ktn-linter/): the socket path is never computed on this side, and state.json is not a source (a clean stop removes it, which is exactly when the launch button is needed). It then asks GET /status once per pointer (the protocol it speaks, the workspaces waiting for approval) and GET /api/view for the workspace, over the socket.
Every known failure ends in a drawn state, never in silence:
| State | When | Offered |
|---|---|---|
| out of a Go project | the probe said so | nothing at all |
| loading | no answer yet | — |
| unreachable | no endpoint.json, nothing on the socket, a daemon silent past the request's wait + 5 s, an answer too big or unreadable | the launch button, or the command to run |
| incompatible | another protocol than v1 | nothing: no further call until endpoint.json changes |
| pending approval | the daemon waits for the person | the daemon's exact ktn-linter admin workspace approve … |
| refused / not served | the daemon's reason | — |
| clean / findings | served | the counts on the status line |
A request refused before it left (an organization's web-fetch policy, a hook) is a state of its own, never "unreachable", and offers no launch.
One request at a time, tied to the underlying fetch: $.http.fetch has a fixed 30 s deadline and no signal, so the mod races its own (the request's wait + 5 s), draws "unreachable" when it fires, sends nothing more until the first request has settled, and drops its late answer. Five failures in a row hold the turn's refresh back (3 s doubling to 60 s); the person's own act — the command, a press — passes it. No timer exists while no pane is placed: the status line is refreshed at the end of each turn, with wait=0.
The launch button is a convenience, not the guarantee — the hooks and the MCP shim start the daemon. It runs <exe> daemon ensure with the absolute binary endpoint.json names (never a name looked up in PATH), from /, with KTN_USER_DAEMON=1, then watches the socket every 500 ms for 10 s: a zero exit proves a request, not a daemon. Two presses run one command. On failure it shows the exit code and the first 300 characters of stderr. Where the session has no host commands, the command is shown to copy instead.
Every string from the daemon is cleaned of control characters before it is drawn, by a plain loop: no regular expression runs on external data.
/ktn-view opens it in a Go project (/ktn-view off closes it); out of one the command answers and opens nothing. At the top, the synthesis: the state, the workspace, the totals by severity and by phase, how long since the last answer, the launch button when the daemon is down. Below, the findings as a collapsible tree, the way the daemon serves it: directories folded with their counts, only the children of the ones opened. A press on a directory or a file opens or folds it (the key of each row is the model's stable dir: / file: id, never a line or a counter); the opened ids live in $.state and come back after a hot reload. The pane does not window the tree: the engine scrolls it natively, the daemon bounds it, and a window that stops short says "… N de plus, voir l'UI web". The drawing stops its rows below the engine's 100 000 characters.
While the pane is placed, and only then, one timer exists: a chain of one-shot timers, 3 s apart, each long-polling the daemon (wait=20, raced at 25 s). Closed, nothing is left armed. A press may send one request beside a long-poll that waits on a daemon that answers; the long-poll's answer, about a tree nobody looks at any more, is dropped.
Ouvrir l'UI web mints a bootstrap link over the socket (POST /ui/boot, same uid) on the press, never before. The daemon grants only read-only view sessions. The token is held in a module variable and drawn in the pane's Link alone: never in $.state, which every plugin can read, never in the command's text, which the model reads. The link's label is the URL without its fragment.
autoOpen is off by default, as kodflow/ktn-linter#774 recommended: the engine seats a pane nobody asked for only from 144 columns, and once you have opened it with /ktn-view it seats it again from 110, in every later session.
autoOpen | Effect |
|---|---|
false (default) | the pane opens on /ktn-view |
true | the pane opens when a Go session starts |
tmux capture-pane of a terminal at 144 columns, fullscreen layout, against a test daemon (KTN_USER_DAEMON=1, private HOME and XDG_*), the pane opened by /ktn-view and three folders opened from the keyboard (the scratch path shortened, three of the six findings shown):
│ktn · 6 constats · 0 E · 2 W · 4 I
│…/scratchpad/kws
│phases 2:1 5:1 6:2 7:2
│actualisé il y a 0 s · moteur init
│http://127.0.0.1:8080/
│▾ . 6 · 2W 4I
│ ▾ pkg/ 6 · 2W 4I
│ ▾ api/ 6 · 2W 4I
│ ▾ handler.go 6 · 2W 4I
│ L1 KTN-SC-ST1000 at least one file in a package should…
│ L5 KTN-FUNC-MAXPARAM function 'Serve' has 7 parameters…
│ L6 KTN-VAR-USEANY prefer any to interface{}
Under the prompt: ⚠ kodflow-ktn: 6 constats · 0 E · 2 W · 4 I. The daemon stopped, the band above the prompt shows the button:
ktn · daemon injoignable — aucun daemon sur le socket
[ Lancer le daemon ]
A workspace waiting for approval, at 110 columns:
ktn · en attente d'approbation — repository not approved [-]
à lancer dans votre propre terminal :
ktn-linter admin workspace approve "/…/scratchpad/kws2"
tests/fixtures/resolve-project.json is ktn-linter's golden table for the resolver (pkg/daemonaddr/project_roots_external_test.go), transcribed row for row at the commit its provenance.sha names, and checked against the Go resolver at that commit before it was committed. The pure tests replay every row; changing an expected value fails them, as it fails the Go test. To move the pin: copy the table again from the new commit, re-run the Go resolver on each tree, then update KTN_LINTER_SHA in hooks/mod/contract.ts.
tests/fixtures/view/ holds ktn-linter's golden view models (pkg/view/testdata/golden), byte for byte at the commit SOURCE names. The pure tests read every one back whole, check that the expansions the mod sends are the golden queries' own, and draw them under the 100 000-character limit.
node --test plugins/kodflow-ktn/tests/pure/*.spec.ts # pure modules, no engine
claude plugin test plugins/kodflow-ktn # the hooks, in the engine
Node 22.18 or later runs the TypeScript as it is. No npm dependency.
hooks/mod/register.ts 380 lines1// register.ts — kodflow-ktn's function hooks: the one module that touches `$`.
2//
3// The mod COMPLETES ktn-linter's hooks and replaces none (decision of
4// 2026-10-14, kodflow/ktn-linter#774): the command hooks keep delivering the
5// findings to the model through the daemon's socket, everywhere, Kepler
6// included. So nothing here hooks a tool call (no `tool.call`, no
7// `classic.PreToolUse` / `PostToolUse`): CI asserts it on the validator's
8// report.
9//
10// Every hook is registered unconditionally and is inert until the session
11// proves to be a Go project:
12// - nothing draws in a `-p` run, the Agent SDK or Kepler's launch mode
13// (`$.session.surfaces()` empty, #774): the mod stays inert there;
14// - under the version floor it says so once and does nothing else;
15// - `session.start` registers the command and hands the probe to the
16// background: it never waits on it, and it never calls the daemon;
17// - the ONE reconciliation point is `turn.complete` of the main loop and
18// `classic.SessionStart` after a /clear, /resume or fork: the root moved
19// (`/cd`, a worktree), a folder gained its go.mod, `$.state` came back
20// empty, a timer a hook refused — each is caught there and nowhere else.
21//
22// The daemon is reached through client.ts (pure, port-injected) and drawn by
23// view.ts (pure): what is here is the port and the wiring.
24//
25// The pane (#777) is opened by /ktn-view, or at the start with `autoOpen`
26// (off by default: the engine seats an unasked pane only from 144 columns,
27// and once the person has opened it, it seats it again from 110, session
28// after session — measured by #774). While it is placed, and only then, a
29// tick long-polls the daemon; closed, no timer is left.
30
31import type { Register } from "claude-code";
32import type { KtnActivation, KtnDaemon, KtnModel, KtnSnap, KtnTree } from "../../types";
33import { Client, type Port, type UiLink } from "./client.ts";
34import {
35 COMMAND,
36 MIN_ENGINE,
37 PANE_ID,
38 PROBE_MAX_CALLS,
39 PROBE_MAX_MS,
40} from "./contract.ts";
41import { atLeast } from "./engine.ts";
42import {
43 parseActivation,
44 resolveProject,
45 verdictOf,
46 workspaceRoot,
47 worthReprobing,
48 type Activation,
49 type Resolution,
50} from "./project.ts";
51import { band, pane, safe, stateLine, statusText } from "./view.ts";
52
53const SNAP = { plugin: "kodflow-ktn", key: "snap" } as const;
54const TREE = { plugin: "kodflow-ktn", key: "tree" } as const;
55const EXPANDED = { plugin: "kodflow-ktn", key: "expanded" } as const;
56
57// The module's own copy: the fast source. A reload starts it over, and the
58// next reconciliation rebuilds it from the disk; `$.state` keeps what is drawn.
59let snap: KtnSnap = { activation: "probing" };
60let resolution: Resolution | undefined;
61let probing = false;
62let started = false;
63let activation: Activation = "auto";
64let client: Client | undefined;
65let lastStatus: string | undefined;
66let autoOpen = false;
67/** The dir: and file: ids opened in the pane. */
68let expanded: string[] = [];
69/**
70 * The web UI's bootstrap link. Held HERE and drawn in the pane's tree alone:
71 * never in `$.state`, which any plugin reads, never in the command's text,
72 * which the model reads.
73 */
74let uiLink: UiLink | undefined;
75
76// $.state takes JSON data: no undefined anywhere in it
77const plain = <T>(v: T): T => JSON.parse(JSON.stringify(v));
78
79/** Writes a transition twice: the module's variable, then `$.state`. */
80async function publish($: any, next: KtnSnap): Promise<void> {
81 snap = plain(next);
82 await $.state.set(SNAP, snap).catch(() => undefined);
83}
84
85/** The status line, written only when its text changes. */
86function status($: any): void {
87 const text = statusText(snap);
88 if (text === lastStatus) return;
89 lastStatus = text;
90 $.ui.status(text);
91}
92
93const isLive = (a: KtnActivation) => a !== "inert" && a !== "floor";
94
95/** Leaving a Go project: no timer, no request, nothing on screen. */
96function standDown($: any): void {
97 client?.stop();
98 lastStatus = undefined;
99 uiLink = undefined;
100 $.ui.status(undefined);
101 void $.ui.close({ id: PANE_ID }).catch(() => undefined);
102}
103
104/** Whether this mod's pane is placed now, by the engine's own record. */
105async function placed($: any): Promise<boolean> {
106 const panes = await $.ui.panes().catch(() => []);
107 return panes.some((p: any) => p.id === PANE_ID && p.isPlaced);
108}
109
110/** Opens the pane and starts the tick when the surface placed it. */
111async function openPane($: any): Promise<boolean> {
112 const r = await $.ui.open({ id: PANE_ID, title: "ktn" }).catch(() => undefined);
113 const isPlaced = r?.isPlaced === true;
114 if (isPlaced) client?.setTicking(true);
115 return isPlaced;
116}
117
118async function writeTree($: any, model: KtnModel): Promise<void> {
119 const tree: KtnTree = {
120 workspace: snap.workspace ?? "",
121 nodes: model.nodes,
122 truncated: model.truncated,
123 hidden: model.hidden,
124 };
125 await $.state.set(TREE, plain(tree)).catch(() => undefined);
126}
127
128/** A press on a directory or a file of the tree: open it, or fold it. */
129async function toggle($: any, id: string): Promise<void> {
130 if (!client) return;
131 expanded = expanded.includes(id) ? expanded.filter((x) => x !== id) : [...expanded, id];
132 await $.state.set(EXPANDED, expanded).catch(() => undefined);
133 client.setExpand(expanded);
134 client.nudge();
135}
136
137/** "Ouvrir l'UI web": a fresh link, kept in the module, drawn in the pane. */
138async function openUi($: any): Promise<void> {
139 if (!client) return;
140 const r = await client.mint();
141 if ("error" in r) {
142 $.ui.toast(`ktn: UI web indisponible (${r.error})`);
143 return;
144 }
145 uiLink = r;
146 $.ui.invalidate("ui.render");
147}
148
149/** The engine, as client.ts sees it. */
150function portOf($: any, canRun: boolean): Port {
151 return {
152 now: () => $.clock.now(),
153 after: (ms, fn) => $.clock.after(ms, fn),
154 read: (path) => $.fs.read(path),
155 home: () => $.env.get("HOME"),
156 fetch: (url, init) => $.http.fetch(url, init),
157 run: canRun ? (argv, init) => $.process.run(argv, init) : undefined,
158 emit: (daemon: KtnDaemon, model: KtnModel | undefined) => {
159 if (snap.activation !== "active") return;
160 void publish($, { ...snap, daemon }).then(() => status($));
161 if (model) void writeTree($, model);
162 },
163 };
164}
165
166/**
167 * The activation probe: at most three listings and 250 ms, walked up from
168 * the session's root (never `repo().root`, which names the MAIN working tree
169 * of a linked worktree and would turn every Kepler worktree off).
170 */
171async function probe($: any): Promise<void> {
172 if (probing) return;
173 probing = true;
174 try {
175 const root: string = await $.session.root();
176 const t0: number = await $.clock.now();
177 const r = await resolveProject(root, (dir) => $.fs.list(dir), {
178 maxCalls: PROBE_MAX_CALLS,
179 expired: async () => (await $.clock.now()) - t0 > PROBE_MAX_MS,
180 });
181 resolution = r;
182 const verdict = verdictOf(r, activation);
183 const was = snap.activation;
184 if (verdict !== "active") {
185 await publish($, { activation: verdict, root });
186 if (was === "active") standDown($);
187 return;
188 }
189 const workspace = workspaceRoot(r);
190 const same = was === "active" && snap.workspace === workspace;
191 await publish($, {
192 activation: "active",
193 root,
194 workspace,
195 daemon: same ? snap.daemon : { kind: "loading", launch: "none" },
196 });
197 if (!same) {
198 expanded = [];
199 uiLink = undefined;
200 client?.setExpand([]);
201 }
202 client?.setWorkspace(workspace);
203 status($);
204 // Off the start's path: the hook that probed has returned long ago
205 if (!same) void client?.refresh();
206 if (!same && autoOpen) await openPane($);
207 } finally {
208 probing = false;
209 }
210}
211
212/**
213 * The one reconciliation point. Walks again when the root moved or the last
214 * walk could still change its mind, puts the snapshot back in `$.state` when
215 * it came back empty (a /clear, a worker that was replaced), re-arms a timer
216 * that never fired, and refreshes the status line with a request that waits
217 * on nothing (`wait=0`).
218 */
219async function reconcile($: any): Promise<void> {
220 if (!started || !isLive(snap.activation)) return;
221 const root: string | undefined = await $.session.root().catch(() => undefined);
222 if (root !== undefined && (root !== snap.root || worthReprobing(resolution)))
223 await probe($);
224 const held = await $.state.get(SNAP).catch(() => undefined);
225 if (held && held.value === undefined) await publish($, snap);
226 if (snap.activation !== "active" || !client) return;
227 // A pane the engine placed since (a wider terminal, a surface attaching)
228 // starts the tick; one that went away stops it
229 client.setTicking(await placed($));
230 await client.reconcile();
231 if (!client.ticking) void client.refresh({ waitS: 0 });
232}
233
234/** The session's start, or a surface attaching to a session that had none. */
235async function start($: any, surfaces: readonly string[]): Promise<void> {
236 started = true;
237 const v = await $.session.version().catch(() => undefined);
238 if (!atLeast(v?.base ?? v?.version, MIN_ENGINE)) {
239 await publish($, { activation: "floor" });
240 $.ui.status(`Claude Code ${MIN_ENGINE} ou plus requis`);
241 return;
242 }
243 // Host commands are the CLI's (`$.process` is "CLI only"): on a session
244 // the terminal does not draw, the button becomes a command to copy
245 const canRun = surfaces.includes("terminal");
246 client = new Client(portOf($, canRun));
247 // A hot reload keeps $.state: the person's opened folders come back
248 const held = await $.state.get(EXPANDED).catch(() => undefined);
249 if (Array.isArray(held?.value)) {
250 expanded = held.value.filter((x: unknown) => typeof x === "string").slice(0, 500);
251 client.setExpand(expanded);
252 }
253 await $.command
254 .register({
255 name: "ktn-view",
256 description: "ktn-linter : état du daemon et constats du projet Go",
257 })
258 .catch((err: unknown) =>
259 $.ui.log(`kodflow-ktn: /${COMMAND} refusé : ${String(err)}`, {
260 to: "debug",
261 }),
262 );
263 await publish($, { activation: "probing" });
264 // Detached: the probe's listings are not the start's to wait on
265 void probe($).catch(() => undefined);
266}
267
268function commandText(): string {
269 switch (snap.activation) {
270 case "active":
271 return `${stateLine(snap).text.replace("ktn · ", "ktn: ")} (workspace ${snap.workspace ?? "?"})`;
272 case "undetermined":
273 return "ktn: projet indéterminé (sonde interrompue) ; activation: always pour forcer";
274 case "probing":
275 return "ktn: sonde du projet en cours";
276 case "floor":
277 return `ktn: Claude Code ${MIN_ENGINE} ou plus requis`;
278 default:
279 return "ktn: pas un projet Go";
280 }
281}
282
283/** The launch button's action: a person's act, so the breaker gives way. */
284function launch(): void {
285 if (!client) return;
286 client.userAction();
287 void client.launch();
288}
289
290export const register: Register = (on, options) => {
291 activation = parseActivation(options.activation);
292 autoOpen = options.autoOpen === true;
293
294 // ONE session.start: two without a matcher keep the module from loading
295 on("session.start", async ($, e, next) => {
296 const surfaces = await $.session.surfaces().catch(() => []);
297 if (surfaces.length === 0) {
298 await publish($, { activation: "inert" });
299 return next(e);
300 }
301 const result = next(e);
302 await start($, surfaces);
303 return result;
304 }).catch(($, e, next) => next(e));
305
306 // A host that attaches its first surface after the start
307 on("session.attach", async ($, e, next) => {
308 const result = await next(e);
309 if (!started && snap.activation === "inert")
310 await start($, await $.session.surfaces().catch(() => [e.surface])).catch(
311 () => undefined,
312 );
313 return result;
314 }).catch(($, e, next) => next(e));
315
316 on("turn.complete", async ($, e, next) => {
317 const result = await next(e);
318 if (e.agentId === undefined) void reconcile($).catch(() => undefined);
319 return result;
320 }).catch(($, e, next) => next(e));
321
322 on("classic.SessionStart", async ($, e, next) => {
323 const result = await next(e);
324 void reconcile($).catch(() => undefined);
325 return result;
326 }).catch(($, e, next) => next(e));
327
328 // The name is spelled out (COMMAND) so the validator reads the matcher.
329 // Answers from what the module holds: never a daemon call, which would
330 // hold the prompt up to 30 s (#774). A person's act: the breaker gives way
331 // and a refresh leaves in the background.
332 on("command.run", { command: "ktn-view" }, async ($, e) => {
333 const arg = String(e.args ?? "").trim().toLowerCase();
334 if (arg === "off" || arg === "close") {
335 client?.setTicking(false);
336 await $.ui.close({ id: PANE_ID }).catch(() => undefined);
337 return { text: "ktn: panneau fermé" };
338 }
339 // Out of a Go project: the text alone, no pane
340 if (snap.activation === "active" && client) {
341 await openPane($);
342 client.nudge();
343 }
344 return { text: commandText() };
345 });
346
347 // The person closed the pane (its mark, ctrl+x x): the tick goes with it
348 on("ui.close", async ($, e, next) => {
349 const result = await next(e);
350 if (e.id === PANE_ID) client?.setTicking(false);
351 return result;
352 }).catch(($, e, next) => next(e));
353
354 on("ui.render", { component: "Pane", requestId: "ktn-view" }, async ($, e) => {
355 const els: any = $.ui.resolve(e);
356 const held = (await $.state.get(SNAP).catch(() => undefined))?.value ?? snap;
357 const tree = (await $.state.get(TREE).catch(() => undefined))?.value;
358 const now: number = await $.clock.now();
359 const link =
360 uiLink && (uiLink.expiresAt === 0 || uiLink.expiresAt > now)
361 ? { url: uiLink.url, label: uiLink.url.split("#")[0]! }
362 : undefined;
363 return safe(els, () =>
364 pane(els, { snap: held, tree, link, now }, {
365 launch,
366 toggle: (id) => void toggle($, id),
367 openUi: () => void openUi($),
368 }),
369 );
370 });
371
372 // The band above the prompt, while there is something to act on
373 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
374 const els: any = $.ui.resolve(e);
375 const held = (await $.state.get(SNAP).catch(() => undefined))?.value ?? snap;
376 const tree = safe(els, () => band(els, held, { launch }));
377 return tree ?? next(e);
378 });
379};
380hooks/mod/client.ts 553 lines1// client.ts — the mod's side of the daemon: discovery, the refresh, the
2// states, the launch button. Pure: the engine is a port handed in, so the
3// node tests drive every failure with a clock of their own.
4//
5// "Never crashes" (kodflow/ktn-linter#776) is held as two properties:
6// 1. no callback holds the worker every mod shares: one request at a time,
7// a body too big to parse refused unread, every loop bounded;
8// 2. every known failure ends in a DRAWN state, never in silence: each
9// path below ends in `set(...)`, and nothing here throws past `refresh`.
10//
11// One request at a time: `inflight` is tied to the UNDERLYING fetch, not to
12// the race the mod runs against it. `$.http.fetch` has a fixed 30 s deadline
13// and no signal, so when the race's own timer (the request's wait + 5 s)
14// fires first, the state turns unreachable at once, but no second request
15// leaves until the first one has settled; and its late answer is dropped
16// (the generation moved on). One exception, bounded: while a pane's
17// long-poll waits on a HEALTHY daemon (it is answering, just holding the
18// request for up to 20 s), a person's press may send one request beside it,
19// and the long-poll's answer — now about a tree the person no longer looks
20// at — is dropped by the same generation. Never more than two.
21//
22// No timer exists unless a pane is placed (#777 sets `ticking`): the status
23// line is refreshed by the turn's end, the pane by a chain of one-shot
24// timers, re-armed in a `finally`, never `every` (two periods could overlap).
25import type { KtnDaemon, KtnModel } from "../../types";
26import {
27 BACKOFF_MAX_MS,
28 BACKOFF_MIN_MS,
29 BREAKER_AFTER,
30 LAUNCH_POLL_MS,
31 LAUNCH_TIMEOUT_MS,
32 MAX_TEXT,
33 MAX_WAIT_S,
34 PROTOCOL_VERSION,
35 RACE_SLACK_MS,
36 UI_BOOT,
37 VIEW_ROUTE,
38} from "./contract.ts";
39import {
40 classifyRejection,
41 isNotServed,
42 parseJson,
43 parseModel,
44 parseStatus,
45 refusalOf,
46 type Failure,
47} from "./daemon.ts";
48import { anchorPaths, parseEndpoint, type Endpoint } from "./endpoint.ts";
49import { clean } from "./sanitize.ts";
50
51export type Answer = { status: number; text: string };
52export type Timer = { cancel(): void };
53
54/** What the client needs from the engine. */
55export type Port = {
56 now(): Promise<number>;
57 /** `$.clock.after`: one call of `fn` after `ms`, unless cancelled. */
58 after(ms: number, fn: () => void): Timer;
59 /** `$.fs.read`: rejects when the file is missing. */
60 read(path: string): Promise<string>;
61 home(): Promise<string | undefined>;
62 /** `$.http.fetch` over the socket: resolves any status, rejects on transport. */
63 fetch(url: string, init: { socketPath: string; method?: string }): Promise<Answer>;
64 /** `$.process.run`; absent where the surface has no host commands. */
65 run?: (
66 argv: string[],
67 init: { cwd: string; timeoutMs: number; env: Record<string, string> },
68 ) => Promise<{ exitCode: number; stderr: string }>;
69 /** Called on every change of what is drawn. */
70 emit(daemon: KtnDaemon, model: KtnModel | undefined): void;
71};
72
73const TIMEOUT = Symbol("timeout");
74/** An answer to a request someone else's has superseded: not a failure, nothing to draw. */
75const STALE = Symbol("stale");
76type Outcome = Answer | Failure | typeof STALE;
77const isFailure = (x: Answer | Failure): x is Failure => "kind" in x;
78
79/** A bootstrap link to the web UI: the token rides in the fragment. */
80export type UiLink = { url: string; expiresAt: number };
81
82/** The tick's period while a pane is placed (#777). */
83export const TICK_MS = 3_000;
84/** A launch still "starting" past this is given up at the next reconciliation. */
85const LAUNCH_STALE_MS = 3 * LAUNCH_TIMEOUT_MS;
86
87export class Client {
88 port: Port;
89 workspace = "";
90 view: KtnDaemon = { kind: "loading", launch: "none" };
91 model: KtnModel | undefined = undefined;
92 /** The model last handed to `emit`, so an unchanged one is not sent again. */
93 emitted: KtnModel | undefined = undefined;
94 endpoint: Endpoint | undefined = undefined;
95 endpointText: string | undefined = undefined;
96 handshaken = false;
97 incompatible = false;
98 /** Underlying fetches not settled yet: 0, 1, or 2 (a long-poll and a press). */
99 inflight = 0;
100 /** True while the request in flight is a long-poll. */
101 longPolling = false;
102 gen = 0;
103 failures = 0;
104 breakerUntil = 0;
105 rev = 0;
106 /** Directory and file ids the pane asked to see opened (#777). */
107 expand: string[] = [];
108 /** The long-poll's wait while a pane is placed, 0 otherwise. */
109 waitS = 0;
110 launching = false;
111 launchStartedAt = 0;
112 ticking = false;
113 timer: Timer | undefined = undefined;
114 dueAt = 0;
115 tickRunning = false;
116 stopped = false;
117
118 constructor(port: Port) {
119 this.port = port;
120 }
121
122 /** The workspace asked about; a new one starts the view over. */
123 setWorkspace(ws: string): void {
124 this.stopped = false;
125 if (ws === this.workspace) return;
126 this.workspace = ws;
127 this.rev = 0;
128 this.model = undefined;
129 this.expand = [];
130 this.set({ kind: "loading", launch: "none" });
131 }
132
133 /** Leaving the Go project: no timer, no request. */
134 stop(): void {
135 this.stopped = true;
136 this.setTicking(false);
137 }
138
139 private set(next: Partial<KtnDaemon> & { kind?: KtnDaemon["kind"] }): void {
140 const view: KtnDaemon = { ...this.view, ...next };
141 // A state never claims more than it knows: counts go with "ready" alone
142 if (view.kind !== "ready") {
143 delete view.totals;
144 delete view.engine;
145 }
146 if (view.kind === "ready" || view.kind === "loading") {
147 delete view.detail;
148 delete view.command;
149 }
150 for (const k of Object.keys(view) as (keyof KtnDaemon)[])
151 if (view[k] === undefined) delete view[k];
152 // The same state again (a 204, the same failure) redraws nothing
153 const model = view.kind === "ready" ? this.model : undefined;
154 if (JSON.stringify(view) === JSON.stringify(this.view) && model === this.emitted) return;
155 this.view = view;
156 this.emitted = model;
157 try {
158 this.port.emit(view, model);
159 } catch {
160 // The drawing is the engine's business; a refused write changes nothing here
161 }
162 }
163
164 private async race<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMEOUT> {
165 let timer: Timer | undefined;
166 const timeout = new Promise<typeof TIMEOUT>((resolve) => {
167 timer = this.port.after(ms, () => resolve(TIMEOUT));
168 });
169 try {
170 return await Promise.race([p, timeout]);
171 } finally {
172 timer?.cancel();
173 }
174 }
175
176 /** One request over the socket, raced; a failure is a value, never a throw. */
177 private async request(path: string, waitS: number, method?: string): Promise<Outcome> {
178 const socketPath = this.endpoint!.socket;
179 const mine = ++this.gen;
180 const long = waitS > 0;
181 this.inflight++;
182 if (long) this.longPolling = true;
183 const underlying: Promise<Answer | Failure> = this.port
184 .fetch(`http://ktn${path}`, method ? { socketPath, method } : { socketPath })
185 .then(
186 (r) => ({ status: Number(r?.status) || 0, text: typeof r?.text === "string" ? r.text : "" }),
187 (err) => classifyRejection(err),
188 )
189 .finally(() => {
190 this.inflight--;
191 if (long) this.longPolling = false;
192 });
193 const r = await this.race(underlying, waitS * 1_000 + RACE_SLACK_MS);
194 if (mine !== this.gen) return STALE;
195 if (r === TIMEOUT) {
196 // Whatever it says later is about a request nobody waits for any more
197 this.gen++;
198 return { kind: "silent", detail: "le daemon ne répond pas" };
199 }
200 return r;
201 }
202
203 private ensureCommand(): string {
204 return `KTN_USER_DAEMON=1 ${this.endpoint?.exe || "ktn-linter"} daemon ensure`;
205 }
206
207 private backoff(): number {
208 const n = Math.max(1, this.failures);
209 return Math.min(BACKOFF_MIN_MS * 2 ** Math.min(n - 1, 10), BACKOFF_MAX_MS);
210 }
211
212 private async failed(f: Failure): Promise<void> {
213 this.failures++;
214 this.handshaken = false;
215 if (this.failures >= BREAKER_AFTER)
216 this.breakerUntil = (await this.port.now()) + this.backoff();
217 if (f.kind === "policy") {
218 this.set({ kind: "policy", detail: f.detail, launch: "none", command: undefined });
219 return;
220 }
221 const launch =
222 f.kind === "absent" ? (this.endpoint?.exe && this.port.run ? "button" : "command") : "none";
223 this.set({
224 kind: "unreachable",
225 detail: f.kind === "absent" ? "aucun daemon sur le socket" : f.detail,
226 launch,
227 command: launch === "none" ? undefined : this.ensureCommand(),
228 });
229 }
230
231 /** endpoint.json from its anchors; a move resets what the old one taught. */
232 private async readEndpoint(): Promise<{ ok: true } | { ok: false; detail: string }> {
233 const paths = anchorPaths(await this.port.home());
234 for (const path of paths) {
235 let text: string;
236 try {
237 text = await this.port.read(path);
238 } catch {
239 continue;
240 }
241 if (text !== this.endpointText) {
242 this.endpointText = text;
243 this.handshaken = false;
244 this.incompatible = false;
245 this.failures = 0;
246 this.breakerUntil = 0;
247 }
248 const parsed = parseEndpoint(text);
249 if (!parsed.ok) {
250 this.endpoint = undefined;
251 return { ok: false, detail: parsed.reason };
252 }
253 this.endpoint = parsed.endpoint;
254 return { ok: true };
255 }
256 this.endpoint = undefined;
257 this.endpointText = undefined;
258 return { ok: false, detail: "endpoint.json absent : le daemon utilisateur n'a jamais démarré" };
259 }
260
261 private query(waitS: number): string {
262 const q = [`workspace=${encodeURIComponent(this.workspace)}`, `wait=${waitS}`];
263 if (this.rev > 0) q.push(`since=${this.rev}`);
264 for (const id of this.expand) q.push(`expand=${encodeURIComponent(id)}`);
265 return q.join("&");
266 }
267
268 /**
269 * One look at the daemon. `waitS` is the long-poll (0 for the status line);
270 * `force` passes the breaker (a person's own act); `interactive` may go
271 * beside a long-poll that is waiting on a daemon that answers (a press on
272 * the tree), and supersedes it.
273 */
274 async refresh(opts: { waitS?: number; force?: boolean; interactive?: boolean } = {}): Promise<void> {
275 if (this.stopped || !this.workspace) return;
276 const beside = opts.interactive === true && this.inflight === 1 && this.longPolling && this.handshaken;
277 if (this.inflight > 0 && !beside) return;
278 try {
279 await this.refreshOnce(opts.waitS ?? 0, opts.force === true);
280 } catch (err) {
281 // Not a path anything above expects; still a drawn state, not a silence
282 await this.failed({ kind: "broken", detail: clean(String(err), MAX_TEXT) }).catch(() => undefined);
283 }
284 }
285
286 private async refreshOnce(waitS: number, force: boolean): Promise<void> {
287 const now = await this.port.now();
288 if (!force && now < this.breakerUntil) return;
289 const ep = await this.readEndpoint();
290 if (!ep.ok) {
291 // No pointer, no request: nothing on the network is asked
292 this.set({
293 kind: "unreachable",
294 detail: ep.detail,
295 launch: "command",
296 command: this.ensureCommand(),
297 });
298 return;
299 }
300 if (this.incompatible) return;
301 if (!this.handshaken) {
302 const st = await this.request("/status", 0);
303 if (st === STALE) return;
304 if (isFailure(st)) return this.failed(st);
305 if (st.status !== 200) return this.failed({ kind: "broken", detail: `HTTP ${st.status} sur /status` });
306 const parsed = parseStatus(st.text, this.workspace);
307 if (!parsed.ok) return this.failed({ kind: "broken", detail: parsed.detail });
308 if (parsed.protocol !== PROTOCOL_VERSION) return this.incompatibleWith(parsed.protocol);
309 this.handshaken = true;
310 }
311 const r = await this.request(`${VIEW_ROUTE}?${this.query(waitS)}`, waitS);
312 if (r === STALE) return;
313 if (isFailure(r)) return this.failed(r);
314 if (r.status === 204) return this.served(now, undefined);
315 if (r.status === 200) {
316 const m = parseModel(r.text);
317 if (m.ok) return this.served(now, m.model);
318 if (m.incompatible !== undefined) return this.incompatibleWith(m.incompatible);
319 return this.failed({ kind: "broken", detail: m.detail });
320 }
321 if (isNotServed(r.status, r.text)) return this.notServed(refusalOf(r.text));
322 if (r.status === 400)
323 return this.refused(refusalOf(r.text) || "requête refusée");
324 return this.failed({ kind: "broken", detail: `HTTP ${r.status}` });
325 }
326
327 private incompatibleWith(protocol: string): void {
328 this.incompatible = true;
329 this.handshaken = false;
330 this.set({
331 kind: "incompatible",
332 detail: `ktn-linter ${clean(this.endpoint?.version, 40) || "?"} parle ${clean(protocol, 20) || "?"}, ce mod attend ${PROTOCOL_VERSION}`,
333 launch: "none",
334 command: undefined,
335 });
336 }
337
338 private refused(reason: string): void {
339 this.failures = 0;
340 this.set({ kind: "refused", detail: reason, launch: "none", command: undefined });
341 }
342
343 /** Not served: waiting for the person's approval, or refused outright. */
344 private async notServed(reason: string): Promise<void> {
345 const st = await this.request("/status", 0);
346 if (st === STALE) return;
347 if (!isFailure(st) && st.status === 200) {
348 const parsed = parseStatus(st.text, this.workspace);
349 if (parsed.ok && parsed.pending) {
350 this.failures = 0;
351 this.set({
352 kind: "pending",
353 detail: parsed.pending.reason,
354 command: parsed.pending.command,
355 launch: "none",
356 });
357 return;
358 }
359 }
360 this.refused(reason || "workspace non servi");
361 }
362
363 private served(now: number, model: KtnModel | undefined): void {
364 this.failures = 0;
365 this.breakerUntil = 0;
366 if (model) {
367 this.model = model;
368 this.rev = model.rev;
369 }
370 const m = this.model;
371 this.set({
372 kind: "ready",
373 launch: "none",
374 version: this.endpoint?.version ? clean(this.endpoint.version, 40) : undefined,
375 totals: m?.totals,
376 engine: m?.state,
377 at: now,
378 });
379 }
380
381 /** A person's own act (a press, the command): the breaker gives way. */
382 userAction(): void {
383 this.failures = 0;
384 this.breakerUntil = 0;
385 }
386
387 /**
388 * A person's act (the command, a press): the breaker and the backoff give
389 * way, and a look leaves now — the tick's next one, or one beside a
390 * long-poll that is waiting on a daemon that answers.
391 */
392 nudge(): void {
393 this.userAction();
394 if (!this.ticking) {
395 void this.refresh({ waitS: 0, force: true });
396 return;
397 }
398 if (this.tickRunning) {
399 void this.refresh({ waitS: 0, force: true, interactive: true });
400 return;
401 }
402 this.timer?.cancel();
403 this.timer = undefined;
404 this.arm(0);
405 }
406
407 /** The pane changed what it shows opened: the next answer must be whole. */
408 setExpand(ids: string[]): void {
409 this.expand = ids;
410 this.rev = 0;
411 }
412
413 private sleep(ms: number): Promise<void> {
414 return new Promise((resolve) => {
415 this.port.after(ms, resolve);
416 });
417 }
418
419 /**
420 * The launch button. Compare-and-set before any await, so two presses run
421 * one command; the binary is the absolute one endpoint.json names, never a
422 * name looked up in PATH; the working directory is `/`, never the project.
423 */
424 async launch(): Promise<void> {
425 if (this.launching) return;
426 this.launching = true;
427 try {
428 const exe = this.endpoint?.exe;
429 if (!exe || !this.port.run) return;
430 this.launchStartedAt = await this.port.now();
431 this.set({ launching: "starting", launchError: undefined });
432 let exit: { exitCode: number; stderr: string } | undefined;
433 let error = "";
434 try {
435 exit = await this.port.run([exe, "daemon", "ensure"], {
436 cwd: "/",
437 timeoutMs: LAUNCH_TIMEOUT_MS,
438 env: { KTN_USER_DAEMON: "1" },
439 });
440 } catch (err) {
441 error = clean(String((err as any)?.message ?? err), MAX_TEXT);
442 }
443 // A zero exit proves a request, not a daemon: the socket says
444 this.userAction();
445 const t0 = await this.port.now();
446 for (;;) {
447 this.handshaken = false;
448 await this.refresh({ waitS: 0, force: true });
449 if (this.view.kind !== "unreachable") break;
450 if ((await this.port.now()) - t0 >= LAUNCH_TIMEOUT_MS) break;
451 await this.sleep(LAUNCH_POLL_MS);
452 }
453 if (this.view.kind === "unreachable")
454 this.set({
455 launching: "failed",
456 launchError:
457 error || `code ${exit?.exitCode ?? "?"} : ${clean(exit?.stderr, MAX_TEXT).trim() || "pas de message"}`,
458 });
459 else this.set({ launching: undefined, launchError: undefined });
460 } finally {
461 this.launching = false;
462 }
463 }
464
465 /** The pane is placed (or no longer): the tick, and its long-poll, exist only then. */
466 setTicking(on: boolean): void {
467 this.ticking = on && !this.stopped;
468 if (!this.ticking) {
469 this.timer?.cancel();
470 this.timer = undefined;
471 this.waitS = 0;
472 return;
473 }
474 this.waitS = MAX_WAIT_S;
475 this.arm(0);
476 }
477
478 /**
479 * A bootstrap link to the web UI, minted over the socket (same uid) on the
480 * person's press: `POST /ui/boot`. The token is returned to the caller and
481 * kept by it in a module variable, drawn in the pane's tree alone — never
482 * written to `$.state` (any plugin reads it) nor to the command's text
483 * (the model reads it).
484 */
485 async mint(): Promise<UiLink | { error: string }> {
486 if (!this.endpoint) return { error: "daemon injoignable" };
487 const socketPath = this.endpoint.socket;
488 const underlying = this.port
489 .fetch(`http://ktn${UI_BOOT}`, { socketPath, method: "POST" })
490 .then(
491 (r) => ({ status: Number(r?.status) || 0, text: typeof r?.text === "string" ? r.text : "" }),
492 (err) => classifyRejection(err),
493 );
494 const r = await this.race(underlying, RACE_SLACK_MS);
495 if (r === TIMEOUT) return { error: "le daemon ne répond pas" };
496 if (isFailure(r)) return { error: r.detail };
497 if (r.status !== 200) return { error: clean(r.text, 160).trim() || `HTTP ${r.status}` };
498 const raw: any = parseJson(r.text);
499 const url = raw?.url;
500 // Only the loopback listener the daemon serves; anything else is not drawn
501 if (typeof url !== "string" || url.length > 2_000 || !(url.startsWith("http://127.0.0.1:") || url.startsWith("http://localhost:") || url.startsWith("http://[::1]:")))
502 return { error: "lien de l'UI inattendu" };
503 const expiresAt = Date.parse(String(raw?.expires_at));
504 return { url, expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0 };
505 }
506
507 /** Arms the ONE timer of the chain, if none is armed. */
508 private arm(delay: number): void {
509 if (!this.ticking || this.timer || this.stopped) return;
510 let fired = false;
511 const timer = this.port.after(delay, () => {
512 fired = true;
513 if (this.timer === timer) this.timer = undefined;
514 void this.tick();
515 });
516 if (!fired) this.timer = timer;
517 void this.port.now().then((now) => {
518 if (this.timer === timer) this.dueAt = now + delay;
519 });
520 }
521
522 private async tick(): Promise<void> {
523 if (this.tickRunning) return;
524 this.tickRunning = true;
525 try {
526 await this.refresh({ waitS: this.waitS });
527 } finally {
528 this.tickRunning = false;
529 this.arm(this.failures > 0 ? this.backoff() : TICK_MS);
530 }
531 }
532
533 /**
534 * The reconciliation's part (#775 step b): a timer a hook refused never
535 * fires, and nothing else would notice. Past three periods beyond its due
536 * time, it is cancelled and armed again. A launch that never settled is
537 * given up the same way.
538 */
539 async reconcile(): Promise<void> {
540 const now = await this.port.now();
541 if (this.launching && now - this.launchStartedAt > LAUNCH_STALE_MS) {
542 this.launching = false;
543 this.set({ launching: "failed", launchError: "le lancement n'a pas abouti" });
544 }
545 if (!this.ticking || this.tickRunning) return;
546 if (this.timer === undefined || now > this.dueAt + 3 * TICK_MS) {
547 this.timer?.cancel();
548 this.timer = undefined;
549 this.arm(0);
550 }
551 }
552}
553hooks/mod/contract.ts 111 lines1// contract.ts — the constants this mod shares with ktn-linter and with the
2// engine, each with where it was measured or where the other side writes it.
3//
4// Nothing here is computed: a value that the daemon owns is read from the
5// daemon (endpoint.json, /status, /api/view), and the few constants a client
6// must know beforehand are pinned below with their source, so a change on
7// the other side is a diff someone reads, not a silent drift.
8
9/** The ktn-linter commit the constants and the fixtures were taken from. */
10export const KTN_LINTER_SHA = "bb5c349d2009ef79df8df1b9a5e2ba7758cb1827";
11
12/**
13 * The oldest Claude Code the spike of kodflow/ktn-linter#774 measured
14 * (2.1.299 and 2.1.301 only, no bisection below): the mod stays inert under
15 * it, with one status line saying why.
16 */
17export const MIN_ENGINE = "2.1.299";
18
19/**
20 * The activation probe's budget, fixed by the same spike: one `$.fs` call
21 * costs 1 to 9 ms, `session.start` is awaited before the first prompt, so the
22 * probe stops after three listings or 250 ms and answers "undetermined".
23 */
24export const PROBE_MAX_CALLS = 3;
25export const PROBE_MAX_MS = 250;
26
27/**
28 * pkg/daemonaddr/socket.go `ProtocolVersion`: the daemon's wire version, the
29 * `protocol_version` of /status and the `schema` of every /api/view answer.
30 */
31export const PROTOCOL_VERSION = "v1";
32
33/** pkg/daemonctl/files.go `EndpointSchema`: endpoint.json's layout version. */
34export const ENDPOINT_SCHEMA = 1;
35
36/**
37 * pkg/daemonctl/layout.go `CheckSocketPath`: the daemon refuses to bind a
38 * socket path over 96 bytes (the engine itself reaches 107, the Linux
39 * `sun_path` limit, measured by #774). A pointer naming a longer one was not
40 * written by a daemon that could serve it.
41 */
42export const SOCKET_MAX_BYTES = 96;
43
44/**
45 * pkg/userdirs `Anchor()`: endpoint.json sits under HOME alone, whatever
46 * XDG_STATE_HOME says, so a client finds it when the state is redirected.
47 * base_xdg.go: `~/.local/state`; base_darwin.go: `~/Library/Application
48 * Support`. Windows (LOCALAPPDATA) is out of the mod's support (#774).
49 */
50export const ANCHORS = [
51 ".local/state/ktn-linter/endpoint.json",
52 "Library/Application Support/ktn-linter/endpoint.json",
53] as const;
54
55/** pkg/view/wire.go `RoutePath`, mounted on the socket only. */
56export const VIEW_ROUTE = "/api/view";
57
58/** pkg/view/query.go `MaxWait`: the long-poll's wait while a pane is placed. */
59export const MAX_WAIT_S = 20;
60
61/**
62 * pkg/serve/ui.go `uiBootPath`: the socket route that mints a bootstrap link
63 * to the web UI, of class `view` (read-only, pkg/webui) — the only class it
64 * grants, so a token that went through a mod never opens an admin session.
65 */
66export const UI_BOOT = "/ui/boot";
67
68/**
69 * A rendered tree draws its first 100 000 characters (the engine's limit):
70 * the pane stops its rows at this budget and says how many it left out.
71 */
72export const RENDER_BUDGET = 90_000;
73
74/** pkg/view/wire.go `CodeWorkspaceNotServed`. */
75export const NOT_SERVED = "workspace_not_served";
76
77/**
78 * pkg/view/build.go `MaxResponseBytes` is 1 MiB; the engine's fetch takes
79 * 4 MiB. Past 1.5 MiB the answer is not one a daemon of this protocol sends,
80 * and parsing it would hold the worker every mod shares: it is refused unread.
81 */
82export const MAX_BODY_CHARS = 1_572_864;
83
84/** pkg/view/build.go `MaxLimit`: the most nodes one window holds. */
85export const MAX_NODES = 1_000;
86
87/**
88 * `$.http.fetch` has one fixed deadline, 30 s, and no signal (#774). The mod
89 * races its own: the request's wait plus 5 s.
90 */
91export const RACE_SLACK_MS = 5_000;
92
93/** After this many failures in a row, the turn's refresh waits its backoff. */
94export const BREAKER_AFTER = 5;
95export const BACKOFF_MIN_MS = 3_000;
96export const BACKOFF_MAX_MS = 60_000;
97
98/** The launch button: `daemon ensure` gets 10 s, then a look every 500 ms for 10 s. */
99export const LAUNCH_TIMEOUT_MS = 10_000;
100export const LAUNCH_POLL_MS = 500;
101
102/** What a daemon string may take in a drawing, and an error the button shows. */
103export const MAX_TEXT = 300;
104
105/** The plugin's name, which `$.state` keys and the pane id are scoped by. */
106export const PLUGIN = "kodflow-ktn";
107/** `/ktn` belongs to kodflow-devops (#774): the mod's command is this one. */
108export const COMMAND = "ktn-view";
109/** The pane's id, the same word. */
110export const PANE_ID = "ktn-view";
111hooks/mod/engine.ts 23 lines1// engine.ts — the engine's version against the mod's floor. Pure.
2
3/** `2.1.299`, `2.1.299-dev…` → [2, 1, 299]; undefined when not a release. */
4export function release(v: string | undefined): number[] | undefined {
5 if (typeof v !== "string") return undefined;
6 const core = v.split("-")[0]!.split(".");
7 if (core.length !== 3) return undefined;
8 const nums = core.map((p) => (/^\d{1,6}$/.test(p) ? Number(p) : NaN));
9 return nums.some(Number.isNaN) ? undefined : nums;
10}
11
12/**
13 * Whether `version` is at or above `floor`. A version that is not spelled
14 * as a release is below it: the mod was measured on releases only.
15 */
16export function atLeast(version: string | undefined, floor: string): boolean {
17 const a = release(version);
18 const b = release(floor);
19 if (!a || !b) return false;
20 for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! > b[i]!;
21 return true;
22}
23hooks/mod/project.ts 183 lines1// project.ts — is the session in a Go project, and which workspace would the
2// daemon serve for it. Pure: the file system is a lister handed in.
3//
4// It is ktn-linter's `daemonaddr.ResolveProject` (#743), walked the same way,
5// and it is held to the SAME golden table (tests/fixtures/resolve-project.json,
6// copied from the Go test at a pinned commit): a second predicate that
7// drifted from the daemon's would light the mod up where the hooks see no
8// project, or keep it dark where they serve one.
9//
10// - git root: the nearest directory holding `.git`, a folder (main
11// checkout) or a FILE (linked worktree); none outside a repository.
12// - module roots: every directory from the start up to that boundary (or
13// the file system's root) holding a regular `go.mod` or `go.work`,
14// deepest first. Crossing a `vendor/` directory discards what was found
15// below it: vendored copies are other modules.
16// - module root: the nearest go.work among them, else the nearest go.mod.
17// - Go: at least one module root. A repository whose modules all sit in
18// subfolders is NOT Go from its root — the resolver's definition, which
19// `activation: always` overrides.
20//
21// One listing per level, walked upward only: nothing below the start is ever
22// read, so a `bazel-out` link to a tree holding a go.mod is never followed.
23// The walk stops at the boundary, or at the budget (#774: three listings,
24// 250 ms), and then says so: an incomplete walk is UNDETERMINED, never a
25// guess.
26
27/** One directory entry, as `$.fs.list` gives it. */
28export type Entry = {
29 name: string;
30 kind: "file" | "dir" | "other";
31 isLink: boolean;
32};
33
34/** Lists a directory; rejects when it cannot be read. */
35export type Lister = (dir: string) => Promise<readonly Entry[]>;
36
37export type Resolution = {
38 /** Where the walk started, as given (absolute). */
39 dir: string;
40 /** "" outside a repository, or when the walk stopped short of it. */
41 gitRoot: string;
42 /** Deepest first. */
43 moduleRoots: string[];
44 /** The nearest go.work, else the nearest go.mod; "" for none. */
45 moduleRoot: string;
46 isGo: boolean;
47 /** False when the budget, a deadline or an unreadable folder cut the walk. */
48 complete: boolean;
49 /** Listings made. */
50 calls: number;
51};
52
53export type Activation = "auto" | "always" | "off";
54export type Verdict = "active" | "inactive" | "undetermined";
55
56export type Budget = {
57 /** At most this many listings; Infinity for none. */
58 maxCalls: number;
59 /** Answers true once the time is spent; checked before every listing. */
60 expired?: () => Promise<boolean>;
61};
62
63const VENDOR = "vendor";
64
65/** The parent of an absolute POSIX path; "/" is its own. */
66export function parentOf(dir: string): string {
67 const cut = dir.lastIndexOf("/");
68 return cut <= 0 ? "/" : dir.slice(0, cut);
69}
70
71export function baseOf(dir: string): string {
72 return dir.slice(dir.lastIndexOf("/") + 1);
73}
74
75/** Drops a trailing slash, keeping "/". */
76export function clean(dir: string): string {
77 let d = dir;
78 while (d.length > 1 && d.endsWith("/")) d = d.slice(0, -1);
79 return d;
80}
81
82// A go.mod or go.work counts when it is a regular file. A link is counted as
83// the file it most likely leads to: the Go side stats through it, and telling
84// a link to a folder from one to a file would cost a call per level.
85const isMarkerFile = (e: Entry | undefined): boolean =>
86 e !== undefined && (e.kind === "file" || e.isLink);
87
88/**
89 * Walks from `start` (absolute) up to the git root, the way
90 * `daemonaddr.ResolveProject` does, within the budget.
91 */
92export async function resolveProject(
93 start: string,
94 list: Lister,
95 budget: Budget,
96): Promise<Resolution> {
97 const dir = clean(start);
98 const out: Resolution = {
99 dir,
100 gitRoot: "",
101 moduleRoots: [],
102 moduleRoot: "",
103 isGo: false,
104 complete: false,
105 calls: 0,
106 };
107 // [dir, isWork], deepest first
108 let markers: [string, boolean][] = [];
109 let current = dir;
110 for (;;) {
111 if (out.calls >= budget.maxCalls) break;
112 if (budget.expired && (await budget.expired())) break;
113 let entries: readonly Entry[];
114 try {
115 out.calls++;
116 entries = await list(current);
117 } catch {
118 break;
119 }
120 const byName = new Map<string, Entry>();
121 for (const e of entries) byName.set(e.name, e);
122 const isWork = isMarkerFile(byName.get("go.work"));
123 if (isWork || isMarkerFile(byName.get("go.mod")))
124 markers.push([current, isWork]);
125 if (baseOf(current) === VENDOR) markers = [];
126 // `.git` of any kind marks the boundary (Lstat on the Go side)
127 if (byName.has(".git")) {
128 out.gitRoot = current;
129 out.complete = true;
130 break;
131 }
132 const parent = parentOf(current);
133 if (parent === current) {
134 out.complete = true;
135 break;
136 }
137 current = parent;
138 }
139 out.moduleRoots = markers.map(([d]) => d);
140 out.moduleRoot =
141 markers.find(([, w]) => w)?.[0] ?? markers[0]?.[0] ?? "";
142 out.isGo = out.complete && markers.length > 0;
143 return out;
144}
145
146/**
147 * The root a session in this directory declares to the per-user daemon
148 * (`ProjectRootsValue.WorkspaceRoot`, #758): the module root, else the git
149 * root, else the start. On an incomplete walk, the best seen so far.
150 */
151export function workspaceRoot(r: Resolution): string {
152 return r.moduleRoot || r.gitRoot || r.dir;
153}
154
155/**
156 * The highest directory a search upward may reach (`SearchTop`): the git
157 * root, else the highest module root, else the start.
158 */
159export function searchTop(r: Resolution): string {
160 return r.gitRoot || r.moduleRoots[r.moduleRoots.length - 1] || r.dir;
161}
162
163/** What the mod does with a resolution, under the person's setting. */
164export function verdictOf(r: Resolution, activation: Activation): Verdict {
165 if (activation === "off") return "inactive";
166 if (activation === "always") return "active";
167 if (!r.complete) return "undetermined";
168 return r.isGo ? "active" : "inactive";
169}
170
171export function parseActivation(v: unknown): Activation {
172 return v === "always" || v === "off" ? v : "auto";
173}
174
175/**
176 * Whether a resolution is worth walking again at the next turn: a folder may
177 * have gained its go.mod (`go mod init`), and an undetermined walk may finish
178 * on a quieter disk. A Go project stays one until the root moves.
179 */
180export function worthReprobing(r: Resolution | undefined): boolean {
181 return r === undefined || !r.complete || !r.isGo;
182}
183hooks/mod/view.ts 267 lines1// view.ts — what kodflow-ktn draws, from what it holds. Pure: the element
2// table is the surface's (`$.ui.resolve(e)`), `h` the module's JSX factory;
3// nothing here reads `$`, fetches or writes, so a drawing is a function of
4// the snapshot and nothing else.
5//
6// Only Box, Text, Button and Code are used, present on every surface; every
7// string from the daemon was sanitised before it got here (daemon.ts), and
8// `safe` turns any throw into a fallback tree of Box and Text, never a
9// closed pane or an empty band.
10import type { KtnCounts, KtnDaemon, KtnNode, KtnSnap, KtnTree } from "../../types";
11import { RENDER_BUDGET } from "./contract.ts";
12import { clean } from "./sanitize.ts";
13
14declare const h: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => any;
15
16export type Els = Record<string, unknown>;
17export type Actions = {
18 launch?: () => void;
19 /** Opens or folds a directory or a file, by its id. */
20 toggle?: (id: string) => void;
21 /** Mints a link to the web UI. */
22 openUi?: () => void;
23};
24
25/** h without the empty children a conditional leaves behind. */
26export function el(type: unknown, props: Record<string, unknown> | null, ...kids: unknown[]): any {
27 return h(type, props, ...kids.flat().filter((k) => k !== null && k !== undefined && k !== false));
28}
29
30export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
31
32export function countsText(c: KtnCounts): string {
33 const s = c.severity;
34 return `${plural(c.findings, "constat", "constats")} · ${s.error} E · ${s.warning} W · ${s.info} I`;
35}
36
37/**
38 * The status line under the prompt, or undefined for none. The engine heads
39 * it with the plugin's name already ("kodflow-ktn: …").
40 */
41export function statusText(snap: KtnSnap): string | undefined {
42 if (snap.activation === "floor") return undefined; // said once, at the start
43 if (snap.activation !== "active") return undefined;
44 const d = snap.daemon;
45 if (!d || d.kind === "loading") return "…";
46 switch (d.kind) {
47 case "ready":
48 return d.totals && d.totals.findings > 0
49 ? countsText(d.totals)
50 : "✓ aucun constat";
51 case "unreachable":
52 return d.launching === "starting" ? "démarrage du daemon…" : "daemon injoignable";
53 case "incompatible":
54 return "API incompatible";
55 case "pending":
56 return "workspace en attente d'approbation";
57 case "refused":
58 return "workspace non servi";
59 case "policy":
60 return "requête refusée (politique)";
61 }
62}
63
64/** The line that names the state, for the band and the pane's head. */
65export function stateLine(snap: KtnSnap): { text: string; color?: string } {
66 if (snap.activation === "inactive" || snap.activation === "undetermined")
67 return { text: "ktn · hors projet Go", color: "inactive" };
68 if (snap.activation !== "active") return { text: "ktn · inactif", color: "inactive" };
69 const d = snap.daemon;
70 const why = (s: string) => (d?.detail ? `${s} — ${clean(d.detail, 200)}` : s);
71 switch (d?.kind) {
72 case undefined:
73 case "loading":
74 return { text: "ktn · chargement…", color: "inactive" };
75 case "unreachable":
76 return { text: why("ktn · daemon injoignable"), color: "warning" };
77 case "incompatible":
78 return { text: why("ktn · API incompatible"), color: "error" };
79 case "pending":
80 return { text: why("ktn · en attente d'approbation"), color: "warning" };
81 case "refused":
82 return { text: why("ktn · workspace refusé / non servi"), color: "error" };
83 case "policy":
84 return { text: why("ktn · requête refusée par une politique"), color: "error" };
85 case "ready":
86 return d.totals && d.totals.findings > 0
87 ? { text: `ktn · ${countsText(d.totals)}` }
88 : { text: "ktn · propre : aucun constat", color: "success" };
89 }
90}
91
92/** What the person can do about the state: a button, a command, a note. */
93export function remedy(els: Els, d: KtnDaemon | undefined, actions: Actions): any[] {
94 if (!d) return [];
95 const out: any[] = [];
96 if (d.kind === "unreachable") {
97 if (d.launching === "starting")
98 out.push(el(els.Text, { dimColor: true }, "démarrage du daemon…"));
99 else if (d.launch === "button" && actions.launch)
100 out.push(
101 el(els.Button, { key: "ktn-launch", label: "Lancer le daemon", onPress: actions.launch }),
102 );
103 if (d.launch === "command" && d.command)
104 out.push(
105 el(els.Text, { dimColor: true }, "à lancer dans un terminal :"),
106 el(els.Code, { source: clean(d.command, 600), language: "sh" }),
107 );
108 if (d.launching === "failed" && d.launchError)
109 out.push(el(els.Text, { color: "error" }, `échec du lancement : ${clean(d.launchError, 300)}`));
110 }
111 if (d.kind === "pending" && d.command)
112 out.push(
113 el(els.Text, { dimColor: true }, "à lancer dans votre propre terminal :"),
114 el(els.Code, { source: clean(d.command, 600), language: "sh" }),
115 );
116 return out;
117}
118
119/**
120 * The band above the prompt: drawn only while there is something to act on
121 * (the daemon to start, an approval to give, a refusal to read). A served
122 * workspace leaves the band to others; the status line says the counts.
123 */
124export function band(els: Els, snap: KtnSnap, actions: Actions): any | undefined {
125 if (snap.activation !== "active") return undefined;
126 const d = snap.daemon;
127 if (!d || d.kind === "ready" || d.kind === "loading") return undefined;
128 const line = stateLine(snap);
129 return el(
130 els.Box,
131 { flexDirection: "column" },
132 el(els.Text, line.color ? { color: line.color, wrap: "truncate-end" } : { wrap: "truncate-end" }, line.text),
133 remedy(els, d, actions),
134 );
135}
136
137/** A drawing that cannot throw: any failure becomes a two-element tree. */
138export function safe(els: Els, draw: () => any): any {
139 try {
140 return draw();
141 } catch (err) {
142 return fallback(els, err);
143 }
144}
145
146export function fallback(els: Els, err: unknown): any {
147 return h(
148 els.Box,
149 { flexDirection: "column" },
150 h(els.Text, { color: "warning" }, "ktn · rendu impossible"),
151 h(els.Text, { dimColor: true }, clean(String((err as any)?.message ?? err), 120)),
152 );
153}
154
155/** "7 · 1E 4W 2I", the severities that have any. */
156export function shortCounts(c: KtnCounts | undefined): string {
157 if (!c) return "";
158 const s = c.severity;
159 const parts = [s.error ? `${s.error}E` : "", s.warning ? `${s.warning}W` : "", s.info ? `${s.info}I` : ""].filter(Boolean);
160 return parts.length ? `${c.findings} · ${parts.join(" ")}` : String(c.findings);
161}
162
163export function phasesText(c: KtnCounts): string {
164 const keys = Object.keys(c.phase).sort((a, b) => Number(a) - Number(b));
165 return keys.length ? `phases ${keys.map((k) => `${k}:${c.phase[k]}`).join(" ")}` : "";
166}
167
168export function ago(ms: number): string {
169 const s = Math.max(0, Math.round(ms / 1000));
170 if (s < 60) return `${s} s`;
171 const m = Math.round(s / 60);
172 return m < 60 ? `${m} min` : `${Math.round(m / 60)} h`;
173}
174
175const SEVERITY_COLOR: Record<string, string> = { error: "error", warning: "warning" };
176
177/** One row of the tree: a pressable directory or file, or a finding. */
178function row(els: Els, n: KtnNode, actions: Actions): { tree: any; size: number } {
179 const indent = " ".repeat(Math.min(n.depth, 32));
180 if (n.kind === "finding") {
181 const text = `${indent}L${n.line ?? 0} ${n.rule ?? ""} ${n.message ?? ""}`;
182 const color = SEVERITY_COLOR[n.severity ?? ""];
183 return {
184 tree: el(els.Text, color ? { color, wrap: "truncate-end" } : { dimColor: true, wrap: "truncate-end" }, text),
185 size: text.length,
186 };
187 }
188 const mark = n.expanded ? "▾" : "▸";
189 const label = `${indent}${mark} ${n.name}${n.kind === "dir" && n.name !== "." ? "/" : ""}`;
190 const counts = shortCounts(n.counts);
191 // The root is always open: drawn, never pressed
192 if (n.id === "dir:.")
193 return { tree: el(els.Text, { bold: true, wrap: "truncate-end" }, `${label} ${counts}`), size: label.length + counts.length };
194 return {
195 tree: el(
196 els.Button,
197 { key: n.id, plain: true, onPress: () => actions.toggle?.(n.id) },
198 label,
199 " ",
200 el(els.Text, { dimColor: true }, counts),
201 ),
202 size: label.length + counts.length + 2,
203 };
204}
205
206export type PaneData = {
207 snap: KtnSnap;
208 tree?: KtnTree;
209 /** The link the person minted, held by the module alone. */
210 link?: { url: string; label: string };
211 now: number;
212};
213
214/**
215 * The pane: the synthesis first (the state, the workspace, the totals by
216 * severity and by phase, how long since the last answer, the launch button
217 * when the daemon is down), then the collapsible tree as the daemon served
218 * it — directories folded with their counts, the children of the opened ones
219 * only. The pane does not window the tree: the engine scrolls it, and the
220 * daemon bounds it (`truncated`, then "… voir l'UI web"). Its rows stop at a
221 * text budget below the engine's 100 000 characters, saying what was left.
222 */
223export function pane(els: Els, data: PaneData, actions: Actions): any {
224 const { snap } = data;
225 const line = stateLine(snap);
226 const head: any[] = [
227 el(els.Text, line.color ? { color: line.color, wrap: "truncate-end" } : { bold: true, wrap: "truncate-end" }, line.text),
228 ];
229 if (snap.activation !== "active") return el(els.Box, { flexDirection: "column" }, head);
230 const d = snap.daemon;
231 if (snap.workspace) head.push(el(els.Text, { dimColor: true, wrap: "truncate-start" }, clean(snap.workspace, 300)));
232 if (d?.kind === "ready" && d.totals) {
233 const p = phasesText(d.totals);
234 if (p) head.push(el(els.Text, { dimColor: true, wrap: "truncate-end" }, p));
235 const age = d.at ? `actualisé il y a ${ago(data.now - d.at)}` : "";
236 const engine = d.engine ? `moteur ${clean(d.engine, 30)}` : "";
237 if (age || engine) head.push(el(els.Text, { dimColor: true }, [age, engine].filter(Boolean).join(" · ")));
238 }
239 head.push(...remedy(els, d, actions));
240 if (d?.kind === "ready" && actions.openUi) {
241 head.push(
242 data.link
243 ? el(els.Link, { href: data.link.url, label: data.link.label })
244 : el(els.Button, { key: "ktn-open-ui", label: "Ouvrir l'UI web", onPress: actions.openUi }),
245 );
246 }
247 const rows: any[] = [];
248 const t = d?.kind === "ready" ? data.tree : undefined;
249 if (t && t.workspace === snap.workspace) {
250 let used = 0;
251 let left = 0;
252 for (const n of t.nodes) {
253 if (used >= RENDER_BUDGET) {
254 left++;
255 continue;
256 }
257 const r = row(els, n, actions);
258 used += r.size;
259 rows.push(r.tree);
260 }
261 const more = left + (t.truncated ? t.hidden : 0);
262 if (more > 0 || t.truncated)
263 rows.push(el(els.Text, { dimColor: true }, `… ${more > 0 ? `${more} de plus, ` : ""}voir l'UI web`));
264 }
265 return el(els.Box, { flexDirection: "column" }, head, rows);
266}
267hooks/mod/daemon.ts 192 lines1// daemon.ts — what the daemon's answers mean, read defensively. Pure.
2//
3// The spike of kodflow/ktn-linter#774 measured how each failure reaches a mod
4// through `$.http.fetch`, and the states are told apart by that shape:
5//
6// daemon absent rejected: ECONNREFUSED (TCP) or ENOENT (socket)
7// daemon silent rejected at 30 s: "aborted … no complete answer"
8// (the mod's own race fires first, at wait + 5 s)
9// HTTP error resolved, ok:false
10// invalid JSON resolved; JSON.parse throws IN THE MOD
11// answer too big rejected: "over the 4194304-byte limit"
12// socket path refused rejected: "… refused: takes an absolute path"
13// refused by a policy rejected with the refusal's own text (a hook's
14// or another plugin `{ deny }` or the organization's web-fetch policy)
15//
16// The last one is NOT "daemon unreachable": offering to launch a daemon the
17// session is not allowed to reach would only fail again.
18import type { KtnCounts, KtnModel, KtnNode } from "../../types";
19import { MAX_BODY_CHARS, MAX_NODES, NOT_SERVED, PROTOCOL_VERSION } from "./contract.ts";
20import { clean, isPlain } from "./sanitize.ts";
21
22export type Failure =
23 | { kind: "absent"; detail: string }
24 | { kind: "silent"; detail: string }
25 | { kind: "broken"; detail: string }
26 | { kind: "policy"; detail: string };
27
28// A transport failure's own words, as the engine and the OS spell them
29const TRANSPORT = [
30 "ECONNREFUSED",
31 "ENOENT",
32 "ECONNRESET",
33 "EPIPE",
34 "EACCES",
35 "ENOTSOCK",
36 "ETIMEDOUT",
37 "EHOSTUNREACH",
38 "socket hang up",
39];
40
41/** A rejected fetch, by its message. */
42export function classifyRejection(err: unknown): Failure {
43 const msg = String((err as any)?.message ?? err);
44 const detail = clean(msg, 200);
45 if (msg.includes("no complete answer") || msg.includes("aborted"))
46 return { kind: "silent", detail: "le daemon ne répond pas" };
47 if (msg.includes("-byte limit"))
48 return { kind: "broken", detail: "réponse trop grosse" };
49 if (msg.includes("takes an absolute path"))
50 return { kind: "broken", detail: "chemin de socket refusé" };
51 for (const word of TRANSPORT)
52 if (msg.includes(word)) return { kind: "absent", detail };
53 return { kind: "policy", detail };
54}
55
56/** JSON.parse that answers undefined instead of throwing, and refuses a body too big to parse. */
57export function parseJson(text: unknown): unknown {
58 if (typeof text !== "string" || text.length > MAX_BODY_CHARS) return undefined;
59 try {
60 return JSON.parse(text);
61 } catch {
62 return undefined;
63 }
64}
65
66const num = (v: unknown): number =>
67 typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
68
69// "1" to "99": a phase number as the daemon keys it
70const isPhaseKey = (k: string): boolean =>
71 k.length >= 1 && k.length <= 2 && [...k].every((c) => c >= "0" && c <= "9");
72
73export function countsOf(raw: any): KtnCounts {
74 const sev = raw && typeof raw === "object" ? raw.severity : undefined;
75 const phase: Record<string, number> = {};
76 const p = raw && typeof raw === "object" ? raw.phase : undefined;
77 if (p && typeof p === "object")
78 for (const k of Object.keys(p).slice(0, 16))
79 if (isPhaseKey(k)) phase[k] = num(p[k]);
80 return {
81 findings: num(raw?.findings),
82 severity: {
83 error: num(sev?.error),
84 warning: num(sev?.warning),
85 info: num(sev?.info),
86 },
87 phase,
88 };
89}
90
91const KINDS = new Set(["dir", "file", "finding"]);
92
93function nodeOf(raw: any): KtnNode | undefined {
94 if (!raw || typeof raw !== "object") return undefined;
95 // An id goes back to the daemon as it came (expand=): never altered, so
96 // one with a control character or past any sane length is dropped instead
97 if (!isPlain(raw.id, 1_100) || !KINDS.has(raw.kind)) return undefined;
98 const node: KtnNode = {
99 id: raw.id,
100 kind: raw.kind,
101 name: clean(raw.name, 200),
102 depth: Math.min(num(raw.depth), 64),
103 expanded: raw.expanded === true,
104 };
105 if (isPlain(raw.parent, 1_100)) node.parent = raw.parent;
106 if (raw.kind === "finding") {
107 node.line = num(raw.line);
108 node.rule = clean(raw.rule, 80);
109 node.severity = clean(raw.severity, 16);
110 node.phase = num(raw.phase);
111 node.message = clean(raw.message, 200);
112 } else node.counts = countsOf(raw.counts);
113 return node;
114}
115
116export type ModelRead =
117 | { ok: true; model: KtnModel }
118 | { ok: false; incompatible?: string; detail: string };
119
120/** An /api/view answer of 200, read field by field; never a throw. */
121export function parseModel(text: string): ModelRead {
122 if (text.length > MAX_BODY_CHARS)
123 return { ok: false, detail: "réponse trop grosse" };
124 const raw: any = parseJson(text);
125 if (!raw || typeof raw !== "object")
126 return { ok: false, detail: "réponse illisible (JSON invalide)" };
127 if (raw.schema !== PROTOCOL_VERSION)
128 return {
129 ok: false,
130 incompatible: clean(String(raw.schema), 20),
131 detail: "schéma du modèle inconnu",
132 };
133 const rawNodes: unknown[] = Array.isArray(raw.tree?.nodes) ? raw.tree.nodes : [];
134 const nodes: KtnNode[] = [];
135 for (const n of rawNodes) {
136 if (nodes.length >= MAX_NODES) break;
137 const node = nodeOf(n);
138 if (node) nodes.push(node);
139 }
140 const total = num(raw.window?.total);
141 return {
142 ok: true,
143 model: {
144 rev: num(raw.rev),
145 workspace: clean(raw.workspace, 300),
146 state: clean(raw.state, 30),
147 totals: countsOf(raw.totals),
148 nodes,
149 // Cut by the server or by us: the rest is the web UI's
150 truncated: raw.truncated === true || rawNodes.length > nodes.length,
151 hidden: Math.max(0, total - nodes.length),
152 },
153 };
154}
155
156export type StatusRead =
157 | { ok: true; protocol: string; pending?: { reason: string; command: string } }
158 | { ok: false; detail: string };
159
160/**
161 * /status: the protocol it speaks and, when the workspace waits for the
162 * person's approval, the exact command the daemon says to run.
163 */
164export function parseStatus(text: string, workspace: string): StatusRead {
165 const raw: any = parseJson(text);
166 if (!raw || typeof raw !== "object")
167 return { ok: false, detail: "réponse illisible (JSON invalide)" };
168 const protocol = typeof raw.protocol_version === "string" ? raw.protocol_version : "";
169 const list: any[] = Array.isArray(raw.pending_workspaces) ? raw.pending_workspaces : [];
170 const hit = list.find((p) => p && p.root === workspace);
171 return {
172 ok: true,
173 protocol: clean(protocol, 20),
174 pending: hit
175 ? { reason: clean(hit.reason, 200), command: clean(hit.command, 600) }
176 : undefined,
177 };
178}
179
180/** The reason in a refusal's body: the typed error's message, or its text. */
181export function refusalOf(text: string): string {
182 const raw: any = parseJson(text);
183 const msg = raw?.error?.message;
184 return clean(typeof msg === "string" ? msg : text, 200).trim();
185}
186
187export function isNotServed(status: number, text: string): boolean {
188 if (status === 403) return text.includes("is not served");
189 if (status === 404) return (parseJson(text) as any)?.error?.code === NOT_SERVED;
190 return false;
191}
192hooks/mod/endpoint.ts 62 lines1// endpoint.ts — where the per-user daemon is, read from the ONE discovery
2// anchor ktn-linter writes for its clients: endpoint.json under HOME (#757,
3// pkg/daemonctl `EndpointValue`). Pure.
4//
5// The socket path is never computed here: it depends on the runtime directory
6// and the environment, and a second resolver in JS would agree with the Go one
7// on one platform out of three. The pointer names it; the mod reads it.
8// state.json is not a source either: a clean stop removes it, which is exactly
9// when the launch button has to appear.
10import { ANCHORS, ENDPOINT_SCHEMA, SOCKET_MAX_BYTES } from "./contract.ts";
11
12export type Endpoint = {
13 socket: string;
14 /** The absolute binary the launcher starts; "" when the pointer has none. */
15 exe: string;
16 version: string;
17};
18
19export type EndpointRead =
20 | { ok: true; endpoint: Endpoint }
21 | { ok: false; reason: string };
22
23/** The anchors to try, in order, under an absolute HOME. */
24export function anchorPaths(home: string | undefined): string[] {
25 if (!home || !home.startsWith("/")) return [];
26 const base = home.endsWith("/") ? home.slice(0, -1) : home;
27 return ANCHORS.map((rel) => `${base}/${rel}`);
28}
29
30const utf8Length = (s: string) => new TextEncoder().encode(s).length;
31
32/** Reads endpoint.json's text; every failure is a reason, never a throw. */
33export function parseEndpoint(text: string): EndpointRead {
34 let raw: any;
35 try {
36 raw = JSON.parse(text);
37 } catch {
38 return { ok: false, reason: "endpoint.json illisible (JSON invalide)" };
39 }
40 if (!raw || typeof raw !== "object")
41 return { ok: false, reason: "endpoint.json illisible" };
42 if (raw.schema !== ENDPOINT_SCHEMA)
43 return {
44 ok: false,
45 reason: `endpoint.json de schéma ${String(raw.schema)}, ${ENDPOINT_SCHEMA} attendu`,
46 };
47 const socket = raw.socket;
48 if (typeof socket !== "string" || !socket.startsWith("/") || socket.includes("\0"))
49 return { ok: false, reason: "endpoint.json sans socket absolu" };
50 if (utf8Length(socket) > SOCKET_MAX_BYTES)
51 return {
52 ok: false,
53 reason: `socket trop long (${utf8Length(socket)} octets, ${SOCKET_MAX_BYTES} au plus)`,
54 };
55 const exe =
56 typeof raw.exe === "string" && raw.exe.startsWith("/") && !raw.exe.includes("\0")
57 ? raw.exe
58 : "";
59 const version = typeof raw.version === "string" ? raw.version : "";
60 return { ok: true, endpoint: { socket, exe, version } };
61}
62hooks/mod/sanitize.ts 33 lines1// sanitize.ts — every string that came from the daemon (paths, messages,
2// rules, the command it suggests) passes here before it is drawn. Pure.
3//
4// The daemon already sanitises (pkg/view `Sanitize`); this is the client's own
5// guard, so a daemon of another version, or something else listening on the
6// socket, cannot move the cursor or recolour the terminal. A plain loop over
7// code points: no regular expression ever runs on external data.
8
9/** C0 (tab and newline included), DEL and C1 become a space; cut at `max`. */
10export function clean(input: unknown, max = 300): string {
11 if (typeof input !== "string") return "";
12 let out = "";
13 let n = 0;
14 for (const ch of input) {
15 if (n >= max) return out + "…";
16 const c = ch.codePointAt(0)!;
17 out += c < 0x20 || (c >= 0x7f && c <= 0x9f) ? " " : ch;
18 n++;
19 }
20 return out;
21}
22
23/** Whether a string is safe to send back as is (an id, a path): no control. */
24export function isPlain(input: unknown, max: number): input is string {
25 if (typeof input !== "string" || input.length === 0 || input.length > max)
26 return false;
27 for (let i = 0; i < input.length; i++) {
28 const c = input.charCodeAt(i);
29 if (c < 0x20 || (c >= 0x7f && c <= 0x9f)) return false;
30 }
31 return true;
32}
33types/index.d.ts 133 lines1// The values kodflow-ktn keeps in `$.state` for the session. `$.state` is
2// the DISPLAY copy: every transition writes the module's own variable first
3// (the fast source, lost on a reload) and then this snapshot (what the
4// drawings subscribe to, kept across a hot reload). Any plugin may read any
5// value here, so nothing secret is ever written: no token, no credential.
6
7/**
8 * Where the mod stands in this session:
9 * inert nothing draws here (`-p`, the Agent SDK, Kepler's launch
10 * mode): no probe, no daemon call, nothing opened;
11 * floor Claude Code older than the version the mod was measured on;
12 * probing the activation probe is under way;
13 * active a Go project (or `activation: always`);
14 * inactive not a Go project (or `activation: off`);
15 * undetermined the probe ran out of budget: drawn as inactive.
16 */
17export type KtnActivation =
18 | "inert"
19 | "floor"
20 | "probing"
21 | "active"
22 | "inactive"
23 | "undetermined";
24
25/** Findings counted in total, by effective severity and by phase. */
26export type KtnCounts = {
27 findings: number;
28 severity: { error: number; warning: number; info: number };
29 /** By phase number, as the daemon keys it ("1" to "9"). */
30 phase: Record<string, number>;
31};
32
33/** One node of the view model (pkg/view): a directory, a file or a finding. */
34export type KtnNode = {
35 /** Stable: dir:<path>, file:<path>, finding:<fingerprint>. */
36 id: string;
37 kind: "dir" | "file" | "finding";
38 name: string;
39 parent?: string;
40 depth: number;
41 expanded: boolean;
42 counts?: KtnCounts;
43 line?: number;
44 rule?: string;
45 severity?: string;
46 phase?: number;
47 message?: string;
48};
49
50/** What one /api/view answer said, cut to what the mod draws. */
51export type KtnModel = {
52 rev: number;
53 workspace: string;
54 /** The engine's lifecycle state, "idle" before any engine serves it. */
55 state: string;
56 totals: KtnCounts;
57 nodes: KtnNode[];
58 /** The server's window stopped short, or the mod cut it. */
59 truncated: boolean;
60 /** Visible nodes not in this window: the web UI's to show. */
61 hidden: number;
62};
63
64/**
65 * The daemon, as the mod sees it — the seven drawn states of #776 are
66 * `activation` not active (out of a Go project) and these:
67 * loading no answer yet;
68 * unreachable no endpoint.json, nothing on the socket, a silent or
69 * broken daemon;
70 * incompatible another protocol: no further call until endpoint.json moves;
71 * pending the workspace waits for the person's approval (the command);
72 * refused the daemon does not serve it (its reason);
73 * policy the request was refused before it left (an organization's
74 * policy, a hook): distinct from unreachable, no launch offered;
75 * ready served: clean when `totals.findings` is 0.
76 */
77export type KtnDaemonKind =
78 | "loading"
79 | "unreachable"
80 | "incompatible"
81 | "pending"
82 | "refused"
83 | "policy"
84 | "ready";
85
86export type KtnDaemon = {
87 kind: KtnDaemonKind;
88 /** Why, in the daemon's or the transport's words, sanitised. */
89 detail?: string;
90 /** A command for the person to run: the approval, or the launch. */
91 command?: string;
92 /** What is offered to start the daemon: a button, a command, or nothing. */
93 launch: "button" | "command" | "none";
94 launching?: "starting" | "failed";
95 launchError?: string;
96 /** The ktn-linter version endpoint.json names. */
97 version?: string;
98 totals?: KtnCounts;
99 /** The engine's lifecycle state. */
100 engine?: string;
101 /** When the last answer came, milliseconds since the epoch. */
102 at?: number;
103};
104
105export type KtnSnap = {
106 activation: KtnActivation;
107 /** The session's project root the probe walked from. */
108 root?: string;
109 /** The root the daemon is asked about (the module root, else the git root). */
110 workspace?: string;
111 daemon?: KtnDaemon;
112};
113
114/** The pane's tree: the window of the view model the last answer served. */
115export type KtnTree = {
116 workspace: string;
117 nodes: KtnNode[];
118 truncated: boolean;
119 hidden: number;
120};
121
122declare module "claude-code" {
123 interface PluginState {
124 "kodflow-ktn": {
125 snap: KtnSnap;
126 /** The pane's tree; absent until a model was served. */
127 tree: KtnTree;
128 /** The dir: and file: ids the person opened in the pane. No secret. */
129 expanded: string[];
130 };
131 }
132}
133