@bike4mind/cli 0.14.0 → 0.15.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/dist/index.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { $ as OAuthClient, A as substituteArguments, B as DEFAULT_THOROUGHNESS, C as WebSocketToolExecutor, D as ServerLlmBackend, E as WebSocketLlmBackend, F as generateCliTools, G as buildSystemPrompt, H as registerFeatureModuleTools, I as ALWAYS_DENIED_FOR_AGENTS, J as ReActAgent, K as buildSkillsPromptSection, L as DEFAULT_AGENT_MODEL, M as extractCompactInstructions, N as loadContextFiles, O as isTransientNetworkError, P as PermissionManager, Q as SessionStore, R as DEFAULT_MAX_ITERATIONS, S as ApiClient, T as FallbackLlmBackend, U as setWebSocketToolExecutor, V as clearFeatureModuleTools, W as getPlanModeFilePath, X as CheckpointStore, Y as CustomCommandStore, Z as CommandHistoryStore, _ as createAgentDelegateTool, a as createBlockerTools, at as searchFiles, b as createSkillTool, c as createDecisionStore, d as createFindDefinitionTool, et as hasFileReferences, f as createTodoStore, g as BackgroundAgentManager, h as createBackgroundAgentTools, i as createBlockerStore, it as formatFileSize, j as formatStep, k as McpManager, l as formatDecisionsOutput, m as createCoordinateTaskTool, n as createReviewGateTool, nt as searchCommands, o as formatBlockersOutput, ot as warmFileCache, p as createWriteTodosTool, q as isReadOnlyTool, r as formatReviewGatesOutput, rt as mergeCommands, s as createDecisionLogTool, t as createReviewGateStore, tt as processFileReferences, u as createGetFileStructureTool, v as AgentStore, w as WebSocketConnectionManager, x as parseAgentConfig, y as SubagentOrchestrator, z as DEFAULT_RETRY_CONFIG } from "./tools-BTPUXUNS.mjs";
2
+ import { $ as OAuthClient, A as substituteArguments, B as DEFAULT_THOROUGHNESS, C as WebSocketToolExecutor, D as ServerLlmBackend, E as WebSocketLlmBackend, F as generateCliTools, G as buildSystemPrompt, H as registerFeatureModuleTools, I as ALWAYS_DENIED_FOR_AGENTS, J as ReActAgent, K as buildSkillsPromptSection, L as DEFAULT_AGENT_MODEL, M as extractCompactInstructions, N as loadContextFiles, O as isTransientNetworkError, P as PermissionManager, Q as SessionStore, R as DEFAULT_MAX_ITERATIONS, S as ApiClient, T as FallbackLlmBackend, U as setWebSocketToolExecutor, V as clearFeatureModuleTools, W as getPlanModeFilePath, X as CheckpointStore, Y as CustomCommandStore, Z as CommandHistoryStore, _ as createAgentDelegateTool, a as createBlockerTools, at as searchFiles, b as createSkillTool, c as createDecisionStore, d as createFindDefinitionTool, et as hasFileReferences, f as createTodoStore, g as BackgroundAgentManager, h as createBackgroundAgentTools, i as createBlockerStore, it as formatFileSize, j as formatStep, k as McpManager, l as formatDecisionsOutput, m as createCoordinateTaskTool, n as createReviewGateTool, nt as searchCommands, o as formatBlockersOutput, ot as warmFileCache, p as createWriteTodosTool, q as isReadOnlyTool, r as formatReviewGatesOutput, rt as mergeCommands, s as createDecisionLogTool, t as createReviewGateStore, tt as processFileReferences, u as createGetFileStructureTool, v as AgentStore, w as WebSocketConnectionManager, x as parseAgentConfig, y as SubagentOrchestrator, z as DEFAULT_RETRY_CONFIG } from "./tools-ChYlNt33.mjs";
3
3
  import { n as useCliStore, t as selectActiveBackgroundAgents } from "./store-DV5s-qni.mjs";
