SLOPSHOPPER

thoughtknot

/graph draws a graph note's first graph in a pane, with the ThoughtKnot core

paneguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thoughtknot
│ ┃ graph-editor ✕ › fix the failing auth test and add an audit log call │ ┃ [fn] │ ┃ [fn] [fn] ⏺ Read(src/auth.ts) │ ┃ [ [fn] ] ⎿ Read 6 lines │ ┃ [fn] ⏺ 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 │ │ › /graph │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · graph-editor
[fn] [fn] [fn] [ [fn] ] [fn]
Pane · graph
[fn]
README

ThoughtKnot

ThoughtKnot turns what you know into a map you can see, change and share. You write things and the links between them as plain text, and it draws them. You can edit a graph by typing or by dragging the drawing.

It is one product with several ways in. The Obsidian plugin works now, on desktop and phone, offline. A standalone app, a VS Code plugin and a JavaScript library come next.

<!-- readme:status --> Status: in development. It is not in the community plugin directory yet. It is built in ten slices. Slices 1 to 4 are done, and slice 5 is in progress. <!-- /readme:status -->

At a glance

You want toGo to
Use itHow to use ThoughtKnot
Know what it sends, and whenPrivacy and network use
Build it or change itDeveloping and CONTRIBUTING.md
See how it fits togetherHow the packages fit

What is here

  • packages/obsidian: the plugin.
  • packages/graph-spec: the graph format library in plain JavaScript. It reads graphs, checks their rules and answers questions about them. It is one file with no dependencies, so a web page or Node can load it.
  • python/graph_spec: the same library in Python. It also draws with Graphviz and runs extra checks with Grafeo. Its answers are tested against the JavaScript library on every shared case.
  • Each release carries both libraries as one archive, graph-spec-libraries-<version>.tar.gz. SHA256SUMS lists every file in the release with its SHA-256.
  • manifest.json, versions.json and styles.css: the files the plugin store reads. CI builds main.js from a tag and attaches it to each release. It is never committed.

How the packages fit

Each layer uses only the one below it. So a new host, such as a VS Code extension or a desktop app, reuses everything below the hosts as it is.

  hosts       packages/obsidian      packages/claude-code  <-  packages/app, packages/vscode
                     \                      /
  core                 packages/core          host-free: reading, drawing, edits, undo
                            |
  format      packages/graph-spec   <=>   python/graph_spec
              (JavaScript)   held to the same answers   (Python)
  • packages/graph-spec: the graph format. It reads GQL blocks and runs the spec's rules, with no dependencies. Guide: packages/graph-spec/GUIDE.md.
  • python/graph_spec: the same format in Python, function for function. It adds the Graphviz drawing and the Grafeo checks. Shared tests hold the two to the same answers. Guide: python/graph_spec/GUIDE.md.
  • packages/core: the editor core, on top of graph-spec. It works out what to draw, the problems and their fixes, zoom, edits and undo. It uses no Obsidian, editor or Node module, so it runs on a phone too. Guide: packages/core/GUIDE.md.
  • packages/obsidian: the Obsidian plugin. It puts the core's drawings and edits on Obsidian's page. Guide: packages/obsidian/GUIDE.md.
  • packages/claude-code: the Claude Code host. Its /graph <note> command draws a note's graph in a pane. It also serves a browser editor on your own machine, on the same core. Guide: packages/claude-code/GUIDE.md.
  • packages/app: the desktop app, for macOS, Windows and Linux. It runs the browser editor in a Tauri window, over a plain folder of notes, offline, with no server inside. Guide: packages/app/GUIDE.md.
  • packages/vscode: the VS Code extension. It runs the browser editor in a VS Code tab beside a Markdown note, over the note's workspace folder. Guide: packages/vscode/GUIDE.md.

Privacy and network use

The short version for every app is the privacy page. It says what is kept, where, for how long, and how to delete it.

The short version, for the plugin, the app and the browser editor, is the privacy page.

The plugin makes no network request of its own. There are no analytics, no update checks, no remote images and no databases. Graphviz, the one runtime dependency, is bundled and runs on your device.

