SLOPSHOPPER

Thunk.AI Skills

How to build, test and analyze thunks with the Thunk.AI Builder MCP, a Thunk pane with your thunks, and next-step suggestions. Installed with a tenant plugin.

newpanebandguardcommandtoast
v?Apache-2.0updated 2026-10-25ThunkAI/thunk-plugins/plugins/thunk-skills
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thunk-skills
│ ┃ Thunk.AI ✕ › fix the failing auth test and add an audit log call │ ┃ ● Thunk.AI │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ Welcome to Thunk.AI │ ⏺ Update(src/auth.ts) │ ┃ │ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Thunks are AI agents that run your │ ⏺ Bash(bun test) │ ┃ │ business processes step by step, with │ ⎿ 3 pass, 1 fail │ ┃ │ your tools and human review where you │ │ ┃ │ need it. Claude can build, test and │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ fix them with you right here. │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ Got it ] │ │ ┃ ╰────────────────────────────────────────╯ › /thunk │ ┃ ⎿ thunk-skills: Thunk.AI pane opened. │ ┃ Get started │ ┃ │ ┃ [ Build my first thunk ] │ ┃ │ ┃ [ Take a guided tour ] │ ┃ │ ┃ [ What can Thunk.AI do for me? ] │ ┃ │ ┃ Your thunks │ ┃ │ ┃ Claude lists them here once it has looked ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Thunk.AI
● Thunk.AI ╭────────────────────────────────────────────────────────╮ │ Welcome to Thunk.AI │ │ │ │ Thunks are AI agents that run your business processes │ │ step by step, with your tools and human review where │ │ you need it. Claude can build, test and fix them with │ │ you right here. │ │ │ │ [ Got it ] │ ╰────────────────────────────────────────────────────────╯ Get started [ Build my first thunk ] [ Take a guided tour ] [ What can Thunk.AI do for me? ] Your thunks Claude lists them here once it has looked them up. [ Show my thunks ] Ask Claude to… “Analyze my thunk "Invoice intake": why are its work items failing?” “Run a test work item on "Invoice intake" and walk me through each step” “Explain how "Invoice intake" decides when to ask for review” Commands /thunk <name or id> open a thunk in the browser pane /thunk open this pane /thunk-welcome show the welcome again /mcp sign in to the Thunk.AI Builder server
README

Thunk.AI Skills

Build, test and analyze Thunk.AI thunks from Claude: AI agents that carry out a business process step by step, with your tools and human review where you need it.

This plugin carries:

  • The thunk-guide skill: which Thunk.AI help articles to read, the order to build a thunk in, and how to test, troubleshoot and clean up a thunk.
  • A Thunk.AI pane and next steps (Claude Code): a pane beside the chat with a first-run welcome, things to try and your most recent thunks (the last list Claude looked up; Show my thunks asks it to); after Claude uses a Builder tool, a suggested next step above the prompt, and Analyze, Explain, Debug errors and Suggest improvements for the thunk you have open; /thunk <name or id> opens a thunk, /thunk the pane, and /thunk-welcome shows the welcome again. These show in the Claude desktop app's Code tab. In the terminal nothing shows unless you run /thunk or /thunk-welcome, and they do not run in claude.ai or Cowork.

The Builder MCP tools come from the plugin for your Thunk.AI tenant, which installs this one with it.

Install

Add the Thunk.AI marketplace (the public one is below; a private edition comes with its own), then install your tenant's plugin from it with /plugin:

claude plugin marketplace add ThunkAI/thunk-plugins

Then run /mcp, select its thunk-builder server, choose Authenticate and sign in with your Thunk.AI account. The Thunk.AI pane opens at the next session start and lists your thunks once you are signed in.

Files

PathWhat it is
skills/thunk-guide/The skill, built from help-center articles (not committed in thunk-web)
hooks/register.tsxThe Claude Code hooks module: the pane, the next-step band and system prompt rule
hooks/pane.test.tsxIts tests: claude plugin test .
types/index.d.tsThe module's $.state contract
plugin.jsonCodex's manifest; .claude-plugin/plugin.json is Claude's

License

Apache-2.0