4
- import { Ut as validateJupyterKernelName, Wt as validateNotebookPath$1, g as CREDIT_DEDUCT_TRANSACTION_TYPES, i as getEnvironmentName, n as logger, r as getApiUrl, t as ConfigStore, v as ChatModels } from "./ConfigStore-B9I7UHuG.mjs";
5
- import { t as version } from "./package-uvIC6spW.mjs";
4
+ import { Gt as validateNotebookPath$1, Wt as validateJupyterKernelName, g as CREDIT_DEDUCT_TRANSACTION_TYPES, i as getEnvironmentName, n as logger, r as getApiUrl, t as ConfigStore, v as ChatModels } from "./ConfigStore-HRgwfPBk.mjs";
5
+ import { t as version } from "./package-CaPvuP1F.mjs";
6
6
  import { r as checkForUpdate } from "./updateChecker-C8xsNY2L.mjs";
7
7
  import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
8
8
  import { Box, Static, Text, render, useApp, useInput, usePaste, useStdout } from "ink";
9
- import { execSync } from "child_process";
9
+ import { execSync, spawn } from "child_process";
10
10
  import { randomBytes, randomUUID } from "crypto";
11
11
  import { existsSync, promises, readFileSync, statSync } from "fs";
12
12
  import { homedir } from "os";
@@ -2839,6 +2839,190 @@ function injectHandoffMessage(messages, handoff) {
2839
2839
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2840
2840
  }, ...messages.filter((m) => !isInjectedHandoff(m))];
2841
2841
  }