The one exception: AI models you set up. The plugin can ask an AI model to propose changes to a graph. It sends content only to AI models you have set up yourself, and only when you send it. AI is off until you turn it on (Use AI models, in the plugin's settings) and set up a model.

Where your text ends up. A model at a remote address receives the text you are shown. It keeps it under its provider’s own terms and retention, so it leaves your control once sent. A model at localhost runs on your device, so the text stays on your device.

What happens on each send

  • You choose what is sent. It can be the whole graph, one of its views, or the things you tick. It goes as GQL: each thing with its kinds and properties, the links between them, your question and the instructions. Nothing else from the vault goes: not the note's other text, not another graph, not a comment.
  • You see what will be sent before it goes. You also see which model and address it goes to. Nothing goes until you press Send, and nothing is ever sent in the background.
  • Each model has a ceiling. This is the highest classification of note it may be sent, and you set it. A note above the ceiling is not sent to that model. A note at your highest classification is never sent to any model.
  • A note with no classification counts as the most sensitive level. So by default it goes to no model. To change that, give the note a classification. Or choose a lower level under Unlabelled notes count as, in the settings.
  • Credentials are looked for by common patterns. These include a password or secret property, an API key, an access key and a token. They also include a private key, a card number and the key of any model you set up. What matches is never sent. These are patterns, not a promise, so keep secrets out of notes you send.
  • A key stays in Obsidian's own secret store. It is never in the vault, the plugin's settings, a note or a log. A model on another machine is reached over https only. A model's address may hold no user name, password or query.
  • The answer comes back as a proposal. It is written into the note as a change set beside the graph. You accept or reject each change. An AI never writes to the graph.
  • Every send is recorded in the plugin's own data, without the text sent. The record keeps a SHA-256 digest of the text, its size, the kind of choice and how many things and links. It also keeps the model's name and address, the time and the result. The last 200 sends are kept. The command "Clear the record of sends to AI models" clears them.

Where requests go

Requests go straight from your device to the address you gave, through Obsidian's own request function. There is no server of ours in between, and no AI vendor's library inside the plugin.

Obsidian follows redirects; point a model only at an address you trust. Obsidian also adds headers of its own to each request, beyond those shown before you send.

Developing

You need Node 24 or later. CONTRIBUTING.md says how the code is written and how each rule is checked.

npm ci
git config core.hooksPath .githooks
npm test
npm run typecheck
npm run lint
npm run build

The second line turns on the pre-commit check. It refuses any staged file that looks confidential or personal:

  • a classification other than Public in a note's frontmatter, or a note's uuid line;
  • a path into a home folder;
  • a personal email address, a phone number or a card number.

CI runs the same check over every file, so a commit that skipped the hook is still caught.

A fresh clone needs only these steps. CI runs them exactly as written here, in a fresh clone:

  • on every merge to main;
  • before every release;
  • on every pull request that changes them or what they install.

So setting up never depends on a maintainer's machine.

Only collaborators can open pull requests and issues for now, and each pull request's conversation is locked when it opens. Ideas and questions are welcome in GitHub Discussions. CONTRIBUTING.md says how to ask to join.

Testing in Obsidian, on desktop and phone

The plugin is tested from installed files, not from the plugin store.

  1. npm run build writes main.js once. npm run dev writes it on every save.
  2. node scripts/reset-test-vault.mjs copies main.js, manifest.json and styles.css into the test vault's .obsidian/plugins/thoughtknot/, as real files.
  3. Reload the plugin in Obsidian.

Obsidian Sync carries the files to a phone when it syncs installed community plugins. A new build reaches the vault only when you run the reset again. Releases are still built only by CI, from a tag.

To try the drawing in an ordinary browser first, run node packages/obsidian/harness/build.mjs <folder>. It builds a page that draws a sample note with the real plugin and a small stand-in for Obsidian. Serve the folder and open it.

The Python library has its own pinned setup (uv, Python 3.14):

cd python/graph_spec
uv sync --locked
uv run --locked python -m unittest discover -s tests

A host that uses the Python library sets three values after importing it: graph_spec.CLASSIFICATION_NOTE, graph_spec.OWNER_ENGINE and graph_spec.WRITTEN_BY. The defaults name no machine or person. No classification note is read until one is set.

Supply chain

  • Exact pins. Every dependency is pinned to an exact version. The lockfile is committed, and installs use npm ci.
  • No install scripts. .npmrc sets ignore-scripts=true.
  • Verified. CI runs npm audit signatures before every build.
  • Slow to adopt, quick to patch. Dependabot proposes version updates each week, after a seven-day wait. Security updates come at once.
  • Pinned actions. Every GitHub Action is pinned to a full commit hash. CI runs with read-only permissions, and only the release job may write.
  • Built by CI. Releases are built from a version tag, with no v in front. Each has SHA256SUMS for every file. Once the repository is public, each also gets a build provenance attestation. GitHub keeps those only for public repositories owned by a person.
  • Fit to be public, checked every time. scripts/public-readiness.mjs runs in CI and before every release. It checks every branch and tag for secrets and for anything confidential or personal. It checks that every commit comes from a no-reply address. It also sets up a fresh clone from the steps above alone.
  • GitHub settings checked too. The same script (--settings) checks the repository's settings. Collaborators only may open pull requests and issues. Every action is pinned and the token is read-only. A fork's workflows run only once approved. Secret scanning with push protection, Dependabot alerts and fixes, and CodeQL are on.
  • Dependencies reviewed, advisories re-read. Each pull request's dependency changes are checked against published advisories. The Graphviz inside the engine is checked against them each week.
  • No test framework. Tests use the runner built into Node.

Versions

ThoughtKnot uses semantic versions, with one catch before 1.0.0. Then a step in the leftmost part that is not 0 may change or remove what the library exports. So 0.7 to 0.8 may break your code. A patch step, such as 0.8.0 to 0.8.1, never does. Each release's notes have a "Library changes" section. It lists what was added, changed or removed.

Licence

Mozilla Public License 2.0 (MPL-2.0). See LICENSE.

  • Anyone may use, fork and change the code.
  • A changed file stays under the same licence, so its changes stay open.
  • The code may still sit inside a larger work under other terms.
  • Releases before this change stay MIT.

The graph spec's own text is under CC-BY-4.0, so anyone may build on the format if they credit it. See LICENSE-SPEC.md for the files it covers.

The layout engine, @viz-js/viz, compiles Graphviz (Eclipse Public License 2.0) into WebAssembly. See THIRD_PARTY_NOTICES.md, which ships with every release.

Source 2 files
hooks/register.tsx 281 lines
1// /graph: draws a graph note's first graph in a pane, with the ThoughtKnot core (spec §10, a Claude Code host). Reading,
2// the layout source and theming run here, in the mod's own environment (graph.js); Graphviz runs as a process through
3// node (bin/render.mjs), since a mod has no WebAssembly. Where no drawing can be, the pane shows the graph as text with
4// the reason: when node is missing, on the terminal (which draws no Svg), and for a graph too large to draw.
5//
6// /graph edit <note> starts the session's browser editor (spec §26, bin/editor-server.mjs) and draws, in a pane of its
7// own, the address to open in the browser pane. The address carries the session's secret token, so it is drawn and
8// copied and goes nowhere else: not into the command's answer, a log line, a toast, state or the conversation
9// (cc_transcript_clean). /graph stop stops the server; so does the session ending. When the server cannot start, the
10// answer says why and the pane shows the drawing /graph gives.
11import { atom, read, update } from 'claude-code';
12import type { EngineInterface, Register } from 'claude-code';
13
14import type { GraphView } from '../types';
15import {
16  dataFolderRefusal,
17  EditorLauncher,
18  guardDataFolder,
19  guardGateFiles,
20  guardOpenNote,
21  pathOf,
22  confirmArgs,
23  editArgs,
24  followSystemLanguage,
25  layOut,
26  notePath,
27  PANE_PALETTE,
28  readLinkVia,
29  realPathVia,
30  sayEdit,
31  sayProposed,
32  sayStopped,
33  settingsFrom,
34  viewOf,
35  word,
36} from './graph.js';
37
38const PANE = 'graph';
39const EDITOR_PANE = 'graph-editor';
40const PROPOSE = 'mcp__thoughtknot__propose';
41const PROPOSE_TOOL =
42  'Propose changes to the graph of the note open in the ThoughtKnot editor. Nothing is applied: the changes wait on a card ' +
43  'in the editor for the user to accept or reject. Each change is one of: {op: "add-thing", name, kind, properties?}, ' +
44  '{op: "add-link", from, type, to}, {op: "set", thing, key, value}, {op: "remove-thing", thing}, ' +
45  '{op: "remove-link", from, type, to}, each with an optional "reason". Names are one word. You cannot add a Person, ' +
46  'a Model or a Step. "read" lists what you read, if you say so.';
47const PROPOSE_SCHEMA = {
48  type: 'object',
49  properties: {
50    changes: { type: 'array', items: { type: 'object', properties: { op: { type: 'string' } }, required: ['op'] } },
51    read: { type: 'array', items: { type: 'string' } },
52  },
53  required: ['changes'],
54};
55const view = atom({ plugin: 'thoughtknot', key: 'view' } as const, null as GraphView | null);
56// counts changes to the editor pane so it is drawn again; it holds no address, token or note
57const editorChanges = atom({ plugin: 'thoughtknot', key: 'editor' } as const, 0);
58
59/** The session's editor: made when first asked for, and kept for the module's life (its server dies with it). */
60let launcher: EditorLauncher | null = null;
61
62/** The mod's data folder (settings and drafts): `CLAUDE_PLUGIN_DATA`, else the plugins data folder under the config. */
63async function dataFolderOf($: EngineInterface): Promise<{ data: string; home?: string }> {
64  const home = await $.env.get('HOME');
65  // an empty CLAUDE_PLUGIN_DATA is unset
66  const data = (await $.env.get('CLAUDE_PLUGIN_DATA')) || `${home ?? ''}/.claude/plugins/data/thoughtknot`;
67  return home ? { data, home } : { data };
68}
69
70/** The user's settings, saved as settings.json in the data folder; none, or one that cannot be read, is the defaults. */
71async function settingsAt($: EngineInterface, data: string) {
72  try {
73    return settingsFrom(JSON.parse(await $.fs.read(`${data}/settings.json`)));
74  } catch {
75    return settingsFrom(null);
76  }
77}
78
79/** The launcher over this host's calls; the server it runs is the mod's own bin/editor-server.mjs. */
80async function launcherOf($: EngineInterface): Promise<EditorLauncher> {
81  if (launcher) return launcher;
82  const { data } = await dataFolderOf($);
83  launcher = new EditorLauncher({
84    spawn: (request) => $.process.spawn(request),
85    // a matrix note is read only when its real path, links settled, is inside the vault: the mod's file calls do not
86    // settle links, so node is asked
87    realPath: realPathVia(
88      (request) => $.process.spawn(request),
89      ['node', `${$.plugin.root}/bin/real-path.mjs`],
90      (ms, fn) => $.clock.after(ms, fn),
91    ),
92    exists: (path) => $.fs.exists(path),
93    read: (path) => $.fs.read(path),
94    // the user's settings, saved as settings.json in the plugin data folder; none, or one that cannot be read, is the
95    // defaults, which are the strictest: Claude's ceiling the lowest, a note with no classification the highest
96    settings: () => settingsAt($, data),
97    after: (ms, fn) => $.clock.after(ms, fn),
98    cwd: await $.session.cwd(),
99    server: ['node', `${$.plugin.root}/bin/editor-server.mjs`],
100    drafts: data,
101    // Claude's proposals: a key made for each start, and the shipped sender (node), which is handed the request on its
102    // standard input so the key is in no command line (cc_claude_proposes)
103    key: () => {
104      const bytes = new Uint8Array(32);
105      globalThis.crypto.getRandomValues(bytes);
106      return btoa(String.fromCharCode(...bytes))
107        .replace(/\+/g, '-')
108        .replace(/\//g, '_')
109        .replace(/=+$/, '');
110    },
111    propose: async (request) => {
112      const ran = await $.process.run(['node', `${$.plugin.root}/bin/propose.mjs`], {
113        stdin: JSON.stringify(request),
114        timeoutMs: 30_000,
115      });
116      return JSON.parse(ran.stdout) as { status: number; text: string };
117    },
118    changed: () => void update($, editorChanges, (n) => n + 1).then(() => $.ui.invalidate('ui.render')),
119  });
120  return launcher;
121}
122
123/** The view of the note at `path`, or of why it could not be read. */
124async function viewAt($: EngineInterface, path: string): Promise<GraphView> {
125  let note: string;
126  try {
127    note = await $.fs.read(path);
128  } catch (err) {
129    const why = word('cc.could-not-read', { path, why: err instanceof Error ? err.message : String(err) });
130    return { title: path, alt: why, lines: [why] };
131  }
132  const run = (argv: readonly string[], stdin: string, timeoutMs: number) => $.process.run(argv, { stdin, timeoutMs });
133  // the drawing carries light and dark and follows the app's own appearance (PANE_PALETTE says why)
134  return viewOf(note, (dots) => layOut(dots, $.plugin.root, run), PANE_PALETTE);
135}
136
137/**
138 * Draws the note in the graph pane, as /graph does; the text it answers with. The pane is one Claude can read, so the
139 * note is held to the same gate as the editor's (cc_classified_asks, cc_credential_never): one that may not open is not
140 * drawn, and the answer says why, with no text of the note.
141 */
142async function drawNote($: EngineInterface, path: string): Promise<string> {
143  const gate = await (await launcherOf($)).gate(path, await $.session.cwd());
144  if ('refuse' in gate) return word('cc.refused-not-drawn', { why: gate.refuse });
145  if ('ask' in gate) return word('cc.asks-not-drawn', { why: gate.ask, path });
146  const shown = await viewAt($, path);
147  await update($, view, () => shown);
148  await $.ui.open({ id: PANE, title: word('cc.pane-title', { title: shown.title }) });
149  return shown.why ? word('cc.shown-as-text', { alt: shown.alt, why: shown.why }) : shown.alt;
150}
151
152export const register: Register = (on) => {
153  // the pane's words are said in the system's language (spec §42)
154  followSystemLanguage();
155  // Claude's own writes to the data folder are refused (spec §26): it could loosen its own gate. So are its writes to
156  // the note open in the editor (cc_claude_proposes, risk AI-1): it changes that note only by the propose tool, which
157  // the refusal names. So are its writes to the matrix note and to a classification line (risk TRUST-1). Every tool is
158  // hooked, as the guards judge any tool whose input names a path, not only the ones listed (risk TRUST-2). A guard
159  // that fails refuses too. Reads are not judged.
160  on('tool.call', async ($, e, next) => {
161    const tool = e.tool;
162    const input = e as Record<string, unknown>;
163    if (tool !== 'Bash' && pathOf(tool, input) === null) return next(e);
164    const { data, home } = await dataFolderOf($);
165    const ports = {
166      ...(home ? { home } : {}),
167      cwd: await $.session.cwd(),
168      realPath: realPathVia(
169        (request) => $.process.spawn(request),
170        ['node', `${$.plugin.root}/bin/real-path.mjs`],
171        (ms, fn) => $.clock.after(ms, fn),
172      ),
173      readLink: readLinkVia(
174        (request) => $.process.spawn(request),
175        ['node', `${$.plugin.root}/bin/real-path.mjs`],
176        (ms, fn) => $.clock.after(ms, fn),
177      ),
178    };
179    const refusal =
180      (await guardDataFolder(tool, input, { data, ...ports })) ??
181      (await guardOpenNote(tool, input, { note: launcher?.note ?? null, ...ports })) ??
182      (await guardGateFiles(tool, input, {
183        matrixNote: (await settingsAt($, data)).aiMatrixNote ?? null,
184        read: (path) => $.fs.read(path),
185        ...ports,
186      }));
187    return refusal === null ? next(e) : { deny: refusal };
188  }).catch(($, e, next) => (next.called ? next(e) : { deny: dataFolderRefusal() }));
189
190  on('session.start', async ($, e, next) => {
191    await $.command.register({ name: 'graph', description: word('cc.command') });
192    // the one tool Claude has here: to propose changes to the note open in the editor. It writes nothing itself.
193    await $.tool.register({
194      name: 'propose',
195      description: PROPOSE_TOOL,
196      inputSchema: PROPOSE_SCHEMA,
197      isDeferred: false,
198    });
199    return next(e);
200  });
201
202  // Claude proposes; the server makes the change set, and the user accepts or rejects it on the card (cc_claude_proposes)
203  on('tool.call', { tool: PROPOSE }, async ($, e) => {
204    const outcome = await (await launcherOf($)).propose({ changes: e.changes, read: e.read });
205    return outcome.ok ? { result: sayProposed(outcome) } : { deny: sayProposed(outcome) };
206  });
207
208  on('command.run', { command: 'graph' }, async ($, e) => {
209    if (e.args.trim() === 'stop') {
210      const stopped = launcher ? await launcher.stop('/graph stop') : false;
211      return { text: stopped ? sayStopped('/graph stop') : word('cc.editor.usage') };
212    }
213    const confirming = confirmArgs(e.args);
214    const editing = editArgs(e.args);
215    if (confirming || editing !== null) {
216      // who ran the command, as the host says: only the composer is the person typing, and nothing else may say yes
217      const origin = (e as { origin?: unknown }).origin;
218      const opened = confirming
219        ? await (await launcherOf($)).confirm(origin)
220        : await (await launcherOf($)).edit(editing ?? '', await $.session.cwd(), origin);
221      if (!opened.ok) {
222        // the editor did not start: say why, and show the drawing /graph gives, unless the gate held the note back
223        if (editing && !opened.held) await drawNote($, editing);
224        return { text: sayEdit(opened) };
225      }
226      await update($, editorChanges, (n) => n + 1);
227      await $.ui.open({ id: EDITOR_PANE, title: word('cc.editor.pane-title'), focus: true });
228      return { text: sayEdit(opened) };
229    }
230    const path = notePath(e.args);
231    if (!path) return { text: word('cc.usage') };
232    return { text: await drawNote($, path) };
233  });
234
235  // the session ending ends its server
236  on('session.end', async ($, e, next) => {
237    await launcher?.stop(word('server.session-ended'));
238    return next(e);
239  });
240
241  // the one place the address is read: to draw it for the person, and to copy it for them
242  on('ui.render', { component: 'Pane', requestId: EDITOR_PANE }, async ($, e) => {
243    const { Box, Text, Link, Button } = $.ui.resolve(e);
244    await read($, editorChanges);
245    const href = launcher?.link ?? null;
246    if (href === null) {
247      const ended = launcher?.ended;
248      return <Text dimColor>{ended ? sayStopped(ended) : word('cc.editor.usage')}</Text>;
249    }
250    return (
251      <Box flexDirection="column">
252        <Text>{word('cc.editor.open-address')}</Text>
253        <Link href={href} label={word('cc.editor.open-link')} />
254        <Button label={word('cc.editor.copy')} onPress={(press) => $.ui.copy({ text: href, surface: press.surface })} />
255        <Text dimColor>{word('cc.editor.secret')}</Text>
256      </Box>
257    );
258  });
259
260  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
261    const elements = $.ui.resolve(e);
262    const { Box, Text } = elements;
263    const shown = await read($, view);
264    if (!shown) return <Text dimColor>{word('cc.usage')}</Text>;
265
266    // the terminal draws no Svg (Image and Raster only), though `'Svg' in` its table holds: the text stands in there
267    const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined;
268    if (Svg && shown.svg) return <Svg source={shown.svg} alt={shown.alt} />;
269    // never text with no reason: a graph that could be drawn says why it is text here
270    const why = shown.why ?? (shown.svg ? word('cc.no-pictures') : undefined);
271    return (
272      <Box flexDirection="column">
273        {why ? <Text color="warning">⚠ {why}</Text> : null}
274        {shown.lines.map((line, i) => (
275          <Text key={`line-${i}`}>{line}</Text>
276        ))}
277      </Box>
278    );
279  });
280};
281
types/index.d.ts 14 lines
1// The mod's state: the view the pane draws, kept by /graph. It is self-contained, as a contract must be. register.tsx
2// stores graph-view.ts's own GraphView in it, so a type check of the mod holds the two alike.
3
4/** One graph as the pane shows it: the SVG where it could be drawn, and always the same graph as lines of text. */
5export type GraphView = { title: string; svg?: string; alt: string; lines: string[]; why?: string };
6
7declare module 'claude-code' {
8  interface PluginState {
9    // `editor` counts the changes of the editor pane (a server started, ended or stopped), so it is drawn again. It
10    // holds no address, token or note: those never enter state, which the host may keep.
11    thoughtknot: { view: GraphView | null; editor: number };
12  }
13}
14