@polderlabs/bizar-plugin 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -98,6 +98,7 @@ import type { Plugin, Hooks, PluginInput, PluginOptions } from "@opencode-ai/plu
98
98
  import { createLogger, type Logger } from "./src/logger.js";
99
99
  import { decide, isLogOnlyWarn } from "./src/loop.js";
100
100
  import { fingerprint } from "./src/fingerprint.js";
101
+ import { createDashboardPublisher, type DashboardPublisher } from "./src/dashboard-client.js";
101
102
  import { StateStore, type SessionState } from "./src/state.js";
102
103
  import { LogWriter } from "./src/report.js";
103
104
  import {
@@ -125,7 +126,7 @@ import { SettingsStore } from "./src/settings.js";
125
126
  import { parseSlashCommand } from "./src/commands.js";
126
127
  import { createPlanActionTool } from "./src/tools/plan-action.js";
127
128
  import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
128
- import { wrapFetchForReasoningCleanup } from "./src/reasoning-clean.js";
129
+ import { stripInlineThinkBlocks } from "./src/reasoning-clean.js";
129
130
 
130
131
  // v0.5.0 — visual plan wiring: side-effect executor + plan-fs
131
132
  import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
@@ -244,6 +245,10 @@ interface RuntimeContext {
244
245
  seenMessageIds: Map<string, Set<string>>;
245
246
  /** sessionID → pending system-transform message, set at warn/escalate. */
246
247
  pendingInjections: Map<string, string>;
248
+ /** v0.7.0-alpha.1 — Dashboard publisher (or null if disabled/not started).
249
+ * Used by the `event` hook to forward opencode session lifecycle
250
+ * events to the v2 dashboard via the @polderlabs/bizar-sdk. */
251
+ dashboardPublisher: DashboardPublisher | null;
247
252
  }
248
253
 
249
254
  /**
@@ -333,10 +338,11 @@ async function init(
333
338
 
334
339
  // --- Background agents (v0.4.2) -----------------------------------------
335
340
 
336
- let instanceManager: InstanceManager | null = null;
337
- let serve: ServeLifecycle | null = null;
338
- let stream: EventStream | null = null;
339
- let bgAvailable = false;
341
+ let instanceManager: InstanceManager | null = null;
342
+ let serve: ServeLifecycle | null = null;
343
+ let stream: EventStream | null = null;
344
+ let dashboardPublisher: DashboardPublisher | null = null;
345
+ let bgAvailable = false;
340
346
 
341
347
  if (readServeDisabled()) {
342
348
  logger.info("bizar: background agents disabled via BIZAR_SERVE_DISABLE=1");
@@ -394,6 +400,44 @@ async function init(
394
400
  });
395
401
  streamHandle = stream;
396
402
 
403
+ // v0.7.0-alpha.1 — Wire dashboard publisher to the EventStream so
404
+ // every opencode SSE event is also published to the v2 dashboard.
405
+ // The publisher gracefully degrades if the dashboard is unreachable
406
+ // (queues, retries, warns; never throws into the plugin).
407
+ try {
408
+ const pub = createDashboardPublisher({
409
+ logger,
410
+ });
411
+ await pub.start();
412
+ dashboardPublisher = pub;
413
+ stream.onEvent((event) => {
414
+ // Translate opencode StreamEvent → DashboardEvent shape.
415
+ // The dashboard only cares about the wire-level (type, properties)
416
+ // so we forward { type, properties } directly. Cast through
417
+ // `unknown` because the opencode event shape doesn't exactly
418
+ // match the SDK's discriminated DashboardEvent — it's a
419
+ // forward-compatible passthrough.
420
+ const dashEvent = {
421
+ type: event.type,
422
+ properties: { ...event },
423
+ } as unknown as Parameters<DashboardPublisher["publish"]>[0];
424
+ void pub.publish(dashEvent);
425
+ });
426
+ // Stop the publisher when the stream is disposed.
427
+ const origDisconnect = stream.disconnect.bind(stream);
428
+ stream.disconnect = async () => {
429
+ await origDisconnect();
430
+ pub.stop();
431
+ };
432
+ logger.info("bizar: dashboard publisher wired to EventStream (v2 protocol)");
433
+ } catch (err) {
434
+ logger.warn(
435
+ `bizar: dashboard publisher failed to start: ${
436
+ err instanceof Error ? err.message : String(err)
437
+ }`,
438
+ );
439
+ }
440
+
397
441
  instanceManager = new InstanceManager({
398
442
  stateStore: bgStateStore,
399
443
  maxConcurrent,
@@ -469,6 +513,7 @@ async function init(
469
513
  directory: input.directory,
470
514
  seenMessageIds: new Map(),
471
515
  pendingInjections: new Map(),
516
+ dashboardPublisher,
472
517
  };
473
518
 
474
519
  return buildHooks(ctx, { instanceManager, bgAvailable });
@@ -700,6 +745,47 @@ async function listPlanSlugs(worktree: string, logger: Logger): Promise<string[]
700
745
  * delegates to the runtime context and the supporting modules.
701
746
  */
702
747
  function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
748
+ // ────────────────────────────────────────────────────────────────────
749
+ // v0.6.2 — Reasoning directive
750
+ // ────────────────────────────────────────────────────────────────────
751
+ // Some reasoning models (notably MiniMax M3 via OpenRouter) emit
752
+ // their chain-of-thought in BOTH the structured `reasoning` /
753
+ // `reasoning_details` field AND inline as `` blocks inside
754
+ // `message.content`. opencode's openrouter SDK extracts the structured
755
+ // reasoning correctly and renders it as a separate "Thought" panel,
756
+ // but it does NOT strip the inline blocks from `content`, so the user
757
+ // sees the same thinking text twice — once in the proper panel and
758
+ // again as visible message text below it.
759
+ //
760
+ // The opencode plugin API in this version does NOT trigger a
761
+ // `config` hook (the `wrap-fetch` workaround from v0.6.1 is dead
762
+ // code in current builds), so we cannot post-process the response
763
+ // stream. The only working hooks that can help are:
764
+ //
765
+ // 1. `experimental.chat.system.transform` — runs every turn; we
766
+ // push a directive telling the model to put thinking in the
767
+ // structured field only.
768
+ // 2. `experimental.chat.messages.transform` — runs before each
769
+ // request; we strip `` blocks from previous assistant
770
+ // messages so the model sees clean history and is less likely
771
+ // to keep emitting inline ``.
772
+ //
773
+ // Neither fixes the CURRENT response (the model has already
774
+ // returned), but together they strongly reduce — and in many cases
775
+ // eliminate — the duplication on subsequent turns.
776
+ const REASONING_DIRECTIVE_MARKER = "BIZAR_REASONING_DIRECTIVE_v0.6.2";
777
+ const REASONING_DIRECTIVE = [
778
+ REASONING_DIRECTIVE_MARKER,
779
+ "",
780
+ "When reasoning is enabled for this conversation, output your thinking",
781
+ "ONLY in the model's structured reasoning field. Do NOT emit `` blocks",
782
+ "inline inside your message content — the opencode host extracts the",
783
+ "reasoning field and renders it as a separate, collapsable \"Thought\"",
784
+ "panel. If you also emit the same text inline, the user will see your",
785
+ "thinking twice (once in the panel and once as visible message body).",
786
+ "Keep the actual response text in the normal content stream.",
787
+ ].join(" ");
788
+
703
789
  // Build the 7 tools. We always register them; if the serve child is
704
790
  // not available, the background tools return a clear error. The
705
791
  // bizar_get_plan_comments, bizar_plan_action, and
@@ -707,7 +793,7 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
707
793
  // work regardless of the serve child's state.
708
794
  //
709
795
  // v0.4.0 — added `bizar_plan_action` (CRUD on the v2 canvas) and
710
- // `bizar_wait_for_feedback` (poll until feedback). Both are pure
796
+ // bizar_wait_for_feedback (poll until feedback). Both are pure
711
797
  // file I/O — no serve child required.
712
798
  //
713
799
  // v0.5.0 — renamed `bizarre_*` → `bizar_*` (single `r`) to match
@@ -731,9 +817,13 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
731
817
  const tools = bg.instanceManager
732
818
  ? {
733
819
  ...basePlanTools,
820
+ // v0.8.0 — bg-spawn no longer needs the HTTP client. It
821
+ // spawns an `opencode run` subprocess per agent (see
822
+ // src/opencode-runner.ts). The serve child is still
823
+ // available for the dashboard's v2 protocol and for any
824
+ // TUI/web client that wants to attach to it.
734
825
  bizar_spawn_background: createBgSpawnTool({
735
826
  instanceManager: bg.instanceManager,
736
- http: (bg.instanceManager as unknown as { http: HttpClient }).http,
737
827
  worktree: ctx.worktree,
738
828
  logger: ctx.logger,
739
829
  }),
@@ -756,36 +846,57 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
756
846
  };
757
847
 
758
848
  return {
759
- // §3.1 config: wrap provider fetches to strip duplicated inline
760
- // think blocks from responses of reasoning models that emit BOTH a
761
- // structured reasoning field (rendered as a thought) AND an inline
762
- // `` block (which would otherwise leak into the visible message).
763
- // See plugins/bizar/src/reasoning-clean.ts for the full rationale.
764
- config: async (cfg) => {
849
+ // Push a persistent system-prompt directive that tells reasoning
850
+ // models to put their thinking in the structured reasoning field
851
+ // (rendered as a separate "Thought" panel by opencode) rather than
852
+ // also emitting it inline as `` blocks in the content. The openrouter
853
+ // SDK does not strip the inline `` blocks, so without this
854
+ // directive the user sees the reasoning twice — once in the proper
855
+ // panel and once as visible message text.
856
+ //
857
+ // We push the directive only when the marker is absent so we don't
858
+ // append it on every turn.
859
+ "experimental.chat.system.transform": async (input, output) => {
860
+ const sessionID = input.sessionID;
861
+ if (!sessionID) return;
862
+ if (!output.system.some((s) => s.includes(REASONING_DIRECTIVE_MARKER))) {
863
+ output.system.push(REASONING_DIRECTIVE);
864
+ }
865
+ // §3.1, §5.4 — handoff injection point. We push a single string
866
+ // onto `output.system` if a pending injection is queued for this
867
+ // session.
868
+ const pending = ctx.pendingInjections.get(sessionID);
869
+ if (pending) {
870
+ output.system.push(pending);
871
+ ctx.pendingInjections.delete(sessionID);
872
+ }
873
+ },
874
+
875
+ // Before the model is called, strip `` blocks from
876
+ // any text content in previous assistant messages. This keeps the
877
+ // model's view of its own history clean of the duplicated thinking
878
+ // it emitted earlier, reducing the chance it will keep emitting
879
+ // inline `` on subsequent turns.
880
+ "experimental.chat.messages.transform": async (_input, output) => {
765
881
  try {
766
- const providers = (cfg as { provider?: Record<string, unknown> } | undefined)?.provider;
767
- if (!providers || typeof providers !== "object") return;
768
- const debug = (msg: string) => ctx.logger.debug(`bizar: ${msg}`);
769
- for (const [name, provider] of Object.entries(providers)) {
770
- if (!provider || typeof provider !== "object") continue;
771
- const prov = provider as { options?: Record<string, unknown> };
772
- if (!prov.options || typeof prov.options !== "object") continue;
773
- const original = prov.options.fetch;
774
- if (typeof original !== "function") continue;
775
- // Only wrap once detect by stamping a sentinel.
776
- const wrapped = (original as { __bizarReasoningClean?: boolean })
777
- .__bizarReasoningClean;
778
- if (wrapped) continue;
779
- prov.options.fetch = wrapFetchForReasoningCleanup(
780
- original as Parameters<typeof wrapFetchForReasoningCleanup>[0],
781
- { debug, providers: [name] },
782
- );
783
- (prov.options.fetch as { __bizarReasoningClean?: boolean }).__bizarReasoningClean = true;
784
- debug(`wrapped provider.fetch for ${name}`);
882
+ const messages = (output as { messages?: unknown }).messages;
883
+ if (!Array.isArray(messages)) return;
884
+ for (const msg of messages) {
885
+ if (!msg || typeof msg !== "object") continue;
886
+ const m = msg as { role?: unknown; parts?: unknown };
887
+ if (m.role !== "assistant") continue;
888
+ if (!Array.isArray(m.parts)) continue;
889
+ for (const part of m.parts) {
890
+ if (!part || typeof part !== "object") continue;
891
+ const p = part as { type?: unknown; text?: unknown };
892
+ if (p.type === "text" && typeof p.text === "string" && p.text.includes("<think>")) {
893
+ p.text = stripInlineThinkBlocks(p.text);
894
+ }
895
+ }
785
896
  }
786
897
  } catch (err) {
787
898
  ctx.logger.warn(
788
- `bizar: config hook failed (passing through): ${
899
+ `bizar: messages.transform failed (passing through): ${
789
900
  err instanceof Error ? err.message : String(err)
790
901
  }`,
791
902
  );
@@ -797,10 +908,37 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
797
908
  // `chat.message` seed, per spec §4.5.1).
798
909
  event: async ({ event }) => {
799
910
  try {
800
- const ev = event as { type?: string; sessionID?: string };
911
+ // v0.7.0-alpha.1 opencode's event object has { type: string,
912
+ // properties: { sessionID: string, ... } }. The legacy plugin
913
+ // assumed `event.sessionID` was top-level (which is wrong), so
914
+ // the hook returned early for every event. We extract from
915
+ // BOTH locations to be robust across opencode versions, and
916
+ // publish to the dashboard regardless.
917
+ const ev = event as {
918
+ type?: string;
919
+ sessionID?: string;
920
+ properties?: { sessionID?: string; [k: string]: unknown };
921
+ };
801
922
  const type = ev.type;
802
- const sessionID = ev.sessionID;
803
- if (!type || !sessionID) return;
923
+ const sessionID = ev.sessionID ?? ev.properties?.sessionID;
924
+ if (!type) return;
925
+
926
+ // v0.7.0-alpha.1 — Forward every opencode event to the dashboard.
927
+ // The plugin SDK does NOT translate opencode events to the SDK's
928
+ // discriminated DashboardEvent shape (it's a forward-compatible
929
+ // passthrough — the dashboard is happy with any {type, properties}
930
+ // event object). The publish is fire-and-forget; failures are
931
+ // logged and swallowed inside the publisher.
932
+ if (ctx.dashboardPublisher !== null) {
933
+ const dashEvent = {
934
+ type,
935
+ properties: { ...ev },
936
+ } as unknown as Parameters<DashboardPublisher["publish"]>[0];
937
+ void ctx.dashboardPublisher.publish(dashEvent);
938
+ }
939
+
940
+ // Legacy logic below: only runs when we have a sessionID.
941
+ if (!sessionID) return;
804
942
 
805
943
  if (type === "session.deleted") {
806
944
  await ctx.stateStore.withLock(sessionID, async () => {
@@ -1102,18 +1240,6 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
1102
1240
  }
1103
1241
  },
1104
1242
 
1105
- // §3.1, §5.4 — handoff injection point. We push a single string onto
1106
- // `output.system` if a pending injection is queued for this session.
1107
- "experimental.chat.system.transform": async (input, output) => {
1108
- const sessionID = input.sessionID;
1109
- if (!sessionID) return;
1110
- const pending = ctx.pendingInjections.get(sessionID);
1111
- if (pending) {
1112
- output.system.push(pending);
1113
- ctx.pendingInjections.delete(sessionID);
1114
- }
1115
- },
1116
-
1117
1243
  // v0.4.2 — register the 4 background tools.
1118
1244
  tool: tools,
1119
1245
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-plugin",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Bizar opencode plugin — loop detection, status reporting, handoff signal, background agents, and slash commands + visual plan flow for subagent activity",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -26,6 +26,7 @@
26
26
  "node": ">=20"
27
27
  },
28
28
  "dependencies": {
29
+ "@polderlabs/bizar-sdk": "*",
29
30
  "zod": "4.1.8"
30
31
  },
31
32
  "devDependencies": {
@@ -107,8 +107,32 @@ export type BackgroundStatus =
107
107
  * - `restartCount` — number of times this instance has been
108
108
  * auto-restarted (not including the original spawn). Default 0.
109
109
  * - `maxRestarts` — cap; default 3.
110
- * - `lastRestartAt` — epoch ms of the most recent auto-restart.
111
- */
110
+ * - `lastRestartAt` — epoch ms of the most recent auto-restart.
111
+ * - `processId` (v0.8.0) — PID of the opencode run subprocess. Set
112
+ * by the opencode-runner after `Bun.spawn` returns. Optional
113
+ * for backward compat.
114
+ * - `exitCode` (v0.8.0) — exit code of the opencode run
115
+ * subprocess. Populated when the process exits.
116
+ * - `runnerState` (v0.8.0) — free-form status from the runner
117
+ * ("starting" | "running" | "done" | "failed" | "killed").
118
+ * Allows the dashboard to show runner-level state separately
119
+ * from the higher-level `status`.
120
+ * - `runnerError` (v0.8.0) — opencode run subprocess error (e.g.
121
+ * "opencode run exited with code 1").
122
+ * - `spawnMessage` (v0.8.0) — "you can continue" message returned
123
+ * to the LLM by the spawn tool. Surfaces in the dashboard.
124
+ * - `spawnNextSteps` (v0.8.0) — list of next-step hints returned
125
+ * to the LLM by the spawn tool.
126
+ * - `sessionIdAt` (v0.8.0) — when the opencode sessionId was
127
+ * first observed in the subprocess stderr.
128
+ * - `runnerStartedAt` / `runnerEndedAt` — when the subprocess
129
+ * started / ended (subset of `startedAt`/`completedAt`).
130
+ * - `spawnedAt` — when the bg-spawn tool recorded the instance.
131
+ * Distinct from `startedAt` because the runner starts a
132
+ * tick or two later.
133
+ * - `exitSignal` — exit signal name (e.g. "SIGTERM",
134
+ * "SIGKILL") if the subprocess was killed.
135
+ */
112
136
  export interface BackgroundState {
113
137
  instanceId: string;
114
138
  sessionId: string;
@@ -153,6 +177,18 @@ export interface BackgroundState {
153
177
  * `{ ok: false }`. Cleared on a successful restart.
154
178
  */
155
179
  restartError?: string;
180
+ // v0.8.0 — process tracking (see opencode-runner.ts).
181
+ processId?: number;
182
+ exitCode?: number;
183
+ runnerState?: string;
184
+ runnerError?: string;
185
+ spawnMessage?: string;
186
+ spawnNextSteps?: string[];
187
+ sessionIdAt?: number;
188
+ runnerStartedAt?: number;
189
+ runnerEndedAt?: number;
190
+ spawnedAt?: number;
191
+ exitSignal?: string;
156
192
  }
157
193
 
158
194
  /**
package/src/background.ts CHANGED
@@ -866,6 +866,19 @@ export class InstanceManager {
866
866
  }
867
867
  }
868
868
 
869
+ /**
870
+ * v0.8.0 — Public version of `_maybeAutoRestart`. The opencode-runner
871
+ * (see src/opencode-runner.ts) calls this from its onExit callback
872
+ * when a `bizar_spawn_background` subprocess exits. Without this,
873
+ * persistent instances would never auto-restart under the new
874
+ * subprocess-based path (the SSE event handler that previously
875
+ * triggered auto-restart is no longer wired up because we don't
876
+ * subscribe to per-session events anymore).
877
+ */
878
+ async maybeAutoRestart(instanceId: string): Promise<void> {
879
+ await this._maybeAutoRestart(instanceId);
880
+ }
881
+
869
882
  /** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
870
883
  private async _maybeAutoRestart(instanceId: string): Promise<void> {
871
884
  const inst = this.instances.get(instanceId);
package/src/commands.ts CHANGED
@@ -314,7 +314,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
314
314
  // No argument — return current state as a dialog
315
315
  return {
316
316
  handled: true,
317
- response: "",
317
+ response: `Visual plan mode is currently ${currentEnabled ? "on" : "off"}.`,
318
318
  dialog: {
319
319
  id: generateId(),
320
320
  title: "Visual Plan",
@@ -334,7 +334,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
334
334
  if (lc === "on" || lc === "true" || lc === "1" || lc === "enable") {
335
335
  return {
336
336
  handled: true,
337
- response: "",
337
+ response: `Visual plan mode is now on.`,
338
338
  settingsPatch: { visualPlanEnabled: true },
339
339
  dialog: {
340
340
  id: generateId(),
@@ -353,7 +353,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
353
353
  if (lc === "off" || lc === "false" || lc === "0" || lc === "disable") {
354
354
  return {
355
355
  handled: true,
356
- response: "",
356
+ response: `Visual plan mode is now off.`,
357
357
  settingsPatch: { visualPlanEnabled: false },
358
358
  dialog: {
359
359
  id: generateId(),
@@ -372,7 +372,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
372
372
  if (lc === "status" || lc === "state" || lc === "?") {
373
373
  return {
374
374
  handled: true,
375
- response: "",
375
+ response: `Visual plan mode is currently ${currentEnabled ? "on" : "off"}.`,
376
376
  dialog: {
377
377
  id: generateId(),
378
378
  title: "Visual Plan",
@@ -444,7 +444,7 @@ function handlePlan(arg: string, ctx: ParseContext): SlashCommandResult {
444
444
  function helpPlan(): SlashCommandResult {
445
445
  return {
446
446
  handled: true,
447
- response: "",
447
+ response: "Plan commands: /plan new <slug> [template] | /plan list | /plan open <slug> | /plan get <slug> | /plan add <slug> | /plan update <slug> <id> | /plan delete <slug> <id> | /plan comment <slug> [id] \"text\" | /plan comments <slug> [id] | /plan status <slug> <status> | /plan wait <slug> [--timeout N]. Run /plan <subcommand> for details.",
448
448
  dialog: {
449
449
  id: generateId(),
450
450
  title: "Plan Commands",
@@ -477,7 +477,7 @@ function handlePlanNew(args: string[], ctx: ParseContext): SlashCommandResult {
477
477
  if (args.length === 0 || args[0] === "") {
478
478
  return {
479
479
  handled: true,
480
- response: "",
480
+ response: "Usage: /plan new <slug> [template]. Available templates: " + KNOWN_TEMPLATES.join(", ") + ".",
481
481
  dialog: {
482
482
  id: generateId(),
483
483
  title: "Create New Plan",
@@ -520,7 +520,7 @@ function handlePlanNew(args: string[], ctx: ParseContext): SlashCommandResult {
520
520
 
521
521
  return {
522
522
  handled: true,
523
- response: "",
523
+ response: `Created plan "${titleCase(slug)}" with the "${resolvedTemplate}" template. Use /plan open ${slug} to open it.`,
524
524
  sideEffect: {
525
525
  kind: "create_plan",
526
526
  slug,
@@ -544,9 +544,26 @@ function handlePlanNew(args: string[], ctx: ParseContext): SlashCommandResult {
544
544
 
545
545
  function handlePlanList(ctx: ParseContext): SlashCommandResult {
546
546
  const slugs = ctx.availablePlanSlugs ?? [];
547
+ if (slugs.length === 0) {
548
+ return {
549
+ handled: true,
550
+ response: "No plans found in the current worktree. Use /plan new <slug> to create one.",
551
+ sideEffect: { kind: "list_plans" },
552
+ dialog: {
553
+ id: generateId(),
554
+ title: "Plans",
555
+ command: "/plan list",
556
+ component: "plan-list",
557
+ data: {
558
+ plans: slugs,
559
+ count: slugs.length,
560
+ },
561
+ },
562
+ };
563
+ }
547
564
  return {
548
565
  handled: true,
549
- response: "",
566
+ response: `Found ${slugs.length} plan(s) (${slugs.length}): ${slugs.join(", ")}.`,
550
567
  sideEffect: { kind: "list_plans" },
551
568
  dialog: {
552
569
  id: generateId(),
@@ -584,7 +601,7 @@ function handlePlanOpen(args: string[], ctx: ParseContext): SlashCommandResult {
584
601
 
585
602
  return {
586
603
  handled: true,
587
- response: "",
604
+ response: `Opening plan "${slug}" at ${url}.`,
588
605
  settingsPatch: { lastUsedSlug: slug },
589
606
  sideEffect: {
590
607
  kind: "open_plan_url",
@@ -618,7 +635,7 @@ function handlePlanGet(args: string[]): SlashCommandResult {
618
635
  }
619
636
  return {
620
637
  handled: true,
621
- response: "",
638
+ response: `Fetching canvas for plan "${slug}"…`,
622
639
  sideEffect: {
623
640
  kind: "tool_invocation",
624
641
  toolName: "bizar_plan_action",
@@ -1078,7 +1095,7 @@ function handleBizar(arg: string, ctx: ParseContext): SlashCommandResult {
1078
1095
  function helpResult(): SlashCommandResult {
1079
1096
  return {
1080
1097
  handled: true,
1081
- response: "",
1098
+ response: "Available commands: /visual-plan [on|off|status], /plan new <slug> [template], /plan list, /plan open <slug>, /plan get <slug>, /plan add <slug>, /plan update <slug> <id>, /plan delete <slug> <id>, /plan comment <slug> [id] \"text\", /plan comments <slug> [id], /plan status <slug> <status>, /plan wait <slug> [--timeout N], /bizar, /bizar <args>, /help. See the dialog for full descriptions.",
1082
1099
  dialog: {
1083
1100
  id: generateId(),
1084
1101
  title: "Bizar Commands",