@clearlysh/beehaven 0.8.16
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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- package/skills/pair-on-canvas/SKILL.md +92 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Daemon Types
|
|
3
|
+
*
|
|
4
|
+
* Types for the 24/7 Claude daemon service, token management,
|
|
5
|
+
* and WebSocket gateway communication.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_DAEMON_CONFIG = {
|
|
8
|
+
autoStart: true,
|
|
9
|
+
wsPort: 18790,
|
|
10
|
+
maxIdleTime: 0, // Never auto-sleep
|
|
11
|
+
persistContext: true,
|
|
12
|
+
maxSessionHistory: 100,
|
|
13
|
+
defaultModel: undefined, // Use Claude's default
|
|
14
|
+
};
|
|
15
|
+
// ============================================
|
|
16
|
+
// IPC CHANNELS
|
|
17
|
+
// ============================================
|
|
18
|
+
/** IPC channel names for daemon communication */
|
|
19
|
+
export const DAEMON_IPC_CHANNELS = {
|
|
20
|
+
// Token management
|
|
21
|
+
SETUP_TOKEN: 'daemon:setup-token',
|
|
22
|
+
VALIDATE_TOKEN: 'daemon:validate-token',
|
|
23
|
+
TOKEN_STATUS: 'daemon:token-status',
|
|
24
|
+
CLEAR_TOKEN: 'daemon:clear-token',
|
|
25
|
+
// Daemon lifecycle
|
|
26
|
+
START: 'daemon:start',
|
|
27
|
+
STOP: 'daemon:stop',
|
|
28
|
+
RESTART: 'daemon:restart',
|
|
29
|
+
STATUS: 'daemon:status',
|
|
30
|
+
// WebSocket info
|
|
31
|
+
WS_URL: 'daemon:ws-url',
|
|
32
|
+
LOCAL_AUTH: 'daemon:local-auth',
|
|
33
|
+
// Events (main -> renderer)
|
|
34
|
+
STATUS_CHANGED: 'daemon:status-changed',
|
|
35
|
+
TOKEN_EXPIRING: 'daemon:token-expiring',
|
|
36
|
+
ERROR: 'daemon:error',
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Default values for SMS commands
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_SMS_COMMAND = {
|
|
42
|
+
status: 'pending',
|
|
43
|
+
retryCount: 0,
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Parse SMS command intent using simple heuristics
|
|
47
|
+
* (Used as fallback if Gemini parsing fails)
|
|
48
|
+
*/
|
|
49
|
+
export function parseIntentHeuristic(text) {
|
|
50
|
+
const lowerText = text.toLowerCase();
|
|
51
|
+
// Video creation patterns
|
|
52
|
+
if (lowerText.includes('tiktok') ||
|
|
53
|
+
lowerText.includes('video') ||
|
|
54
|
+
lowerText.includes('reel') ||
|
|
55
|
+
lowerText.includes('short')) {
|
|
56
|
+
const platformMatch = lowerText.match(/(tiktok|youtube|instagram|reel)/);
|
|
57
|
+
return {
|
|
58
|
+
type: 'video-create',
|
|
59
|
+
confidence: 0.7,
|
|
60
|
+
entities: {
|
|
61
|
+
platform: platformMatch?.[1] || 'tiktok',
|
|
62
|
+
topic: text.replace(/make|create|edit|me|a|about/gi, '').trim(),
|
|
63
|
+
},
|
|
64
|
+
rawText: text,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Code generation patterns
|
|
68
|
+
if (lowerText.includes('create') ||
|
|
69
|
+
lowerText.includes('build') ||
|
|
70
|
+
lowerText.includes('website') ||
|
|
71
|
+
lowerText.includes('app') ||
|
|
72
|
+
lowerText.includes('code')) {
|
|
73
|
+
return {
|
|
74
|
+
type: 'code-generate',
|
|
75
|
+
confidence: 0.6,
|
|
76
|
+
entities: {
|
|
77
|
+
topic: text,
|
|
78
|
+
},
|
|
79
|
+
rawText: text,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Research patterns
|
|
83
|
+
if (lowerText.includes('research') ||
|
|
84
|
+
lowerText.includes('find') ||
|
|
85
|
+
lowerText.includes('search') ||
|
|
86
|
+
lowerText.includes('look up')) {
|
|
87
|
+
return {
|
|
88
|
+
type: 'research',
|
|
89
|
+
confidence: 0.6,
|
|
90
|
+
entities: {
|
|
91
|
+
topic: text,
|
|
92
|
+
},
|
|
93
|
+
rawText: text,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Query patterns
|
|
97
|
+
if (lowerText.startsWith('what') || lowerText.startsWith('how') || lowerText.startsWith('why')) {
|
|
98
|
+
return {
|
|
99
|
+
type: 'query',
|
|
100
|
+
confidence: 0.5,
|
|
101
|
+
entities: {},
|
|
102
|
+
rawText: text,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Default to conversation
|
|
106
|
+
return {
|
|
107
|
+
type: 'conversation',
|
|
108
|
+
confidence: 0.3,
|
|
109
|
+
entities: {},
|
|
110
|
+
rawText: text,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status.json — shared schema between the daemon (writer) and the
|
|
3
|
+
* Clearly Dev Mac app tray (reader).
|
|
4
|
+
*
|
|
5
|
+
* The daemon writes `~/.beehaven/status.json` every 2 seconds with a
|
|
6
|
+
* snapshot of relay connectivity, pending RPCs, reconnect attempts,
|
|
7
|
+
* and version info. The tray polls this file on every menu rebuild to
|
|
8
|
+
* render live status (relay OK, daemon running PID N, etc).
|
|
9
|
+
*
|
|
10
|
+
* Without a shared schema, the two sides could drift — the daemon
|
|
11
|
+
* starts emitting a new field, the tray silently ignores it; the
|
|
12
|
+
* daemon renames a field, the tray reads `undefined` and shows wrong
|
|
13
|
+
* status. This file is the contract.
|
|
14
|
+
*
|
|
15
|
+
* Design choices:
|
|
16
|
+
* - No Zod / io-ts / runtime-type library — CLI keeps a near-zero
|
|
17
|
+
* runtime dependency footprint. A hand-written validator is ~80
|
|
18
|
+
* lines, as correct as any library for this shape.
|
|
19
|
+
* - Explicit schemaVersion so the tray can reject future snapshots
|
|
20
|
+
* that use an incompatible version — better than silently showing
|
|
21
|
+
* wrong data.
|
|
22
|
+
* - Bidirectional: the daemon calls `validateStatusSnapshot()` before
|
|
23
|
+
* writing (catches local drift); the tray calls
|
|
24
|
+
* `readAndValidateStatusFile()` after reading (catches remote drift).
|
|
25
|
+
*/
|
|
26
|
+
/** Current schema version. Bump when a field changes semantics. */
|
|
27
|
+
export const STATUS_SCHEMA_VERSION = 1;
|
|
28
|
+
/**
|
|
29
|
+
* Validate an unknown value against the StatusSnapshot schema.
|
|
30
|
+
*
|
|
31
|
+
* Returns `{ ok: true, value }` with a typed snapshot on success, or
|
|
32
|
+
* `{ ok: false, errors: string[] }` with one message per failing field.
|
|
33
|
+
* Never throws. Rejects extra fields silently (tolerates daemons that
|
|
34
|
+
* emit extra fields newer than the tray knows about).
|
|
35
|
+
*/
|
|
36
|
+
export function validateStatusSnapshot(v) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
|
39
|
+
return { ok: false, errors: ['not an object'] };
|
|
40
|
+
}
|
|
41
|
+
const o = v;
|
|
42
|
+
if (o.schemaVersion !== STATUS_SCHEMA_VERSION) {
|
|
43
|
+
errors.push(`schemaVersion: expected ${STATUS_SCHEMA_VERSION}, got ${JSON.stringify(o.schemaVersion)}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof o.pid !== 'number' || !Number.isFinite(o.pid) || o.pid <= 0) {
|
|
46
|
+
errors.push(`pid: expected positive number, got ${JSON.stringify(o.pid)}`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof o.updatedAt !== 'number' || !Number.isFinite(o.updatedAt) || o.updatedAt <= 0) {
|
|
49
|
+
errors.push(`updatedAt: expected positive number, got ${JSON.stringify(o.updatedAt)}`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof o.relayConnected !== 'boolean') {
|
|
52
|
+
errors.push(`relayConnected: expected boolean, got ${typeof o.relayConnected}`);
|
|
53
|
+
}
|
|
54
|
+
if (typeof o.relayConfigured !== 'boolean') {
|
|
55
|
+
errors.push(`relayConfigured: expected boolean, got ${typeof o.relayConfigured}`);
|
|
56
|
+
}
|
|
57
|
+
if (o.relayEndpoint !== null && typeof o.relayEndpoint !== 'string') {
|
|
58
|
+
errors.push(`relayEndpoint: expected string or null, got ${typeof o.relayEndpoint}`);
|
|
59
|
+
}
|
|
60
|
+
if (typeof o.pendingRequests !== 'number' ||
|
|
61
|
+
!Number.isFinite(o.pendingRequests) ||
|
|
62
|
+
o.pendingRequests < 0) {
|
|
63
|
+
errors.push(`pendingRequests: expected non-negative number, got ${JSON.stringify(o.pendingRequests)}`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof o.reconnectAttempts !== 'number' ||
|
|
66
|
+
!Number.isFinite(o.reconnectAttempts) ||
|
|
67
|
+
o.reconnectAttempts < 0) {
|
|
68
|
+
errors.push(`reconnectAttempts: expected non-negative number, got ${JSON.stringify(o.reconnectAttempts)}`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof o.version !== 'string' || o.version.length === 0) {
|
|
71
|
+
errors.push(`version: expected non-empty string, got ${JSON.stringify(o.version)}`);
|
|
72
|
+
}
|
|
73
|
+
if (errors.length > 0)
|
|
74
|
+
return { ok: false, errors };
|
|
75
|
+
return { ok: true, value: o };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read + validate the status file. Returns the parsed snapshot on
|
|
79
|
+
* success, or a structured error discriminator. Handles:
|
|
80
|
+
* - missing file → `{ ok: false, reason: 'not-found' }`
|
|
81
|
+
* - unreadable / permissions → `{ ok: false, reason: 'read-failed', detail }`
|
|
82
|
+
* - invalid JSON → `{ ok: false, reason: 'malformed', detail }`
|
|
83
|
+
* - schema-version mismatch → `{ ok: false, reason: 'version-mismatch', expected, got }`
|
|
84
|
+
* - shape validation failure → `{ ok: false, reason: 'schema', errors }`
|
|
85
|
+
* - stale (older than maxAgeMs) → `{ ok: false, reason: 'stale', ageMs }`
|
|
86
|
+
*
|
|
87
|
+
* Reader-side helper. Use from the Mac app tray instead of raw
|
|
88
|
+
* `JSON.parse(readFileSync(path))` to get typed errors.
|
|
89
|
+
*/
|
|
90
|
+
export async function readAndValidateStatusFile(path, opts = {}) {
|
|
91
|
+
const { readFile } = await import('node:fs/promises');
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = await readFile(path, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err?.code === 'ENOENT')
|
|
98
|
+
return { ok: false, reason: 'not-found' };
|
|
99
|
+
return { ok: false, reason: 'read-failed', detail: err?.message ?? String(err) };
|
|
100
|
+
}
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return { ok: false, reason: 'malformed', detail: err?.message ?? 'JSON parse failed' };
|
|
107
|
+
}
|
|
108
|
+
// Fast version check — if the version doesn't match, don't bother
|
|
109
|
+
// walking fields. The tray can surface "daemon is newer/older, restart"
|
|
110
|
+
// without a confusing schema-error cascade.
|
|
111
|
+
if (parsed?.schemaVersion !== STATUS_SCHEMA_VERSION) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: 'version-mismatch',
|
|
115
|
+
expected: STATUS_SCHEMA_VERSION,
|
|
116
|
+
got: parsed?.schemaVersion,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const result = validateStatusSnapshot(parsed);
|
|
120
|
+
if (!result.ok)
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: 'schema',
|
|
124
|
+
errors: result.errors,
|
|
125
|
+
};
|
|
126
|
+
if (opts.maxAgeMs !== undefined) {
|
|
127
|
+
const age = Date.now() - result.value.updatedAt;
|
|
128
|
+
if (age > opts.maxAgeMs) {
|
|
129
|
+
return { ok: false, reason: 'stale', ageMs: age };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, value: result.value };
|
|
133
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell-session — the remote shell's working directory, owned by the CLIENT, one per LOGIN.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS EXISTS ────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* The workspace shell kept its cwd server-side in a `WeakMap` keyed on
|
|
6
|
+
* `input.sessionId || this._activeSessionId || 'default'`. The CLI never sent a `sessionId`, and
|
|
7
|
+
* `_activeSessionId` exists only on the Shopify store DO — so on a workspace DO the key was the
|
|
8
|
+
* literal string `'default'` for every caller alive. MEASURED on staging 2026-09-05:
|
|
9
|
+
*
|
|
10
|
+
* jinri-claude bash 'cd Q1 && pwd' -> cwd '~/Q1'
|
|
11
|
+
* northwind-scribe bash 'pwd' -> cwd '~/Q1' <- another agent's directory
|
|
12
|
+
*
|
|
13
|
+
* A relative path therefore resolved against whatever a sibling agent last `cd`'d to, and both
|
|
14
|
+
* sides reported success. (CBH-39.)
|
|
15
|
+
*
|
|
16
|
+
* ── WHY THE CLIENT OWNS IT RATHER THAN THE SERVER ──────────────────────────────────────────
|
|
17
|
+
* Sending a `sessionId` would have been a one-line fix and leaves three properties broken: the map
|
|
18
|
+
* is per-DO-instance RAM, so an eviction silently resets the directory mid-task; nothing can
|
|
19
|
+
* INSPECT it without executing a command; and the map grows per session with no eviction policy.
|
|
20
|
+
* Holding the cwd in a file and sending it makes the transport stateless, survives both DO
|
|
21
|
+
* eviction and daemon restarts, and lets `pwd` answer with no round trip — the same three
|
|
22
|
+
* arguments `cli-state.ts` already makes for keeping the target cursor in a file.
|
|
23
|
+
*
|
|
24
|
+
* ── ⚠⚠ THE KEY IS THE LOGIN, NOT THE IDENTITY — AND THAT DIFFERS FROM `target.json` ─────────
|
|
25
|
+
* `cli-state.effectiveScope()` resolves the process scope THROUGH `agentForScope()` to the bound
|
|
26
|
+
* agent NAME, deliberately: *"A LOGGED-IN AGENT'S CURSOR FOLLOWS THE AGENT, NOT THE PROCESS …
|
|
27
|
+
* a durable agent would start at home each time and lose the workspace it had chosen."* That is
|
|
28
|
+
* right for a cursor and wrong for a cwd, and the two are not the same question:
|
|
29
|
+
*
|
|
30
|
+
* WHICH WORKSPACE am I in -> a standing choice -> follows the identity (target.json)
|
|
31
|
+
* WHERE am I inside it -> this shell, right now -> follows the LOGIN (this file)
|
|
32
|
+
*
|
|
33
|
+
* Two Claude sessions in two terminals, both logged in as `jinri-claude`, are two shells. They
|
|
34
|
+
* share a workspace and must not share a directory — which is exactly how real terminals behave,
|
|
35
|
+
* and why resetting to `~` on a new session is correct here rather than a regression.
|
|
36
|
+
*
|
|
37
|
+
* So this uses the RAW `deriveAgentScope()` (`claude-<sessionId>`, `codex-…`, `pid-<ppid>`) and
|
|
38
|
+
* must NOT be "fixed" to use `effectiveScope()`. A guard pins that.
|
|
39
|
+
*
|
|
40
|
+
* ⚠ AN EXPLICIT `BEEHAVEN_AGENT=<name>` IN TWO TERMINALS STILL SHARES ONE FILE, because that is
|
|
41
|
+
* the one handle a person can set identically on purpose. Naming the same scope twice is a
|
|
42
|
+
* statement that they are the same shell; the automatic handles are all per-session.
|
|
43
|
+
*
|
|
44
|
+
* ⚠ A HUMAN (no scope) KEEPS THE UNSCOPED FILE, mirroring `target.json` exactly — nothing about an
|
|
45
|
+
* existing interactive shell moves.
|
|
46
|
+
*/
|
|
47
|
+
import { deriveAgentScope } from './agent-scope.js';
|
|
48
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
49
|
+
import { join } from 'node:path';
|
|
50
|
+
import { homedir } from 'node:os';
|
|
51
|
+
const STATE_DIR = join(homedir(), '.beehaven');
|
|
52
|
+
/**
|
|
53
|
+
* The per-login key.
|
|
54
|
+
*
|
|
55
|
+
* ⚠ SANITISED, because it lands in a filesystem path — `BEEHAVEN_AGENT=../../..` would otherwise
|
|
56
|
+
* escape STATE_DIR. Same rule as `cli-state.targetFileFor`; a name that sanitises to nothing falls
|
|
57
|
+
* back to the shared file rather than writing `cwd-.json`.
|
|
58
|
+
*/
|
|
59
|
+
export function shellSessionKey(env = process.env) {
|
|
60
|
+
return (deriveAgentScope(env) || '').trim().replace(/[^A-Za-z0-9_-]/g, '');
|
|
61
|
+
}
|
|
62
|
+
function cwdFileFor(key = shellSessionKey()) {
|
|
63
|
+
return join(STATE_DIR, key ? `cwd-${key}.json` : 'cwd.json');
|
|
64
|
+
}
|
|
65
|
+
/** Exported for the scoping guard — the path rule takes an env var into a path, so it is asserted. */
|
|
66
|
+
export const __cwdFileFor = cwdFileFor;
|
|
67
|
+
/**
|
|
68
|
+
* ⚠⚠ ONE FUNCTION DECIDES THE TARGET KEY, BECAUSE TWO DOORS REACH THE SHELL AND THEY SPELL IT
|
|
69
|
+
* DIFFERENTLY.
|
|
70
|
+
*
|
|
71
|
+
* `beehaven ls ~` arrives through `execNotesShell` with `buildingType === 'home'`. `beehaven call
|
|
72
|
+
* bash …` arrives through `cmdCall`, where the address comes from the target cursor — and `cd home`
|
|
73
|
+
* PERSISTS the canonical `workspace`, not `home` (`CANONICAL_HOME_ADDRESS`). Left to compute their
|
|
74
|
+
* own keys the two doors would file one shell's cwd under `home` and the other's under `workspace`,
|
|
75
|
+
* so `cd` through one verb would be invisible to the next — the same shell, two directories,
|
|
76
|
+
* nothing reporting a problem.
|
|
77
|
+
*
|
|
78
|
+
* Every home spelling collapses to `home`; anything else is used as given.
|
|
79
|
+
*/
|
|
80
|
+
export function shellTargetKey(address) {
|
|
81
|
+
const a = String(address ?? '').trim();
|
|
82
|
+
if (!a || a === 'home' || a === 'me' || a === 'workspace')
|
|
83
|
+
return 'home';
|
|
84
|
+
// `--building type:id` and the address grammar's `type/id` name the same DO — normalise the
|
|
85
|
+
// separator, or the same workspace gets two entries depending on which flag was used.
|
|
86
|
+
return a.replace(':', '/');
|
|
87
|
+
}
|
|
88
|
+
function readMap(key = shellSessionKey()) {
|
|
89
|
+
try {
|
|
90
|
+
const f = cwdFileFor(key);
|
|
91
|
+
if (!existsSync(f))
|
|
92
|
+
return {};
|
|
93
|
+
const parsed = JSON.parse(readFileSync(f, 'utf-8'));
|
|
94
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// ⚠ NEVER THROWS. This runs on every shell call; a corrupt file must cost the cwd, not the command.
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The stored cwd for a target, or null when this login has never moved there. */
|
|
102
|
+
export function readShellCwd(target, key = shellSessionKey()) {
|
|
103
|
+
const v = readMap(key)[target];
|
|
104
|
+
return typeof v === 'string' && v ? v : null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Persist the cwd the server reported.
|
|
108
|
+
*
|
|
109
|
+
* ⚠ WRITTEN FROM THE RESPONSE, NEVER PREDICTED FROM THE COMMAND. Guessing that `cd X` lands in `X`
|
|
110
|
+
* means re-implementing the shell's own resolution in the client, where it would drift; the handler
|
|
111
|
+
* already returns the directory it actually ran in, so echoing that back is the only value that
|
|
112
|
+
* cannot disagree with the server.
|
|
113
|
+
*
|
|
114
|
+
* ⚠ `~` IS NOT STORED. It is the default, so writing it just grows the file with entries that mean
|
|
115
|
+
* "nothing" — and an absent entry and an entry saying `~` must not be two ways to say one thing.
|
|
116
|
+
*/
|
|
117
|
+
export function writeShellCwd(target, cwd, key = shellSessionKey()) {
|
|
118
|
+
try {
|
|
119
|
+
const clean = String(cwd ?? '').trim();
|
|
120
|
+
const map = readMap(key);
|
|
121
|
+
if (!clean || clean === '~' || clean === '/home') {
|
|
122
|
+
if (!(target in map))
|
|
123
|
+
return;
|
|
124
|
+
delete map[target];
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
if (map[target] === clean)
|
|
128
|
+
return; // no write when nothing moved
|
|
129
|
+
map[target] = clean;
|
|
130
|
+
}
|
|
131
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
132
|
+
const f = cwdFileFor(key);
|
|
133
|
+
const tmp = `${f}.tmp`;
|
|
134
|
+
writeFileSync(tmp, JSON.stringify(map, null, 2), { mode: 0o600 });
|
|
135
|
+
renameSync(tmp, f); // atomic — two shells may write at once
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* a cwd we could not persist is a cwd that resets; never fail the command over it */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The actions whose payload carries a shell working directory.
|
|
143
|
+
*
|
|
144
|
+
* ⚠ AN ALLOWLIST, NOT A GUESS. Attaching `cwd` to every action would put an unexpected key in
|
|
145
|
+
* hundreds of payloads; `z.object()` strips unknown keys, so most would ignore it silently and any
|
|
146
|
+
* that happened to declare a `cwd` would be handed one that means something else entirely.
|
|
147
|
+
*/
|
|
148
|
+
const SHELL_ACTIONS = new Set(['bash', 'notes-bash']);
|
|
149
|
+
export function isShellAction(action) {
|
|
150
|
+
return SHELL_ACTIONS.has(String(action || '').trim());
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Attach this login's cwd to a shell payload.
|
|
154
|
+
*
|
|
155
|
+
* ⚠ AN EXPLICIT `cwd` IN THE CALLER'S OWN INPUT WINS AND IS LEFT ALONE — `beehaven call bash
|
|
156
|
+
* '{"command":"ls","cwd":"/local/src"}'` is how the LOCAL machine is addressed, and overwriting it
|
|
157
|
+
* would silently redirect a command from the user's laptop to the workspace.
|
|
158
|
+
*/
|
|
159
|
+
export function attachShellCwd(action, input, target) {
|
|
160
|
+
if (!isShellAction(action))
|
|
161
|
+
return input;
|
|
162
|
+
if (typeof input?.cwd === 'string' && input.cwd.trim())
|
|
163
|
+
return input;
|
|
164
|
+
/**
|
|
165
|
+
* ⚠⚠ ALWAYS ASSERT A cwd — SENDING NOTHING STILL INHERITS ANOTHER LOGIN'S DIRECTORY.
|
|
166
|
+
*
|
|
167
|
+
* The first version sent the field only when a directory had been stored, which reads as the
|
|
168
|
+
* obvious economy and leaves the bug half-open. MEASURED against the deployed fix: login A did
|
|
169
|
+
* `cd Q1`; login B, having stored nothing, sent no `cwd`, the server fell back to the shared
|
|
170
|
+
* session map, and B ran in `~/Q1` — the exact leak, surviving the fix for it.
|
|
171
|
+
*
|
|
172
|
+
* Absence has to keep meaning "a caller that does not manage its own cwd" (the web client, MCP,
|
|
173
|
+
* an older CLI), because that is what the session map is still there to serve. So a client that
|
|
174
|
+
* DOES manage its own must say `~` out loud rather than staying quiet: at root by decision, not
|
|
175
|
+
* by default.
|
|
176
|
+
*/
|
|
177
|
+
return { ...input, cwd: readShellCwd(target) ?? '~' };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Store the cwd a shell response reported.
|
|
181
|
+
*
|
|
182
|
+
* ⚠ `cwdReset` MEANS THE STORED PATH IS DEAD — the folder was deleted or renamed since. The server
|
|
183
|
+
* has already fallen back to the root and said so; clearing the entry is what stops this login
|
|
184
|
+
* sending the dead path on every subsequent call forever.
|
|
185
|
+
*/
|
|
186
|
+
export function absorbShellCwd(action, result, target) {
|
|
187
|
+
if (!isShellAction(action))
|
|
188
|
+
return;
|
|
189
|
+
const r = result;
|
|
190
|
+
if (!r || typeof r !== 'object')
|
|
191
|
+
return;
|
|
192
|
+
if (typeof r.cwdReset === 'string' && r.cwdReset) {
|
|
193
|
+
writeShellCwd(target, '~');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (typeof r.cwd === 'string' && r.cwd)
|
|
197
|
+
writeShellCwd(target, r.cwd);
|
|
198
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speak-lock.ts — one voice at a time, machine-wide.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ WHY THIS EXISTS: TWO `speak`s AT ONCE SOUND LIKE A BROKEN MODEL, NOT LIKE TWO
|
|
5
|
+
* MESSAGES. Reported as the voice "spazzing out and stuttering", with the telling
|
|
6
|
+
* detail that *the start was fine* — which is exactly the shape of overlap: the first
|
|
7
|
+
* utterance plays alone and is clean, then the second begins on top of it and the two
|
|
8
|
+
* become an unintelligible smear.
|
|
9
|
+
*
|
|
10
|
+
* Reproduced directly: three backgrounded `speak`s fired three seconds apart showed
|
|
11
|
+
* **two concurrent `afplay` processes**. Nothing in the audio was wrong — the model,
|
|
12
|
+
* the temperature and the joins all measured clean. The defect was that nobody owned
|
|
13
|
+
* the speaker.
|
|
14
|
+
*
|
|
15
|
+
* ⚠ IT SERIALISES, IT DOES NOT REFUSE. Dropping the second utterance would lose
|
|
16
|
+
* something the caller wanted said; playing it later is almost always right for
|
|
17
|
+
* status narration. A caller who genuinely wants to talk over the top can opt out.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ THE LOCK IS ADVISORY AND SELF-HEALING. It holds a pid; a lock whose owner is gone
|
|
20
|
+
* is taken immediately, so a killed `speak` cannot mute the machine until reboot —
|
|
21
|
+
* which would be a far worse failure than the overlap it prevents.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
const LOCK_DIR = join(homedir(), '.beehaven');
|
|
27
|
+
const LOCK_FILE = join(LOCK_DIR, 'speak.lock');
|
|
28
|
+
/** Longest we will wait for another utterance before speaking anyway. */
|
|
29
|
+
const MAX_WAIT_MS = 5 * 60_000;
|
|
30
|
+
const POLL_MS = 250;
|
|
31
|
+
function ownerAlive() {
|
|
32
|
+
try {
|
|
33
|
+
if (!existsSync(LOCK_FILE))
|
|
34
|
+
return null;
|
|
35
|
+
const pid = Number(readFileSync(LOCK_FILE, 'utf8').trim());
|
|
36
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
37
|
+
return null;
|
|
38
|
+
// Signal 0 tests existence without touching the process.
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return pid;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null; // owner is gone — the lock is stale
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
52
|
+
/**
|
|
53
|
+
* Wait for the speaker, then hold it. Returns a release function that is safe to
|
|
54
|
+
* call more than once.
|
|
55
|
+
*/
|
|
56
|
+
export async function acquireSpeakLock(opts = {}) {
|
|
57
|
+
if (opts.wait === false)
|
|
58
|
+
return () => { };
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
catch { /* the write below will report anything real */ }
|
|
63
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
64
|
+
while (ownerAlive() !== null && Date.now() < deadline) {
|
|
65
|
+
await sleep(POLL_MS);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o600 });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// ⚠ AN UNWRITABLE LOCK MUST NOT SILENCE THE UTTERANCE. Overlapping audio is a
|
|
72
|
+
// worse outcome than no audio only if you value tidiness over being heard.
|
|
73
|
+
return () => { };
|
|
74
|
+
}
|
|
75
|
+
let released = false;
|
|
76
|
+
const release = () => {
|
|
77
|
+
if (released)
|
|
78
|
+
return;
|
|
79
|
+
released = true;
|
|
80
|
+
try {
|
|
81
|
+
// Only remove it if it is still OURS — a lock we waited past the deadline for
|
|
82
|
+
// may since have been taken by someone else, and deleting theirs would let a
|
|
83
|
+
// third utterance in on top.
|
|
84
|
+
if (existsSync(LOCK_FILE) && readFileSync(LOCK_FILE, 'utf8').trim() === String(process.pid)) {
|
|
85
|
+
unlinkSync(LOCK_FILE);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch { /* nothing useful to do */ }
|
|
89
|
+
};
|
|
90
|
+
// ⚠ RELEASE ON THE WAYS OUT THAT ARE NOT A NORMAL RETURN. A ^C mid-utterance would
|
|
91
|
+
// otherwise leave a lock owned by a dead pid — self-healing, but only on the next
|
|
92
|
+
// call, which is one confusing wait too many.
|
|
93
|
+
process.once('exit', release);
|
|
94
|
+
process.once('SIGINT', () => { release(); process.exit(130); });
|
|
95
|
+
process.once('SIGTERM', () => { release(); process.exit(143); });
|
|
96
|
+
return release;
|
|
97
|
+
}
|
package/dist/spinner.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight CLI spinner and color utilities — zero dependencies.
|
|
3
|
+
*/
|
|
4
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
5
|
+
export class Spinner {
|
|
6
|
+
timer = null;
|
|
7
|
+
frameIdx = 0;
|
|
8
|
+
text;
|
|
9
|
+
constructor(text) {
|
|
10
|
+
this.text = text;
|
|
11
|
+
}
|
|
12
|
+
start() {
|
|
13
|
+
this.timer = setInterval(() => {
|
|
14
|
+
const frame = frames[this.frameIdx % frames.length];
|
|
15
|
+
process.stderr.write(`\r${color.cyan(frame)} ${this.text}`);
|
|
16
|
+
this.frameIdx++;
|
|
17
|
+
}, 80);
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
update(text) {
|
|
21
|
+
this.text = text;
|
|
22
|
+
}
|
|
23
|
+
succeed(text) {
|
|
24
|
+
this.stop();
|
|
25
|
+
console.log(`${color.green('✓')} ${text ?? this.text}`);
|
|
26
|
+
}
|
|
27
|
+
fail(text) {
|
|
28
|
+
this.stop();
|
|
29
|
+
console.error(`${color.red('✗')} ${text ?? this.text}`);
|
|
30
|
+
}
|
|
31
|
+
stop() {
|
|
32
|
+
if (this.timer) {
|
|
33
|
+
clearInterval(this.timer);
|
|
34
|
+
this.timer = null;
|
|
35
|
+
}
|
|
36
|
+
process.stderr.write('\r\x1b[K'); // Clear line
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const color = {
|
|
40
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
41
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
42
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
43
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
44
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
45
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
46
|
+
gray: (s) => `\x1b[90m${s}\x1b[0m`,
|
|
47
|
+
};
|
|
48
|
+
export function table(headers, rows) {
|
|
49
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
50
|
+
const sep = widths.map((w) => '─'.repeat(w + 2)).join('┼');
|
|
51
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
52
|
+
const formatRow = (row) => row.map((c, i) => ` ${pad(c, widths[i])} `).join('│');
|
|
53
|
+
return [formatRow(headers), sep, ...rows.map(formatRow)].join('\n');
|
|
54
|
+
}
|
|
55
|
+
export function spinner(text) {
|
|
56
|
+
return new Spinner(text);
|
|
57
|
+
}
|