2842
+ const LOCAL_HANDOFF_MESSAGE_CHARS = 1500;
2843
+ /**
2844
+ * Build a SessionHandoff purely from local session state, without any LLM
2845
+ * call. The fields are populated directly from workflow state — no synthesis,
2846
+ * no narrative summary. This is the structural fallback when the LLM is
2847
+ * unreachable (rate-limit, network, auth, upstream outage).
2848
+ *
2849
+ * The shape matches the LLM-generated handoff so callers can persist it in
2850
+ * `session.metadata.workflow.handoff` interchangeably.
2851
+ *
2852
+ * `workflowOverride` lets callers pass the authoritative decision/blocker
2853
+ * arrays (typically from in-memory ref stores) when `session.metadata.workflow`
2854
+ * may not yet have been synced from those refs. Without it, the handoff would
2855
+ * reflect a stale snapshot while `applyHandoffToWorkflow` writes the fresh
2856
+ * refs — leaving the handoff and the surrounding workflow object out of sync.
2857
+ */
2858
+ function buildLocalHandoff(session, workflowOverride) {
2859
+ const workflow = session.metadata.workflow;
2860
+ const decisions = workflowOverride?.decisions ?? workflow?.decisions ?? [];
2861
+ const openBlockers = (workflowOverride?.blockers ?? workflow?.blockers ?? []).filter((b) => b.status === "open");
2862
+ return {
2863
+ summary: `Local handoff for session "${session.name}" (${session.messages.length} messages, model ${session.model}). Generated from session state without an LLM call — no narrative synthesis.`,
2864
+ keyFindings: [],
2865
+ nextSteps: [],
2866
+ pendingDecisions: decisions.map((d) => `${d.summary} (rationale: ${d.rationale})`),
2867
+ blockers: openBlockers.map((b) => b.description),
2868
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
2869
+ };
2870
+ }
2871
+ /**
2872
+ * Render a full Markdown handoff document from session state. Includes
2873
+ * session metadata, all decisions and open blockers verbatim, the last
2874
+ * `LOCAL_HANDOFF_MESSAGE_TAIL` messages role-labeled and lightly truncated,
2875
+ * and a pointer to the on-disk session JSON for deeper context.
2876
+ *
2877
+ * If `session.metadata.workflow.handoff` is set (either from an LLM-generated
2878
+ * synthesis or from `buildLocalHandoff`), its narrative sections are rendered
2879
+ * at the top — so both code paths produce a single uniform artifact.
2880
+ *
2881
+ * Used as the durable artifact for `/handoff` (both LLM-backed and
2882
+ * `--local`) and for the auto-fallback when the LLM is unreachable.
2883
+ */
2884
+ function renderLocalHandoffMarkdown(session, sessionJsonPath) {
2885
+ const workflow = session.metadata.workflow;
2886
+ const decisions = workflow?.decisions ?? [];
2887
+ const openBlockers = (workflow?.blockers ?? []).filter((b) => b.status === "open");
2888
+ const resolvedBlockers = (workflow?.blockers ?? []).filter((b) => b.status === "resolved");
2889
+ const tail = session.messages.filter((m) => !isInjectedHandoff(m)).slice(-20);
2890
+ const handoff = workflow?.handoff;
2891
+ const lines = [];
2892
+ lines.push(`# Session handoff: ${session.name}`);
2893
+ lines.push("");
2894
+ lines.push("Durable artifact for resuming this session elsewhere. Captures any synthesized handoff plus decisions, open blockers, and the tail of the conversation verbatim.");
2895
+ lines.push("");
2896
+ if (handoff) {
2897
+ lines.push("## Synthesized handoff");
2898
+ lines.push("");
2899
+ lines.push(handoff.summary);
2900
+ lines.push("");
2901
+ appendMarkdownSection(lines, "Key findings", handoff.keyFindings);
2902
+ appendMarkdownSection(lines, "Next steps", handoff.nextSteps);
2903
+ appendMarkdownSection(lines, "Pending decisions", handoff.pendingDecisions);
2904
+ appendMarkdownSection(lines, "Blockers", handoff.blockers);
2905
+ lines.push(`_Generated at ${handoff.generatedAt}._`);
2906
+ lines.push("");
2907
+ }
2908
+ lines.push("## Session metadata");
2909
+ lines.push("");
2910
+ lines.push(`- **Session ID:** \`${session.id}\``);
2911
+ lines.push(`- **Model:** ${session.model}`);
2912
+ lines.push(`- **Created:** ${session.createdAt}`);
2913
+ lines.push(`- **Updated:** ${session.updatedAt}`);
2914
+ lines.push(`- **Messages:** ${session.messages.length}`);
2915
+ lines.push(`- **Tool calls:** ${session.metadata.toolCallCount}`);
2916
+ lines.push(`- **Total cost:** $${session.metadata.totalCost.toFixed(4)}`);
2917
+ lines.push(`- **Generated:** ${(/* @__PURE__ */ new Date()).toISOString()}`);
2918
+ if (sessionJsonPath) lines.push(`- **Full session JSON:** \`${sessionJsonPath}\``);
2919
+ lines.push("");
2920
+ lines.push(`## Decisions (${decisions.length})`);
2921
+ lines.push("");
2922
+ if (decisions.length === 0) lines.push("_No decisions logged._");
2923
+ else for (const d of decisions) {
2924
+ lines.push(`### ${d.summary}`);
2925
+ lines.push("");
2926
+ lines.push(`- **Rationale:** ${d.rationale}`);
2927
+ if (d.alternatives && d.alternatives.length > 0) lines.push(`- **Alternatives considered:** ${d.alternatives.join("; ")}`);
2928
+ if (d.context) lines.push(`- **Context:** ${d.context}`);
2929
+ lines.push(`- **Logged at:** ${d.timestamp}`);
2930
+ lines.push("");
2931
+ }
2932
+ lines.push(`## Open blockers (${openBlockers.length})`);
2933
+ lines.push("");
2934
+ if (openBlockers.length === 0) lines.push("_No open blockers._");
2935
+ else for (const b of openBlockers) lines.push(`- ${b.description} _(opened ${b.createdAt})_`);
2936
+ lines.push("");
2937
+ if (resolvedBlockers.length > 0) {
2938
+ lines.push(`## Resolved blockers (${resolvedBlockers.length})`);
2939
+ lines.push("");
2940
+ for (const b of resolvedBlockers) {
2941
+ const resolution = b.resolution ? ` → ${b.resolution}` : "";
2942
+ lines.push(`- ${b.description}${resolution}`);
2943
+ }
2944
+ lines.push("");
2945
+ }
2946
+ lines.push(`## Last ${tail.length} messages`);
2947
+ lines.push("");
2948
+ if (tail.length === 0) lines.push("_No conversation messages._");
2949
+ else for (const msg of tail) {
2950
+ const role = ROLE_LABELS[msg.role] || "System";
2951
+ const content = msg.content.length > LOCAL_HANDOFF_MESSAGE_CHARS ? msg.content.slice(0, LOCAL_HANDOFF_MESSAGE_CHARS) + "\n\n_...[truncated]_" : msg.content;
2952
+ lines.push(`### ${role} — ${msg.timestamp}`);
2953
+ lines.push("");
2954
+ lines.push(content);
2955
+ lines.push("");
2956
+ }
2957
+ return lines.join("\n");
2958
+ }
2959
+ function appendMarkdownSection(lines, heading, items) {
2960
+ if (items.length === 0) return;
2961
+ lines.push(`**${heading}:**`);
2962
+ lines.push("");
2963
+ for (const item of items) lines.push(`- ${item}`);
2964
+ lines.push("");
2965
+ }
2966
+ function defaultLocalHandoffDir() {
2967
+ return path.join(homedir(), ".bike4mind", "handoffs");
2968
+ }
2969
+ function buildLocalHandoffFileName(session, now) {
2970
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
2971
+ return `${session.id}-${stamp}.md`;
2972
+ }
2973
+ /**
2974
+ * Write a local handoff Markdown file to `~/.bike4mind/handoffs/` and return
2975
+ * the absolute path. Creates the directory if missing. The session JSON path
2976
+ * is embedded in the markdown so the user (or another agent) can locate the
2977
+ * full session for deeper context.
2978
+ *
2979
+ * `dir` is overridable for tests.
2980
+ */
2981
+ async function writeLocalHandoffFile(session, options = {}) {
2982
+ const dir = options.dir ?? defaultLocalHandoffDir();
2983
+ const now = options.now ?? /* @__PURE__ */ new Date();
2984
+ const sessionJsonPath = options.sessionJsonPath ?? path.join(homedir(), ".bike4mind", "sessions", `${session.id}.json`);
2985
+ await promises.mkdir(dir, {
2986
+ recursive: true,
2987
+ mode: 448
2988
+ });
2989
+ const filePath = path.join(dir, buildLocalHandoffFileName(session, now));
2990
+ const markdown = renderLocalHandoffMarkdown(session, sessionJsonPath);
2991
+ await promises.writeFile(filePath, markdown, {
2992
+ encoding: "utf-8",
2993
+ mode: 384
2994
+ });
2995
+ return filePath;
2996
+ }
2997
+ /**
2998
+ * True if an error from the LLM completion path indicates the network call
2999
+ * itself could not complete — rate limit, network drop, auth failure, or
3000
+ * upstream outage. Used to decide when to auto-fall back to the local
3001
+ * (LLM-free) handoff path.
3002
+ *
3003
+ * Conservative on purpose: a malformed-response or parse error from a server
3004
+ * that *did* answer is NOT an LLM-unavailable condition — the user can retry,
3005
+ * and falling back would mask a real bug.
3006
+ *
3007
+ * Keep the substring matches below in sync with the error strings thrown by
3008
+ * `apps/cli/src/llm/ServerLlmBackend.ts` (see its catch block around the
3009
+ * `Request failed with status` / `Authentication ...` / `Cannot connect ...`
3010
+ * / `Failed to complete LLM request` throws). Renames there will silently
3011
+ * break the auto-fallback — a typed error hierarchy would be a more robust
3012
+ * long-term fix.
3013
+ *
3014
+ * Note on 403: ServerLlmBackend throws `403 Forbidden: <details>` for
3015
+ * WAF/server-blocked requests. We deliberately do NOT classify these as
3016
+ * unavailable — a 403 typically means the user needs to take action
3017
+ * (re-auth, contact support, fix WAF rule) and silently degrading would
3018
+ * mask that. Real auth failures already surface via the `Authentication ...`
3019
+ * messages above. Add 403 here only if a concrete use case warrants it.
3020
+ */
3021
+ function isLlmUnavailableError(error) {
3022
+ if (!(error instanceof Error)) return false;
3023
+ const message = error.message;
3024
+ return message.includes("Rate limit exceeded") || message.includes("Authentication expired") || message.includes("Authentication failed") || message.includes("Cannot connect to Bike4Mind server") || message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT") || message.includes("ENOTFOUND") || message.includes("ECONNRESET") || message.includes("Failed to complete LLM request") || /Request failed with status 5\d\d/.test(message) || /^5\d\d\b/.test(message);
3025
+ }
2842
3026
  //#endregion
