SLOPSHOPPER

kodflow-ktn

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…

newpanebandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kodflow-ktn
│ ┃ ktn-view ✕ › fix the failing auth test and add an audit log call │ ┃ ktn · inactif │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ kodflow-ktn: Claude Code 2.1.299 ou plus requis

Draws

Pane · ktn-view
ktn · inactif
README

kodflow-ktn

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.

Install

/plugin install kodflow-ktn@kodflow

claude plugin disable kodflow-ktn@kodflow turns it off without touching the rest; the hooks go on as before.

Where it is active

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.

activationEffect
auto (default)active in a Go project only
alwaysactive everywhere — the net for an undetermined probe
offnever 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.

Command

/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 daemon

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:

StateWhenOffered
out of a Go projectthe probe said sonothing at all
loadingno answer yet—
unreachableno endpoint.json, nothing on the socket, a daemon silent past the request's wait + 5 s, an answer too big or unreadablethe launch button, or the command to run
incompatibleanother protocol than v1nothing: no further call until endpoint.json changes
pending approvalthe daemon waits for the personthe daemon's exact ktn-linter admin workspace approve …
refused / not servedthe daemon's reason—
clean / findingsservedthe 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.

The pane

/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.

autoOpenEffect
false (default)the pane opens on /ktn-view
truethe pane opens when a Go session starts

What it looks like

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"

One predicate, one table — and the view model

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.

Tests

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.

Source 10 files
hooks/mod/register.ts 380 lines
1// 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};
380
hooks/mod/client.ts 553 lines
1// 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}
553
hooks/mod/contract.ts 111 lines
1// 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";
111
hooks/mod/engine.ts 23 lines
1// 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}
23
hooks/mod/project.ts 183 lines
1// 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}
183
hooks/mod/view.ts 267 lines
1// 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}
267
hooks/mod/daemon.ts 192 lines
1// 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}
192
hooks/mod/endpoint.ts 62 lines
1// 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}
62
hooks/mod/sanitize.ts 33 lines
1// 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}
33
types/index.d.ts 133 lines
1// 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