@bike4mind/cli 0.13.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/bin/bike4mind-cli.mjs +13 -0
- package/dist/{ConfigStore-C3tokQej.mjs → ConfigStore-HRgwfPBk.mjs} +199 -11
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +13 -17
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +2 -2
- package/dist/commands/mcpCommand.mjs +1 -1
- package/dist/commands/updateCommand.mjs +120 -5
- package/dist/index.mjs +416 -27
- package/dist/{package-DNcd24qN.mjs → package-CaPvuP1F.mjs} +1 -1
- package/dist/{tools-BhPOnNo3.mjs → tools-ChYlNt33.mjs} +1405 -305
- package/dist/updateChecker-C8xsNY2L.mjs +218 -0
- package/package.json +8 -8
- package/dist/updateChecker-D67NPlS5.mjs +0 -117
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-
|
|
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 {
|
|
5
|
-
import { t as version } from "./package-
|
|
6
|
-
import {
|
|
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
|
+
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
|
|
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
|
-
*
|
|
7187
|
-
* the surrounding /save flow must not block
|
|
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
|
|
7206
|
-
|
|
7207
|
-
blockers: blockerStoreRef.current.blockers,
|
|
7555
|
+
applyHandoffToWorkflow(session, handoff);
|
|
7556
|
+
return {
|
|
7208
7557
|
handoff,
|
|
7209
|
-
|
|
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
|
-
|
|
7621
|
+
const result = await generateHandoff(session);
|
|
7622
|
+
if (result) {
|
|
7262
7623
|
await state.sessionStore.save(session);
|
|
7263
|
-
|
|
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`.
|
|
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 =
|
|
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
|
|
7324
|
-
if (!
|
|
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
|
-
|
|
7330
|
-
console.log(
|
|
7331
|
-
console.log(
|
|
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
|
|
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 (
|
|
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":
|