2843
3027
  //#region src/utils/compaction.ts
2844
3028
  /**
@@ -3622,6 +3806,106 @@ var MultiLlmBackend = class {
3622
3806
  }
3623
3807
  };
3624
3808
  //#endregion
3809
+ //#region src/utils/peonNotifier.ts
3810
+ /**
3811
+ * peon-ping notifier — native b4m adapter for peon-ping (https://peonping.com).
3812
+ *
3813
+ * peon-ping plays game-character voice lines + on-screen banners when an AI
3814
+ * coding agent finishes a turn or needs attention. It consumes a small JSON
3815
+ * event on stdin (the "CESP" hook contract shared by Claude Code and every
3816
+ * peon-ping adapter):
3817
+ *
3818
+ * { hook_event_name, notification_type, cwd, session_id, permission_mode }
3819
+ *
3820
+ * This module is the b4m CLI's equivalent of the shell adapters peon-ping
3821
+ * ships for other IDEs (see `adapters/openclaw.sh`), except it runs in-process
3822
+ * and is wired directly to the CLI's lifecycle via a store subscription.
3823
+ *
3824
+ * Behaviour is auto-detect: if a `peon.sh` is found on disk it is enabled,
3825
+ * otherwise every call is a silent no-op. Set `B4M_PEON_PING=0` to force it
3826
+ * off even when installed.
3827
+ */
3828
+ /**
3829
+ * Resolve the path to `peon.sh`, checking the same locations peon-ping's own
3830
+ * adapters use. Returns null when peon-ping is not installed. Resolved once and
3831
+ * cached: `null` means "looked and found nothing".
3832
+ */
3833
+ let resolvedScript;
3834
+ function findPeonScript() {
3835
+ if (resolvedScript !== void 0) return resolvedScript;
3836
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude");
3837
+ resolvedScript = [
3838
+ process.env.CLAUDE_PEON_DIR ? path.join(process.env.CLAUDE_PEON_DIR, "peon.sh") : null,
3839
+ path.join(claudeDir, "hooks", "peon-ping", "peon.sh"),
3840
+ path.join(homedir(), ".openpeon", "peon.sh")
3841
+ ].filter((p) => p !== null).find((p) => existsSync(p)) ?? null;
3842
+ return resolvedScript;
3843
+ }
3844
+ /** Explicit off-switch via env, even when peon-ping is installed. */
3845
+ function isDisabledByEnv() {
3846
+ const v = process.env.B4M_PEON_PING?.toLowerCase();
3847
+ return v === "0" || v === "false" || v === "off";
3848
+ }
3849
+ /** True when peon-ping is installed and not disabled. */
3850
+ function isPeonAvailable() {
3851
+ return !isDisabledByEnv() && findPeonScript() !== null;
3852
+ }
3853
+ /**
3854
+ * Fire a single peon-ping event. Fire-and-forget and fail-safe: the child is
3855
+ * detached and unref'd, all output is discarded, and any spawn error is logged
3856
+ * to the debug log but never surfaced — the CLI must never break because of a
3857
+ * missing or misbehaving peon install.
3858
+ */
3859
+ function notifyPeon(event, options = {}) {
3860
+ if (isDisabledByEnv()) return;
3861
+ const script = findPeonScript();
3862
+ if (!script) return;
3863
+ const payload = JSON.stringify({
3864
+ hook_event_name: event,
3865
+ notification_type: options.notificationType ?? "",
3866
+ cwd: process.cwd(),
3867
+ session_id: useCliStore.getState().session?.id ?? `b4m-${process.pid}`,
3868
+ permission_mode: ""
3869
+ });
3870
+ try {
3871
+ const child = spawn("bash", [script], {
3872
+ stdio: [
3873
+ "pipe",
3874
+ "ignore",
3875
+ "ignore"
3876
+ ],
3877
+ detached: true
3878
+ });
3879
+ child.on("error", (err) => logger.debug(`peon-ping spawn failed: ${err.message}`));
3880
+ child.stdin?.on("error", () => {});
3881
+ child.stdin?.end(payload);
3882
+ child.unref();
3883
+ } catch (err) {
3884
+ logger.debug(`peon-ping notify error: ${err.message}`);
3885
+ }
3886
+ }
3887
+ /**
3888
+ * Subscribe to CLI lifecycle and emit peon-ping events:
3889
+ * - `Stop` when the agent finishes a turn (isThinking true → false)
3890
+ * - `Notification` (permission_prompt) when a permission / user-question /
3891
+ * review-gate prompt first appears and the user needs to act
3892
+ *
3893
+ * Fires `SessionStart` immediately. Returns an unsubscribe function; call it
3894
+ * (and pass `emitSessionEnd`) on exit to emit `SessionEnd`.
3895
+ */
3896
+ function startPeonNotifier() {
3897
+ if (!isPeonAvailable()) return () => {};
3898
+ notifyPeon("SessionStart");
3899
+ return useCliStore.subscribe((state, prev) => {
3900
+ if (prev.isThinking && !state.isThinking) notifyPeon("Stop");
3901
+ if (!prev.permissionPrompt && !!state.permissionPrompt || !prev.userQuestionPrompt && !!state.userQuestionPrompt || !prev.reviewGatePrompt && !!state.reviewGatePrompt) notifyPeon("Notification", { notificationType: "permission_prompt" });
3902
+ });
3903
+ }
3904
+ /** Emit `SessionEnd`. Call on CLI exit. */
3905
+ function emitPeonSessionEnd() {
3906
+ notifyPeon("SessionEnd");
3907
+ }
3908
+ //#endregion
3625
3909
  //#region src/agents/dynamicAgentTool.ts