Source 2 files
hooks/register.tsx 887 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Account, CurrentThunk, NextStep, Thunk } from "../types";
5
6const PANE = "thunk-home";
7const TITLE = "Thunk.AI";
8
9// Thunk.AI's amber accent (the webapp theme's info and folder accent). Only the mark and the
10// welcome card's border use it: as text on a light background it is too faint to read, so
11// words keep the person's theme colors.
12const AMBER = "#eaa606";
13
14// A Builder MCP tool's name: mcp__plugin_<tenant plugin>_thunk-builder__<tool> for a tenant
15// plugin's server, mcp__<server>__<tool> for one added by hand. No tenant is named here: the
16// public and private exports refuse a file that names another edition's tenant.
17const BUILDER_TOOL = /^mcp__(.+)__([a-z_]+)$/;
18
19const account = atom(
20  { plugin: "thunk-skills", key: "account" } as const,
21  {
22    kind: "unknown",
23  } as Account,
24);
25// Whether the pane shows the welcome text: on first run, and again from "About Thunk.AI".
26const isNew = atom({ plugin: "thunk-skills", key: "isNew" } as const, false);
27const next = atom(
28  { plugin: "thunk-skills", key: "next" } as const,
29  null as NextStep | null,
30);
31// Whether thunk-home is open, set on every open and cleared by ui.close. The band reads it so
32// it redraws when the pane closes; $.ui.panes() alone would not redraw it.
33const isPaneOpen = atom(
34  { plugin: "thunk-skills", key: "isPaneOpen" } as const,
35  false,
36);
37// The Builder tools whose thunkId is the thunk the person is working on (not, say, copy_thunk's
38// source or a comparison).
39const WORKS_ON = new Set([
40  "get_thunk",
41  "get_definition",
42  "get_errors",
43  "run_work_item",
44  "rerun_work_item",
45  "create_work_item",
46  "query_work_items",
47  "run_review",
48  "batch_steps",
49  "batch_properties",
50  "batch_tools",
51  "batch_connections",
52]);
53
54// The name in a get_thunk result, when there is one.
55function thunkName(text: string | undefined) {
56  try {
57    return cleanName((JSON.parse(text ?? "") as { name?: unknown }).name);
58  } catch {
59    return undefined;
60  }
61}
62
63// The thunk the person is working on: the one they opened, the one Claude opened in the browser
64// pane, or the one Claude last worked on through the Builder tools. The band offers actions on it.
65const current = atom(
66  { plugin: "thunk-skills", key: "current" } as const,
67  null as CurrentThunk | null,
68);
69// The person hid the band's "Open Thunk.AI pane" button; kept in $.store across sessions.
70const isChipHidden = atom(
71  { plugin: "thunk-skills", key: "isChipHidden" } as const,
72  false,
73);
74
75const STARTERS: NextStep[] = [
76  {
77    label: "Build my first thunk",
78    prompt:
79      "I am new to Thunk.AI. Interview me briefly about a business process I want to automate, then build it as a thunk and open it in the browser pane so I can watch.",
80  },
81  {
82    label: "Take a guided tour",
83    prompt:
84      "Give me a guided tour of Thunk.AI in the browser pane, using one of the public sample thunks: what a thunk is, its workflow plan, steps, tools and a work item run.",
85  },
86  {
87    label: "What can Thunk.AI do for me?",
88    prompt:
89      "Ask me what my role and team are, then suggest three processes Thunk.AI could automate for me, with one sentence each on how.",
90  },
91];
92
93// After Claude uses a Builder tool, the most useful next move.
94const FOLLOW_UPS: Record<string, NextStep> = {
95  create_thunk: {
96    label: "Run a test work item",
97    prompt:
98      "Create a realistic test work item on the thunk we just built, run it and walk me through what each step did.",
99  },
100  batch_steps: {
101    label: "Test the new steps",
102    prompt:
103      "Run a work item through the steps we just changed and tell me whether each one did what it should.",
104  },
105  run_work_item: {
106    label: "Review the run",
107    prompt:
108      "Review the work item that just ran: where it spent time, any errors or weak outputs, and what to change.",
109  },
110  get_errors: {
111    label: "Fix the cause",
112    prompt:
113      "Propose the smallest change that fixes the errors we just found, then rerun the failing work item.",
114  },
115  batch_connections: {
116    label: "Try the connection",
117    prompt:
118      "Run a work item that uses the connection we just set up and confirm its tools work end to end.",
119  },
120  run_review: {
121    label: "Apply the review",
122    prompt:
123      "Apply the top recommendations from that review to the thunk, then rerun a work item to compare.",
124  },
125};
126
127// How a prompt names a thunk: by name when the pane knows it, always by id.
128// thunk names and ids come from server data and URLs, and go into prompts submitted as the
129// person's own words, so neither may carry quotes, line breaks or other control characters.
130const THUNK_ID = /^[A-Za-z0-9_=+-]{1,64}$/;
131
132function cleanId(id: unknown) {
133  return typeof id === "string" && THUNK_ID.test(id) ? id : undefined;
134}
135
136function cleanName(name: unknown) {
137  const text =
138    typeof name === "string"
139      ? name
140          .replace(/[\u0000-\u001f\u007f"“”`]/g, " ")
141          .replace(/\s+/g, " ")
142          .trim()
143      : "";
144  return text ? text.slice(0, 80) : undefined;
145}
146
147function thunkRef(thunk: CurrentThunk) {
148  return thunk.name
149    ? `"${thunk.name}" (id ${thunk.id})`
150    : `with id ${thunk.id}`;
151}
152
153// What the person can ask about the thunk they are working on, above the prompt.
154const THUNK_ACTIONS: {
155  key: string;
156  label: string;
157  prompt: (thunk: CurrentThunk) => string;
158}[] = [
159  {
160    key: "analyze",
161    label: "Analyze",
162    prompt: (thunk) =>
163      `Analyze my thunk ${thunkRef(thunk)}: what it does, how its recent work items went (passed, failed, time taken) and anything that looks wrong.`,
164  },
165  {
166    key: "explain",
167    label: "Explain",
168    prompt: (thunk) =>
169      `Explain my thunk ${thunkRef(thunk)} in plain terms: its goal, its workflow plan and steps, the tools and connections it uses, and where people review its work.`,
170  },
171  {
172    key: "debug",
173    label: "Debug errors",
174    prompt: (thunk) =>
175      `Find the errors and failed work items on my thunk ${thunkRef(thunk)}, explain the cause of each, and propose the smallest fix. Don't change anything yet.`,
176  },
177  {
178    key: "improve",
179    label: "Suggest improvements",
180    prompt: (thunk) =>
181      `Suggest the three most useful improvements to my thunk ${thunkRef(thunk)} (reliability, speed, cost, or the clarity of its instructions), most valuable first. Don't change anything yet.`,
182  },
183];
184
185function openThunkStep(target: string): NextStep {
186  return {
187    label: "Open",
188    prompt: `Open my thunk ${target} in the browser pane.`,
189  };
190}
191
192// The Thunk.AI app's page for one thunk, opened in the browser pane: a thunk.ai host (any
193// tenant's) and a /thunk/<id> path. The URL is parsed, never matched as text.
194function thunkPageId(url: unknown) {
195  try {
196    const page = new URL(String(url));
197    const isThunkHost =
198      page.hostname === "thunk.ai" || page.hostname.endsWith(".thunk.ai");
199    const path = /^\/thunk\/([^/]+)/.exec(page.pathname);
200    return page.protocol === "https:" && isThunkHost && path?.[1]
201      ? cleanId(decodeURIComponent(path[1]))
202      : undefined;
203  } catch {
204    return undefined;
205  }
206}
207
208// The URLs a call on the side-panel browser opens: navigate and preview_start take a url, and
209// browser_batch carries navigate actions.
210function browserUrls(tool: string, input: Record<string, unknown>) {
211  // A cloud session linked to the desktop app reaches the same browser as remote-devices.
212  const name = /^mcp__(?:remote-devices__)?Claude_Browser__(.+)$/.exec(
213    tool,
214  )?.[1];
215  if (name === "navigate" || name === "preview_start") {
216    return [input.url];
217  }
218  if (name === "browser_batch" && Array.isArray(input.actions)) {
219    return (input.actions as { name?: unknown; input?: { url?: unknown } }[])
220      .filter((action) => action?.name === "navigate")
221      .map((action) => action.input?.url);
222  }
223  return [];
224}
225
226const NEXT_STEPS_RULE = [
227  "This user works with Thunk.AI through the Thunk.AI Builder tools.",
228  'After any turn in which you built, changed, ran or analyzed a thunk, end your reply with a short "Next steps" list of two or three concrete actions they could take on that thunk now (for example: run a test work item, add a review step, connect a system, export it as an MCP server), most useful first.',
229  "For a user who is new to Thunk.AI, explain any Thunk.AI term (thunk, step, work item, connection) in a few words the first time you use it.",
230].join(" ");
231
232type Shown = {
233  thunkId?: unknown;
234  name?: unknown;
235  url?: unknown;
236  productionMode?: unknown;
237  folderPath?: unknown;
238};
239
240// The pane never calls the Builder server itself: a plugin's own MCP call goes through the
241// person's permissions with no one to approve it (auto mode refuses it outright), and a plugin
242// cannot approve its own call in the desktop app. It shows the thunks from the last unfiltered
243// list_thunks Claude ran instead, kept in $.store so the next session starts with them.
244const SHOWN = 8;
245
246// The thunks in a list_thunks result, or undefined when the call was filtered (a search, a
247// folder, a tag, a later page, shared or samples only), so the pane never shows a partial list
248// as "your thunks".
249function listedThunks(
250  input: Record<string, unknown>,
251  text: string | undefined,
252) {
253  const isFiltered =
254    ["search", "folder", "tag", "after"].some(
255      (key) => input[key] !== undefined,
256    ) ||
257    (input.access !== undefined &&
258      input.access !== "owned" &&
259      input.access !== "all");
260  if (isFiltered || !text) {
261    return undefined;
262  }
263  try {
264    const listed = JSON.parse(text) as { items?: unknown };
265    const items = (Array.isArray(listed.items) ? listed.items : []) as Shown[];
266    return items
267      .filter((item) => cleanId(item.thunkId))
268      .slice(0, SHOWN)
269      .map(
270        (item): Thunk => ({
271          id: cleanId(item.thunkId) ?? "",
272          name: cleanName(item.name) ?? "Untitled thunk",
273          url: String(item.url),
274          phase: String(item.productionMode ?? ""),
275          folder: String(item.folderPath ?? "/"),
276        }),
277      );
278  } catch {
279    return undefined;
280  }
281}
282
283// The pane, the band and the status line are for the Claude apps. In the terminal they crowd
284// the prompt and take Tab stops, so a terminal session shows none of them unasked: /thunk and
285// /thunk-welcome still open the pane there. The desktop app attaches its surface after
286// session.start, so the pane opens on the first app surface: at session.start when one is
287// already there, else at session.attach. Module state: a reload runs session.start again.
288let isInApp = false;
289
290async function welcomeApp($: EngineInterface) {
291  if (isInApp) {
292    return;
293  }
294  isInApp = true;
295  const state = await read($, account);
296  if (state.kind === "ready") {
297    await showThunks($, state.thunks, state.listedAt);
298  }
299  void openPane($, false).catch(() => undefined);
300  if (await read($, isNew)) {
301    $.ui.toast(
302      "Welcome to Thunk.AI. Pick something to try in the Thunk.AI pane.",
303    );
304  }
305}
306
307async function showThunks(
308  $: EngineInterface,
309  thunks: Thunk[],
310  listedAt: number,
311) {
312  await update(
313    $,
314    account,
315    () => ({ kind: "ready", thunks, listedAt }) as Account,
316  );
317  if (isInApp) {
318    $.ui.status(
319      `Thunk.AI · ${thunks.length === SHOWN ? `${SHOWN}+` : thunks.length} thunks`,
320    );
321  }
322}
323
324// The commands the pane lists. Claude loads the thunk-guide skill on its own when a request
325// needs it, so the skill is not listed.
326const COMMANDS: [string, string][] = [
327  ["/thunk <name or id>", "open a thunk in the browser pane"],
328  ["/thunk", "open this pane"],
329  ["/thunk-welcome", "show the welcome again"],
330  ["/mcp", "sign in to the Thunk.AI Builder server"],
331];
332
333const LIST_THUNKS: NextStep = {
334  label: "Show my thunks",
335  prompt: `List my ${SHOWN} most recently changed thunks (list_thunks with access "owned" and first ${SHOWN}, no other filter), as a short table of name, phase and folder.`,
336};
337
338async function openPane($: EngineInterface, focus: boolean) {
339  await $.ui.open({
340    id: PANE,
341    title: TITLE,
342    ...(focus ? { focus: true } : {}),
343  });
344  await update($, isPaneOpen, () => true);
345  // isPaneOpen may already have been true (a pane waiting on a narrow terminal, or behind
346  // another tab), so redraw the band for what $.ui.panes() now says.
347  $.ui.invalidate("ui.render");
348}
349
350// Whether the person can see the pane now: open, drawn (not waiting on a narrow terminal) and
351// the tab in front.
352async function isPaneVisible($: EngineInterface) {
353  if (!(await read($, isPaneOpen))) {
354    return false;
355  }
356  const pane = (await $.ui.panes().catch(() => [])).find(
357    (one) => one.id === PANE,
358  );
359  return pane !== undefined && pane.isPlaced && pane.isShown;
360}
361
362async function setChipHidden($: EngineInterface, hidden: boolean) {
363  await $.store.set("isChipHidden", hidden);
364  await update($, isChipHidden, () => hidden);
365}
366
367// Sets the current thunk; true when it changed.
368async function setCurrent($: EngineInterface, id: string, name?: string) {
369  const state = await read($, account);
370  const known =
371    cleanName(name) ??
372    (state.kind === "ready"
373      ? state.thunks.find((one) => one.id === id)?.name
374      : undefined);
375  const before = await read($, current);
376  await update($, current, () => ({ id, ...(known ? { name: known } : {}) }));
377  return before?.id !== id;
378}
379
380// The desktop app stacks the pane and the browser pane in one column and splits its height, so
381// the pane steps aside while a thunk is open in the browser; the band's "Thunk.AI pane" brings it
382// back.
383// The thunk whose page last made the pane step aside. Module state: a reload starts it over.
384let steppedAsideFor: string | null = null;
385
386async function closePane($: EngineInterface) {
387  // Asks the engine rather than isPaneOpen, which a reload starts over at false.
388  const panes = await $.ui.panes().catch(() => []);
389  if (panes.some((one) => one.id === PANE)) {
390    await $.ui.close({ id: PANE });
391  }
392  await update($, isPaneOpen, () => false);
393}
394
395// A prompt a slash command asked for. A command.run hook cannot submit one (it would wait on the
396// turn the command holds), so it is submitted when that turn completes.
397let pendingAsk: NextStep | null = null;
398
399function ask($: EngineInterface, step: NextStep) {
400  void update($, next, () => null);
401  // Every prompt the plugin submits comes from a button the person pressed, so it goes in as
402  // their own words, without the frame Claude Code puts around a plugin's message.
403  void $.prompt.submit({ text: step.prompt, asUser: true });
404}
405
406export const register: Register = (on) => {
407  on("session.start", async ($, e, nextHook) => {
408    await $.command.register({
409      name: "thunk",
410      description:
411        "Open a thunk in the browser pane, or with no name the Thunk.AI pane",
412      argumentHint: "[thunk name or id]",
413    });
414    await $.command.register({
415      name: "thunk-welcome",
416      description: "Show the Thunk.AI welcome again",
417    });
418
419    isInApp = false;
420    const hasVisited = (await $.store.get("hasVisited")) === true;
421    await update($, isNew, () => !hasVisited);
422    const chipHidden = (await $.store.get("isChipHidden")) === true;
423    await update($, isChipHidden, () => chipHidden);
424    const cached = (await $.store.get("thunks")) as
425      | { thunks: Thunk[]; listedAt: number }
426      | undefined;
427    if (cached && Array.isArray(cached.thunks)) {
428      await showThunks($, cached.thunks, cached.listedAt);
429    }
430    const surfaces = await $.session.surfaces().catch(() => []);
431    if (e.isInteractive && surfaces.some((one) => one !== "terminal")) {
432      await welcomeApp($);
433    }
434
435    return nextHook(e);
436  });
437
438  on("session.attach", async ($, e, nextHook) => {
439    const attached = await nextHook(e);
440    if (e.surface !== "terminal") {
441      await welcomeApp($).catch(() => undefined);
442    }
443
444    return attached;
445  });
446
447  on("command.run", { command: "thunk" }, async ($, e) => {
448    const target = e.args.trim();
449    if (target) {
450      pendingAsk = openThunkStep(
451        `"${cleanName(target) ?? target}" (a name or an id)`,
452      );
453
454      return { text: `Opening thunk ${target}.` };
455    }
456    await setChipHidden($, false);
457    await openPane($, true);
458
459    return { text: "Thunk.AI pane opened." };
460  });
461
462  on("command.run", { command: "thunk-welcome" }, async ($) => {
463    await update($, isNew, () => true);
464    await openPane($, true);
465
466    return { text: "Thunk.AI welcome shown." };
467  });
468
469  on("ui.close", { id: PANE }, async ($, e, nextHook) => {
470    const closed = await nextHook(e);
471    // A hook beneath may have kept the pane open, so ask rather than assume.
472    const panes = await $.ui.panes().catch(() => []);
473    await update($, isPaneOpen, () => panes.some((one) => one.id === PANE));
474
475    return closed;
476  }).catch(($, e, nextHook) => nextHook(e));
477
478  on("prompt.compose", async ($, e, nextHook) => {
479    const composed = await nextHook(e);
480
481    return {
482      sections: [
483        ...composed.sections,
484        {
485          id: "thunk-skills:next-steps",
486          text: NEXT_STEPS_RULE,
487          scope: "session" as const,
488        },
489      ],
490    };
491  });
492
493  // The person moved on: a suggestion is for the turn that raised it.
494  on("prompt.submit", async ($, e, nextHook) => {
495    await update($, next, () => null).catch(() => undefined);
496    // A prompt of the person's own supersedes one a command left to submit.
497    if (e.origin.kind !== "plugin") {
498      pendingAsk = null;
499    }
500
501    return nextHook(e);
502  });
503
504  on("tool.call", async ($, e, nextHook) => {
505    const ran = await nextHook(e);
506    const match = BUILDER_TOOL.exec(e.tool);
507    const followUp =
508      match?.[1] && /thunk/i.test(match[1]) && match[2]
509        ? FOLLOW_UPS[match[2]]
510        : undefined;
511    if (
512      followUp &&
513      !("deny" in ran && ran.deny) &&
514      !("isError" in ran && ran.isError)
515    ) {
516      await update($, next, () => followUp).catch(() => undefined);
517    }
518
519    const isOk =
520      !("deny" in ran && ran.deny) && !("isError" in ran && ran.isError);
521    const thunks =
522      isOk && match?.[2] === "list_thunks"
523        ? listedThunks(e as unknown as Record<string, unknown>, ran.text)
524        : undefined;
525    if (thunks) {
526      const listedAt = await $.clock.now();
527      await $.store.set("thunks", { thunks, listedAt });
528      await showThunks($, thunks, listedAt).catch(() => undefined);
529    }
530
531    // The main loop's calls only: a subagent's sweep over thunks is not what the person has open.
532    const input = e as unknown as Record<string, unknown>;
533    const isMainLoop = !input.agentId;
534    const builderThunk =
535      isOk &&
536      isMainLoop &&
537      match?.[1] &&
538      /thunk/i.test(match[1]) &&
539      match[2] &&
540      WORKS_ON.has(match[2])
541        ? cleanId(input.thunkId)
542        : undefined;
543    if (builderThunk) {
544      await setCurrent(
545        $,
546        builderThunk,
547        match?.[2] === "get_thunk" ? thunkName(ran.text) : undefined,
548      ).catch(() => false);
549    }
550    const pageId =
551      isOk && isMainLoop
552        ? browserUrls(e.tool, input).map(thunkPageId).find(Boolean)
553        : undefined;
554    // The pane steps aside once per thunk opened, so after the person brings it back, moving
555    // around the same thunk's pages leaves it. Keyed on the page, not on the current thunk:
556    // Open sets the current thunk before Claude opens its page.
557    if (pageId) {
558      await setCurrent($, pageId).catch(() => false);
559      if (pageId !== steppedAsideFor) {
560        steppedAsideFor = pageId;
561        await closePane($).catch(() => undefined);
562      }
563    }
564
565    return ran;
566  });
567
568  on("turn.complete", async ($, e, nextHook) => {
569    const done = await nextHook(e);
570    if (e.agentId) {
571      return done;
572    }
573    if (pendingAsk) {
574      const asked = pendingAsk;
575      pendingAsk = null;
576      // A turn the person stopped also stops what the command asked for.
577      if (!e.isAborted) {
578        ask($, asked);
579
580        return done;
581      }
582    }
583    const step = await read($, next);
584    if (step) {
585      void $.prompt.suggest({ text: step.prompt });
586    }
587
588    return done;
589  });
590
591  // The band above the prompt: a next step after a Builder tool call; otherwise, while the pane
592  // is out of sight and no other plugin draws here, a way back to it.
593  on("ui.render", { component: "AbovePrompt" }, async ($, e, nextHook) => {
594    if (e.props.hasSurvey || e.surface === "terminal") {
595      return nextHook(e);
596    }
597
598    const { Box, Button, Text } = $.ui.resolve(e);
599    // Whatever another plugin (or the engine) draws here stays, under the Thunk.AI row.
600    const below = await nextHook(e);
601    // While Claude works the thunk row stays (a press queues its prompt for after the turn); the
602    // next step and the chip wait, since the turn may change them.
603    const isWorking = e.props.isWorking;
604
605    const step = isWorking ? null : await read($, next);
606    const thunk = await read($, current);
607    const paneVisible = await isPaneVisible($);
608    const showChip =
609      !isWorking &&
610      !paneVisible &&
611      !(await read($, isChipHidden)) &&
612      below.type === "engine";
613    if (!step && !thunk && !showChip) {
614      return below;
615    }
616
617    return (
618      <Box flexDirection="column">
619        {thunk && (
620          <Box flexDirection="column">
621            <Box gap={1}>
622              <Text color={AMBER}>●</Text>
623              <Text bold>{thunk.name ?? "This thunk"}</Text>
624              {!paneVisible && (
625                <Button
626                  key="thunk-pane"
627                  plain
628                  dimColor
629                  onPress={() => openPane($, true)}
630                >
631                  Thunk.AI pane
632                </Button>
633              )}
634              <Button
635                key="clear-thunk"
636                plain
637                dimColor
638                onPress={() => update($, current, () => null)}
639              >
640                Close
641              </Button>
642            </Box>
643            <Box gap={1} flexWrap="wrap" paddingLeft={2}>
644              {THUNK_ACTIONS.map((action) => (
645                <Button
646                  key={`do-${action.key}`}
647                  onPress={() =>
648                    ask($, {
649                      label: action.label,
650                      prompt: action.prompt(thunk),
651                    })
652                  }
653                >
654                  {action.label}
655                </Button>
656              ))}
657            </Box>
658          </Box>
659        )}
660        {step && (
661          <Box gap={1}>
662            <Text color={AMBER}>●</Text>
663            <Text bold>Thunk.AI next step:</Text>
664            <Button key="next" variant="primary" onPress={() => ask($, step)}>
665              {step.label}
666            </Button>
667            <Button
668              key="dismiss"
669              role="dismiss"
670              dimColor
671              onPress={() => update($, next, () => null)}
672            >
673              Not now
674            </Button>
675          </Box>
676        )}
677        {!thunk && showChip && (
678          <Box gap={1}>
679            <Text color={AMBER}>●</Text>
680            <Button key="open-pane" plain onPress={() => openPane($, true)}>
681              Open Thunk.AI pane
682            </Button>
683            <Button
684              key="hide-chip"
685              role="dismiss"
686              plain
687              dimColor
688              onPress={() => setChipHidden($, true)}
689            >
690              Hide
691            </Button>
692          </Box>
693        )}
694        {below}
695      </Box>
696    );
697  });
698
699  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
700    const { Box, Button, Link, Text } = $.ui.resolve(e);
701    const state = await read($, account);
702    const showWelcome = await read($, isNew);
703    const step = await read($, next);
704
705    // Closing the welcome marks it seen; "About Thunk.AI" shows it again without changing that.
706    const closeWelcome = async () => {
707      await $.store.set("hasVisited", true);
708      await update($, isNew, () => false);
709    };
710    const example =
711      state.kind === "ready" && state.thunks[0]
712        ? `"${state.thunks[0].name}"`
713        : '"Invoice intake"';
714
715    // The starters lead for a new user and follow everything else once the welcome is closed.
716    const starters = (isLead: boolean) => (
717      <Box flexDirection="column" gap={isLead ? 1 : 0}>
718        <Text bold>{isLead ? "Get started" : "New to Thunk.AI?"}</Text>
719        {STARTERS.map((starter, index) => (
720          <Button
721            key={`starter-${index}`}
722            hotkey={String(index + 1)}
723            plain={!isLead || undefined}
724            variant={isLead && index === 0 ? "primary" : "secondary"}
725            onPress={async () => {
726              await closeWelcome();
727              ask($, starter);
728            }}
729          >
730            {starter.label}
731          </Button>
732        ))}
733        {!isLead && (
734          <Button
735            key="about"
736            plain
737            dimColor
738            onPress={() => update($, isNew, () => true)}
739          >
740            About Thunk.AI
741          </Button>
742        )}
743      </Box>
744    );
745
746    return (
747      <Box flexDirection="column" gap={1} paddingX={1}>
748        <Box gap={1}>
749          <Text color={AMBER} bold>
750            ●
751          </Text>
752          <Text bold>Thunk.AI</Text>
753        </Box>
754
755        {showWelcome && (
756          <Box
757            flexDirection="column"
758            borderStyle="round"
759            borderColor={AMBER}
760            paddingX={1}
761            gap={1}
762          >
763            <Text bold>Welcome to Thunk.AI</Text>
764            <Text>
765              Thunks are AI agents that run your business processes step by
766              step, with your tools and human review where you need it. Claude
767              can build, test and fix them with you right here.
768            </Text>
769            <Button key="close-welcome" dimColor onPress={closeWelcome}>
770              Got it
771            </Button>
772          </Box>
773        )}
774
775        {step && (
776          <Box flexDirection="column" gap={1}>
777            <Text bold>Suggested next</Text>
778            <Button
779              key="pane-next"
780              variant="primary"
781              onPress={() => ask($, step)}
782            >
783              {step.label}
784            </Button>
785          </Box>
786        )}
787
788        {showWelcome && starters(true)}
789
790        <Box flexDirection="column" gap={1}>
791          <Box gap={1}>
792            <Text bold>Your thunks</Text>
793            {state.kind === "ready" && (
794              <Button
795                key="refresh"
796                plain
797                dimColor
798                onPress={() => ask($, LIST_THUNKS)}
799              >
800                ↻ Refresh
801              </Button>
802            )}
803          </Box>
804          {state.kind === "unknown" && (
805            <Box flexDirection="column" gap={1}>
806              <Text dimColor>
807                Claude lists them here once it has looked them up.
808              </Text>
809              <Button key="show-thunks" onPress={() => ask($, LIST_THUNKS)}>
810                {LIST_THUNKS.label}
811              </Button>
812            </Box>
813          )}
814          {state.kind === "ready" && (
815            <Text dimColor>
816              As of {new Date(state.listedAt).toLocaleString()}
817            </Text>
818          )}
819          {state.kind === "ready" && state.thunks.length === 0 && (
820            <Text dimColor>
821              No thunks yet. Start with "Build my first thunk".
822            </Text>
823          )}
824          {state.kind === "ready" &&
825            state.thunks.map((thunk) => (
826              <Box key={`row-${thunk.id}`} flexDirection="column">
827                <Text>
828                  {thunk.name} <Text dimColor>{thunk.phase}</Text>
829                </Text>
830                <Box gap={1}>
831                  <Button
832                    key={`open-${thunk.id}`}
833                    onPress={async () => {
834                      await setCurrent($, thunk.id, thunk.name);
835                      ask($, openThunkStep(thunkRef(thunk)));
836                    }}
837                  >
838                    Open
839                  </Button>
840                  <Button
841                    key={`analyze-${thunk.id}`}
842                    plain
843                    onPress={async () => {
844                      await setCurrent($, thunk.id, thunk.name);
845                      ask($, {
846                        label: "Analyze",
847                        prompt: THUNK_ACTIONS[0]!.prompt(thunk),
848                      });
849                    }}
850                  >
851                    Analyze
852                  </Button>
853                  <Link href={thunk.url} label="Open in Thunk.AI" />
854                </Box>
855                <Text dimColor>{thunk.folder}</Text>
856              </Box>
857            ))}
858        </Box>
859
860        <Box flexDirection="column">
861          <Text bold>Ask Claude to…</Text>
862          {[
863            `Analyze my thunk ${example}: why are its work items failing?`,
864            `Run a test work item on ${example} and walk me through each step`,
865            `Explain how ${example} decides when to ask for review`,
866          ].map((line, index) => (
867            <Text key={`example-${index}`} dimColor>
868              “{line}”
869            </Text>
870          ))}
871        </Box>
872
873        <Box flexDirection="column">
874          <Text bold>Commands</Text>
875          {COMMANDS.map(([name, what]) => (
876            <Text key={`command-${name}`}>
877              {name} <Text dimColor>{what}</Text>
878            </Text>
879          ))}
880        </Box>
881
882        {!showWelcome && starters(false)}
883      </Box>
884    );
885  });
886};
887
types/index.d.ts 32 lines
1export type Thunk = {
2  id: string;
3  name: string;
4  url: string;
5  phase: string;
6  folder: string;
7};
8
9// What the pane knows about the person's thunks: nothing yet (Claude has not listed them in
10// this or an earlier session), or the last unfiltered list Claude ran and when.
11export type Account =
12  | { kind: "unknown" }
13  | { kind: "ready"; thunks: Thunk[]; listedAt: number };
14
15// The thunk the person is working on; the name when the pane knows it.
16export type CurrentThunk = { id: string; name?: string };
17
18export type NextStep = { label: string; prompt: string };
19
20declare module "claude-code" {
21  interface PluginState {
22    "thunk-skills": {
23      account: Account;
24      isNew: boolean;
25      next: NextStep | null;
26      isPaneOpen: boolean;
27      isChipHidden: boolean;
28      current: CurrentThunk | null;
29    };
30  }
31}
32