@bike4mind/cli 0.18.4 → 0.20.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.
Files changed (41) hide show
  1. package/LICENSE +17 -3
  2. package/README.md +204 -35
  3. package/bin/bike4mind-cli.mjs +137 -24
  4. package/bin/hearth-hook.mjs +292 -0
  5. package/dist/AgentHistoryStore-C8uUKjjC.mjs +35512 -0
  6. package/dist/ApiClient-B_CQrUiF.mjs +277 -0
  7. package/dist/{ConfigStore-Cq20962p.mjs → ConfigStore-DD3DcC3-.mjs} +6256 -3911
  8. package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
  9. package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
  10. package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
  11. package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
  12. package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
  13. package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
  14. package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
  15. package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
  16. package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
  17. package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
  18. package/dist/buildAgent-mVuXU_H4.mjs +824 -0
  19. package/dist/commands/acpCommand.mjs +798 -0
  20. package/dist/commands/apiCommand.mjs +14 -16
  21. package/dist/commands/doctorCommand.mjs +5 -5
  22. package/dist/commands/envCommand.mjs +1 -1
  23. package/dist/commands/headlessCommand.mjs +272 -76
  24. package/dist/commands/mcpCommand.mjs +14 -1
  25. package/dist/commands/pluginCommand.mjs +232 -0
  26. package/dist/commands/updateCommand.mjs +10 -9
  27. package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
  28. package/dist/index.mjs +3281 -2322
  29. package/dist/{package-CBaK53NX.mjs → package-BqKSCbso.mjs} +1 -1
  30. package/dist/serve-CuF0I5en.mjs +772 -0
  31. package/dist/store-BG3e54c8.mjs +3 -0
  32. package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
  33. package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
  34. package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
  35. package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
  36. package/package.json +48 -43
  37. package/dist/BackgroundAgentManager-DOesheMD.mjs +0 -27171
  38. package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
  39. package/dist/store-DgzCTRkN.mjs +0 -3
  40. package/dist/utils-Cdktpk_k.mjs +0 -158
  41. package/dist/utils-DEizxshI.mjs +0 -3
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code hook: forwards hook events into the Hearth event log, so any
3
+ // Claude Code instance reports as a Hearth actor.
4
+ //
5
+ // Wire it in .claude/settings.json under hooks (Stop and/or Notification):
6
+ // { "type": "command", "command": "node <path>/hearth-hook.mjs" }
7
+ // Requires: B4M_API_URL, B4M_API_KEY env vars.
8
+ // Optional: B4M_HEARTH_CHANNEL - a channel id. Unset, the hook addresses the
9
+ // shared default channel by NAME and the server find-or-creates it, so a fresh
10
+ // install needs no per-user setup and lands in the same channel the cc-bridge
11
+ // reports into (one roster, not two half-rosters).
12
+ // Always exits 0 - a reporting hook must never block the session.
13
+ //
14
+ // DISCLOSURE. Everything sent here lands in an append-only log that other
15
+ // actors read and that gateways may one day mirror to external parties, so the
16
+ // forwarded field set is an explicit tier rather than "whatever the hook
17
+ // happened to receive". B4M_HEARTH_DISCLOSURE selects it:
18
+ // 0 - event name, session id, session slug. Zero environment disclosure.
19
+ // 1 - adds the workspace BASENAME (the repo name, never a full path), except
20
+ // when that basename would BE the OS username - the home directory is
21
+ // omitted rather than sent, since `/Users/<user>` basenames to <user>.
22
+ // 2 - adds non-sensitive activity state: a reason code checked against the
23
+ // known set, a tool name that is either a plain identifier or the bare
24
+ // kind `mcp` (an mcp__<server>__<tool> name would otherwise disclose the
25
+ // configured integration), permission mode, effort level, tool duration,
26
+ // subagent type, and a background-task count. Default.
27
+ // Values are VALIDATED, not merely selected: a field documented as a closed set
28
+ // but forwarded unchecked is only a closed set until upstream adds a value.
29
+ // No tier forwards a field the hook docs mark as content-bearing: prompt,
30
+ // tool_input, tool_response, last_assistant_message, compact_summary,
31
+ // custom_instructions, transcript_path, the full cwd, or the raw notification
32
+ // message. The human-readable line is COMPOSED here from those safe parts
33
+ // rather than passing an upstream string through, because Notification.message
34
+ // may itself contain a path or a code snippet.
35
+ // __tests__/hearthHook.test.ts pins the exact field set of every tier, so a
36
+ // newly added field cannot silently escape its tier.
37
+ // Shipped standalone and run under bare `node`, so it stays dependency-free.
38
+
39
+ import { basename, resolve } from 'node:path';
40
+ import { homedir } from 'node:os';
41
+
42
+ const { B4M_API_URL, B4M_API_KEY, B4M_HEARTH_CHANNEL, B4M_HEARTH_DISCLOSURE } = process.env;
43
+
44
+ const DEFAULT_DISCLOSURE = 2;
45
+ const MAX_DISCLOSURE = 2;
46
+ /**
47
+ * Where an UNPARSEABLE value lands. Deliberately the minimum, not the default:
48
+ * `none`, `off`, `min`, `zero`, a quoted `"0"`, or an unexpanded `$LEVEL` all
49
+ * parse to NaN, and an operator who typed any of those was reaching for LESS
50
+ * disclosure, not more. Resolving NaN to the default (which is also the
51
+ * maximum) meant every one of those silently produced the widest tier, and
52
+ * because unset and set-but-garbage were indistinguishable the misconfiguration
53
+ * was undetectable from the outside. A privacy control has to fail closed.
54
+ */
55
+ const MALFORMED_DISCLOSURE = 0;
56
+
57
+ /** Must match DEFAULT_HEARTH_CHANNEL_NAME in b4m-core/hearth; the hook is
58
+ * dependency-free and cannot import it. */
59
+ const DEFAULT_CHANNEL_NAME = 'agents';
60
+
61
+ /** Must match PRESENCE_PAYLOAD_SCHEMA_NAME and PRESENCE_SURFACES in
62
+ * b4m-core/hearth/src/presence.ts, which presence.test.ts pins. */
63
+ const PAYLOAD_SCHEMA = 'hearth.presence@1';
64
+ const SURFACE = 'claude-code-hook';
65
+
66
+ /**
67
+ * UNSET means the default tier. SET-BUT-UNPARSEABLE means the minimum, because
68
+ * a garbage value is a misconfiguration and the safe reading of a broken
69
+ * privacy setting is the narrow one. Out-of-range values clamp into range.
70
+ */
71
+ function disclosureTier() {
72
+ if (B4M_HEARTH_DISCLOSURE === undefined || B4M_HEARTH_DISCLOSURE === '') return DEFAULT_DISCLOSURE;
73
+ const parsed = Number.parseInt(B4M_HEARTH_DISCLOSURE, 10);
74
+ if (Number.isNaN(parsed)) return MALFORMED_DISCLOSURE;
75
+ return Math.max(0, Math.min(MAX_DISCLOSURE, parsed));
76
+ }
77
+
78
+ // 32x32 = 1024 pairs. Collisions are cosmetic (the exact session_id still
79
+ // travels in the payload); the point is that a human can tell two live sessions
80
+ // apart at a glance, which a uuid prefix does not achieve.
81
+ const ADJECTIVES = [
82
+ 'amber', 'brisk', 'calm', 'clever', 'copper', 'crimson', 'dapper', 'eager',
83
+ 'fluent', 'gentle', 'golden', 'hardy', 'humble', 'ivory', 'jolly', 'keen',
84
+ 'lucid', 'merry', 'nimble', 'noble', 'olive', 'patient', 'quiet', 'rapid',
85
+ 'rustic', 'silver', 'solemn', 'sunny', 'teal', 'tidy', 'vivid', 'wry',
86
+ ];
87
+ const ANIMALS = [
88
+ 'otter', 'heron', 'lynx', 'marten', 'badger', 'falcon', 'ibex', 'jackal',
89
+ 'kestrel', 'lemur', 'magpie', 'newt', 'osprey', 'puffin', 'quail', 'raven',
90
+ 'shrike', 'tapir', 'urchin', 'viper', 'walrus', 'yak', 'zebra', 'bison',
91
+ 'crane', 'dingo', 'egret', 'ferret', 'gecko', 'hare', 'impala', 'jay',
92
+ ];
93
+
94
+ /** djb2. Deterministic across processes and restarts, which is the whole point. */
95
+ function hashOf(value) {
96
+ let hash = 5381;
97
+ for (let i = 0; i < value.length; i++) {
98
+ hash = ((hash << 5) + hash + value.charCodeAt(i)) | 0;
99
+ }
100
+ return Math.abs(hash);
101
+ }
102
+
103
+ /**
104
+ * A readable, stable name for a session, derived purely from an id the hook
105
+ * already sends - so it costs no additional disclosure.
106
+ */
107
+ function sessionSlug(sessionId) {
108
+ if (!sessionId) return 'unknown-session';
109
+ const hash = hashOf(sessionId);
110
+ const animalIndex = Math.floor(hash / ADJECTIVES.length) % ANIMALS.length;
111
+ return `${ADJECTIVES[hash % ADJECTIVES.length]}-${ANIMALS[animalIndex]}`;
112
+ }
113
+
114
+ /** Reason codes for events that are not Notifications. Closed set. */
115
+ function reasonForEvent(eventName) {
116
+ switch (eventName) {
117
+ case 'Stop':
118
+ case 'SubagentStop':
119
+ return 'turn_finished';
120
+ case 'PreToolUse':
121
+ case 'PostToolUse':
122
+ return 'tool_use';
123
+ case 'SessionStart':
124
+ return 'session_start';
125
+ case 'SessionEnd':
126
+ return 'session_end';
127
+ case 'UserPromptSubmit':
128
+ return 'prompt_submitted';
129
+ default:
130
+ return 'active';
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Non-sensitive activity state. Every field here is a closed-set string or a
136
+ * number: notification_type is the documented machine-readable classifier (so
137
+ * the raw message never needs to be forwarded OR parsed), and permission_mode,
138
+ * effort level, and duration are the concrete "capability + cost + latency"
139
+ * a presence roster wants. background_tasks is reduced to a COUNT because the
140
+ * task descriptions and commands inside it are content-bearing.
141
+ */
142
+ function activityOf(hook) {
143
+ // notification_type is forwarded ONLY if it is a reason we actually know.
144
+ // REASON_PHRASES already enumerates that vocabulary, so an unknown value falls
145
+ // back to the event-derived code instead of passing an upstream string through.
146
+ const notified =
147
+ typeof hook.notification_type === 'string' && Object.hasOwn(REASON_PHRASES, hook.notification_type)
148
+ ? hook.notification_type
149
+ : undefined;
150
+ const activity = { reason: notified ?? reasonForEvent(hook.hook_event_name) };
151
+ const tool = toolOf(hook.tool_name);
152
+ if (tool) activity.tool = tool;
153
+ if (typeof hook.permission_mode === 'string' && hook.permission_mode) {
154
+ activity.permission_mode = hook.permission_mode;
155
+ }
156
+ if (typeof hook.effort?.level === 'string' && hook.effort.level) activity.effort = hook.effort.level;
157
+ if (typeof hook.duration_ms === 'number') activity.duration_ms = hook.duration_ms;
158
+ if (typeof hook.agent_type === 'string' && hook.agent_type) activity.subagent = hook.agent_type;
159
+ if (Array.isArray(hook.background_tasks)) activity.background_tasks = hook.background_tasks.length;
160
+ return activity;
161
+ }
162
+
163
+ /**
164
+ * Workspace name for tier >= 1: the basename of the session's directory, or
165
+ * undefined when there is no name safe to send.
166
+ *
167
+ * The HOME directory is excluded because its basename IS the OS username on
168
+ * macOS and Linux (`/Users/<user>`, `/home/<user>`) - and starting a session in
169
+ * the home directory is ordinary, so this fired on real sessions at the default
170
+ * tier while the file header promised "the repo name, never a full path". A
171
+ * filesystem root has no useful name either.
172
+ *
173
+ * What this does NOT try to solve: any basename is still a directory name the
174
+ * user chose, so a directory named after a client or a project discloses that
175
+ * name. That is the acknowledged cost of tier 1 and the reason tier 0 exists.
176
+ */
177
+ function workspaceOf(cwd) {
178
+ if (typeof cwd !== 'string' || !cwd) return undefined;
179
+ const absolute = resolve(cwd);
180
+ let home;
181
+ try {
182
+ home = homedir();
183
+ } catch {
184
+ home = undefined;
185
+ }
186
+ if (home && absolute === resolve(home)) return undefined;
187
+ const name = basename(absolute);
188
+ // basename('/') is '' and basename('/root') is a system account, not a workspace.
189
+ if (!name || name === 'root') return undefined;
190
+ return name;
191
+ }
192
+
193
+ /**
194
+ * Tool names that may be forwarded verbatim vs. reduced.
195
+ *
196
+ * An MCP tool is named `mcp__<server>__<tool>`, so forwarding it verbatim
197
+ * disclosed every configured MCP SERVER name at the default tier - the field was
198
+ * documented as a closed set but was `hook.tool_name` unchecked. Reducing it to
199
+ * a bare `mcp` keeps the signal a roster actually wants ("it is calling out to a
200
+ * tool") without naming the integration. Anything else unrecognized is dropped
201
+ * rather than guessed at, since a built-in tool name is a short identifier and a
202
+ * value that is not one did not come from the closed set.
203
+ */
204
+ const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
205
+
206
+ function toolOf(toolName) {
207
+ if (typeof toolName !== 'string' || !toolName) return undefined;
208
+ if (toolName.startsWith('mcp__')) return 'mcp';
209
+ return TOOL_NAME_PATTERN.test(toolName) ? toolName : undefined;
210
+ }
211
+
212
+ /** Phrases for the closed reason set. Anything unrecognized degrades to a
213
+ * generic line rather than echoing an upstream string. */
214
+ const REASON_PHRASES = {
215
+ permission_prompt: 'needs permission',
216
+ idle_prompt: 'is waiting for input',
217
+ agent_needs_input: 'is waiting for input',
218
+ agent_completed: 'finished',
219
+ auth_success: 'authenticated',
220
+ elicitation_dialog: 'is asking a question',
221
+ elicitation_complete: 'got its answer',
222
+ elicitation_response: 'got its answer',
223
+ turn_finished: 'finished a turn',
224
+ tool_use: 'is running a tool',
225
+ session_start: 'started a session',
226
+ session_end: 'ended a session',
227
+ prompt_submitted: 'received a prompt',
228
+ active: 'is active',
229
+ };
230
+
231
+ /** Human-readable one-liner composed from known-safe parts only. */
232
+ function describe({ label, tier, workspace, activity }) {
233
+ const where = tier >= 1 && workspace ? ` in ${workspace}` : '';
234
+ if (tier < 2) return `${label}${where} reported in`;
235
+ const phrase = REASON_PHRASES[activity.reason] ?? 'is active';
236
+ const tool = activity.reason === 'permission_prompt' && activity.tool ? `: ${activity.tool}` : '';
237
+ return `${label}${where} ${phrase}${tool}`;
238
+ }
239
+
240
+ const chunks = [];
241
+ process.stdin.on('data', c => chunks.push(c));
242
+ process.stdin.on('end', async () => {
243
+ try {
244
+ if (!B4M_API_URL || !B4M_API_KEY) return;
245
+ const hook = JSON.parse(Buffer.concat(chunks).toString() || '{}');
246
+
247
+ const tier = disclosureTier();
248
+ const eventName = hook.hook_event_name ?? 'unknown';
249
+ const sessionId = hook.session_id ?? null;
250
+ const slug = sessionSlug(sessionId);
251
+ // One actor per session. That is what makes presence rows distinguishable,
252
+ // gives each reader an independent cursor, and yields a stable per-session
253
+ // color downstream - all three followed from the single collapsed actor.
254
+ //
255
+ // The slug ALONE, matching sessionActorName in b4m-core/hearth/src/identity.ts:
256
+ // the cc-bridge covers these same sessions and ensureActor upserts on
257
+ // displayName, so any disagreement splits one session into two actors. See
258
+ // that function for why neither the workspace nor a client name belongs here.
259
+ //
260
+ // No operator override: a configurable displayName is exactly what splits
261
+ // one session into two actors, so identity is derived and not settable.
262
+ const label = slug;
263
+
264
+ const payload = { hook_event_name: eventName, session_id: sessionId, slug, surface: SURFACE };
265
+ const workspace = workspaceOf(hook.cwd);
266
+ if (tier >= 1 && workspace) payload.workspace = workspace;
267
+ const activity = activityOf(hook);
268
+ if (tier >= 2) payload.activity = activity;
269
+
270
+ await fetch(new URL('/api/hearth/events', B4M_API_URL), {
271
+ method: 'POST',
272
+ headers: { 'content-type': 'application/json', 'x-api-key': B4M_API_KEY },
273
+ body: JSON.stringify({
274
+ ...(B4M_HEARTH_CHANNEL ? { channelId: B4M_HEARTH_CHANNEL } : { channelName: DEFAULT_CHANNEL_NAME }),
275
+ kind: 'presence',
276
+ human: { text: describe({ label, tier, workspace, activity }), format: 'text' },
277
+ machine: { schema: PAYLOAD_SCHEMA, payload },
278
+ refs: {},
279
+ // Self-identify as an agent actor. Without this the hook resolved to the
280
+ // account's HUMAN actor, so every heartbeat rendered as if the person
281
+ // had posted it - the log could not distinguish operator from tooling.
282
+ actor: { kind: 'agent', displayName: label },
283
+ }),
284
+ // Bounded so a hung request can never stall the session past 3s.
285
+ signal: AbortSignal.timeout(3000),
286
+ });
287
+ } catch {
288
+ // Swallow everything: reporting must never fail the hook.
289
+ } finally {
290
+ process.exit(0);
291
+ }
292
+ });