3626
3910
  /**
3627
3911
  * Create the create_dynamic_agent tool
@@ -6484,6 +6768,13 @@ function CliApp() {
6484
6768
  useEffect(() => {
6485
6769
  init();
6486
6770
  }, [init]);
6771
+ useEffect(() => {
6772
+ const unsubscribe = startPeonNotifier();
6773
+ return () => {
6774
+ unsubscribe();
6775
+ emitPeonSessionEnd();
6776
+ };
6777
+ }, []);
6487
6778
  const handleMessageRef = useRef(null);
6488
6779
  const abortControllerRef = useRef(state.abortController);
6489
6780
  abortControllerRef.current = state.abortController;
@@ -7179,12 +7470,71 @@ function CliApp() {
7179
7470
  }
7180
7471
  };
7181
7472
  /**
7473
+ * Apply `handoff` to the session's workflow state, pulling the latest
7474
+ * decisions/blockers/review-gates from their stores. Centralizes the
7475
+ * workflow assembly so the LLM-backed path and the local fallback path
7476
+ * cannot drift apart on which fields land on `session.metadata.workflow`.
7477
+ */
7478
+ const applyHandoffToWorkflow = (session, handoff) => {
7479
+ session.metadata.workflow = {
7480
+ decisions: decisionStoreRef.current.decisions,
7481
+ blockers: blockerStoreRef.current.blockers,
7482
+ handoff,
7483
+ reviewGates: reviewGateStoreRef.current.reviewGates
7484
+ };
7485
+ };
7486
+ /**
7487
+ * Best-effort write of the Markdown handoff artifact to
7488
+ * `~/.bike4mind/handoffs/`. Returns the file path on success, null on
7489
+ * filesystem failure — callers should not block on this.
7490
+ *
7491
+ * Reads `session.metadata.workflow.handoff` to include the narrative
7492
+ * synthesis section (if any), so this works uniformly for both the
7493
+ * LLM-backed path and the local fallback path — see issue #8806.
7494
+ */
7495
+ const writeHandoffMarkdown = async (session) => {
7496
+ try {
7497
+ return await writeLocalHandoffFile(session);
7498
+ } catch (err) {
7499
+ const reason = err instanceof Error ? err.message : String(err);
7500
+ logger.debug(`Handoff markdown write failed: ${reason}`);
7501
+ return null;
7502
+ }
7503
+ };
7504
+ /**
7505
+ * Build a handoff purely from local session state — no LLM call. Mutates
7506
+ * the session's workflow state with the local handoff and writes a Markdown
7507
+ * file to `~/.bike4mind/handoffs/`.
7508
+ *
7509
+ * Used both as the explicit `--local` path and as the auto-fallback when
7510
+ * the LLM is unreachable (rate limit, network, auth) — see issue #8806.
7511
+ */
7512
+ const writeLocalFallbackHandoff = async (session) => {
7513
+ const handoff = buildLocalHandoff(session, {
7514
+ decisions: decisionStoreRef.current.decisions,
7515
+ blockers: blockerStoreRef.current.blockers
7516
+ });
7517
+ applyHandoffToWorkflow(session, handoff);
7518
+ const filePath = await writeHandoffMarkdown(session);
7519
+ if (!filePath) return null;
7520
+ return {
7521
+ handoff,
7522
+ filePath
7523
+ };
7524
+ };
7525
+ /**
7182
7526
  * Generate a structured session handoff via a single LLM call and persist it
7183
7527
  * onto the session's workflow state. Returns the handoff on success, or null
7184
- * if generation was skipped (short session) or failed (parse / agent error).
7528
+ * if generation was skipped (short session) or failed unrecoverably.
7529
+ *
7530
+ * If the LLM is unavailable (rate-limit, network, auth, upstream outage),
7531
+ * automatically falls back to a local handoff written to disk so the user
7532
+ * always has a usable artifact — see issue #8806. Falling back is silent at
7533
+ * the data layer; the caller surfaces the path to the user.
7185
7534
  *
7186
- * Failures are best-effort and surfaced as a warning rather than thrown —
7187
- * the surrounding /save flow must not block on handoff generation.
7535
+ * Other failures (parse errors, short sessions) are best-effort and surfaced
7536
+ * as warnings rather than thrown — the surrounding /save flow must not block
7537
+ * on handoff generation.
7188
7538
  *
7189
7539
  * Callers are responsible for saving the session afterwards.
7190
7540
  */
@@ -7202,17 +7552,27 @@ function CliApp() {
7202
7552
  logger.debug(`Handoff response: ${result.finalAnswer.slice(0, 500)}`);
7203
7553
  return null;
7204
7554
  }
7205
- session.metadata.workflow = {
7206
- decisions: decisionStoreRef.current.decisions,
7207
- blockers: blockerStoreRef.current.blockers,
7555
+ applyHandoffToWorkflow(session, handoff);
7556
+ return {
7208
7557
  handoff,
7209
- reviewGates: reviewGateStoreRef.current.reviewGates
7558
+ filePath: await writeHandoffMarkdown(session),
7559
+ source: "llm"
7210
7560
  };
7211
- return handoff;
7212
7561
  } catch (err) {
7213
7562
  const reason = err instanceof Error ? err.message : String(err);
7214
- console.warn(`⚠️ Handoff generation failed: ${reason}`);
7215
7563
  logger.debug(`Handoff generation error: ${reason}`);
7564
+ if (isLlmUnavailableError(err)) {
7565
+ console.warn(`⚠️ LLM unavailable for handoff generation: ${reason}`);
7566
+ const local = await writeLocalFallbackHandoff(session);
7567
+ if (local) return {
7568
+ handoff: local.handoff,
7569
+ filePath: local.filePath,
7570
+ source: "local-fallback"
7571
+ };
7572
+ console.warn("⚠️ Local handoff fallback also failed; no handoff produced.");
7573
+ return null;
7574
+ }
7575
+ console.warn(`⚠️ Handoff generation failed: ${reason}`);
7216
7576
  return null;
7217
7577
  } finally {
7218
7578
  useCliStore.getState().setIsThinking(false);
@@ -7258,9 +7618,12 @@ function CliApp() {
7258
7618
  }, EXIT_HANDOFF_PROMPT_TIMEOUT_MS);
7259
7619
  })) return;
7260
7620
  try {
7261
- if (await generateHandoff(session)) {
7621
+ const result = await generateHandoff(session);
7622
+ if (result) {
7262
7623
  await state.sessionStore.save(session);
7263
- console.log("🤝 Handoff generated.");
7624
+ const label = result.source === "local-fallback" ? "Local handoff written (LLM unavailable)" : "Handoff generated";
7625
+ if (result.filePath) console.log(`🤝 ${label}. File: ${result.filePath}`);
7626
+ else console.log(`🤝 ${label}.`);
7264
7627
  }
7265
7628
  } catch (err) {
7266
7629
  const reason = err instanceof Error ? err.message : String(err);
@@ -7297,19 +7660,39 @@ function CliApp() {
7297
7660
  };
7298
7661
  /**
7299
7662
  * Show the existing handoff or generate a fresh one. Shared by `/handoff` and
7300
- * `/workflow handoff`. Pass `['generate']` (or `['regen']`) to force regeneration.
7663
+ * `/workflow handoff`. Subcommands:
7664
+ * - `generate` / `regen` — force regeneration via the LLM (auto-falls back
7665
+ * to a local handoff if the LLM is unreachable; see issue #8806).
7666
+ * - `--local` flag — skip the LLM entirely and write a local handoff file
7667
+ * from session state. The recovery path for when the user is
7668
+ * rate-limited or offline.
7301
7669
  */
7302
7670
  const runHandoffCommand = async (args) => {
7303
7671
  if (!state.session) {
7304
7672
  console.log("No active session");
7305
7673
  return;
7306
7674
  }
7675
+ const wantsLocal = args.includes("--local");
7676
+ const filteredArgs = args.filter((a) => a !== "--local");
7307
7677
  const existing = state.session.metadata.workflow?.handoff;
7308
- const wantsRegen = args[0] === "generate" || args[0] === "regen";
7678
+ const wantsRegen = filteredArgs[0] === "generate" || filteredArgs[0] === "regen" || wantsLocal;
7309
7679
  if (existing && !wantsRegen) {
7310
7680
  console.log("\n🤝 Session handoff\n");
7311
7681
  console.log(formatHandoffOutput(existing));
7312
- console.log("Run /handoff generate to refresh.\n");
7682
+ console.log("Run /handoff generate to refresh, or /handoff --local for an LLM-free snapshot.\n");
7683
+ return;
7684
+ }
7685
+ if (wantsLocal) {
7686
+ const local = await writeLocalFallbackHandoff(state.session);
7687
+ if (!local) {
7688
+ console.log("❌ Failed to write local handoff");
7689
+ return;
7690
+ }
7691
+ await state.sessionStore.save(state.session);
7692
+ console.log("\n🤝 Local session handoff (no LLM call)\n");
7693
+ console.log(formatHandoffOutput(local.handoff));
7694
+ console.log(`\n📄 Local handoff written to ${local.filePath}`);
7695
+ console.log("✅ Session saved with local handoff");
7313
7696
  return;
7314
7697
  }
7315
7698
  if (state.session.messages.length < 4) {
@@ -7320,15 +7703,17 @@ function CliApp() {
7320
7703
  console.log("Cannot generate handoff: no active agent");
7321
7704
  return;
7322
7705
  }
7323
- const handoff = await generateHandoff(state.session);
7324
- if (!handoff) {
7325
- console.log("❌ Failed to generate handoff");
7706
+ const result = await generateHandoff(state.session);
7707
+ if (!result) {
7708
+ console.log("❌ Failed to generate handoff. Try /handoff --local for an LLM-free snapshot.");
7326
7709
  return;
7327
7710
  }
7328
7711
  await state.sessionStore.save(state.session);
7329
- console.log("\n🤝 Session handoff\n");
7330
- console.log(formatHandoffOutput(handoff));
7331
- console.log("\n✅ Session saved with refreshed handoff");
7712
+ const fellBack = result.source === "local-fallback";
7713
+ console.log(fellBack ? "\n🤝 Local session handoff (LLM unavailable)\n" : "\n🤝 Session handoff\n");
7714
+ console.log(formatHandoffOutput(result.handoff));
7715
+ if (result.filePath) console.log(`\n📄 Handoff written to ${result.filePath}`);
7716
+ console.log(fellBack ? "✅ Session saved with local fallback handoff" : "✅ Session saved with refreshed handoff");
7332
7717
  };
7333
7718
  const handleCommand = async (command, args) => {
7334
7719
  const customCommand = state.customCommandStore.getCommand(command);
@@ -7472,10 +7857,14 @@ Multi-line Input:
7472
7857
  handoff: state.session.metadata.workflow?.handoff,
7473
7858
  reviewGates: reviewGateStoreRef.current.reviewGates
7474
7859
  };
7475
- const handoff = await generateHandoff(state.session);
7860
+ const handoffResult = await generateHandoff(state.session);
7476
7861
  await state.sessionStore.save(state.session);
7477
7862
  console.log(`✅ Session saved as "${sessionName}"`);
7478
- if (handoff) console.log("🤝 Session handoff generated");
7863
+ if (handoffResult) {
7864
+ const label = handoffResult.source === "local-fallback" ? "Local handoff written (LLM unavailable)" : "Session handoff generated";
7865
+ if (handoffResult.filePath) console.log(`🤝 ${label}. File: ${handoffResult.filePath}`);
7866
+ else console.log(`🤝 ${label}`);
7867
+ }
7479
7868
  break;
7480
7869
  }
7481
7870
  case "resume":
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  //#region package.json
3
- var version = "0.14.0";
3
+ var version = "0.15.0";
4
4
  //#endregion
5
5
  export { version as t };