@clearlysh/beehaven 0.8.33 → 0.8.35
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/agent-attribution.js +20 -2
- package/dist/agent-scope.js +55 -10
- package/dist/agent-state.js +44 -0
- package/dist/agents-md-text.js +59 -43
- package/dist/build-channel.js +2 -2
- package/dist/cli-state.js +45 -3
- package/dist/cli.js +57 -47
- package/dist/commands/agent-skills.js +12 -1
- package/dist/commands/agent.js +235 -28
- package/dist/commands/agents-md.js +97 -11
- package/dist/commands/help.js +14 -11
- package/dist/commands/workspace.js +19 -3
- package/dist/session-binding.js +135 -0
- package/dist/session-target.js +91 -0
- package/package.json +2 -2
- package/skills/clearly-agent/SKILL.md +27 -10
- package/skills/clearly-canvas/authoring.md +4 -3
- package/skills/index.json +2 -2
|
@@ -62,6 +62,21 @@ export const BOOTSTRAP_ACTIONS = new Set([
|
|
|
62
62
|
// The workspace-scoped predecessors, kept so an older flow still bootstraps.
|
|
63
63
|
'agent-mint-token', 'agent-create', 'agent-list',
|
|
64
64
|
]);
|
|
65
|
+
/**
|
|
66
|
+
* ⚠⚠ SPT-13 — `agent-create` ESTABLISHES AN IDENTITY ONLY WHEN IT CREATES THE ONE SIGNED IN.
|
|
67
|
+
*
|
|
68
|
+
* Unwrapped, it runs as the account owner. That is right for the bootstrap it exists for — the
|
|
69
|
+
* signed-in agent creating ITSELF in a workspace that has no row for it yet. It was wrong for every
|
|
70
|
+
* other agent: a signed-in agent whose matrix says `agents.create = false` minted `ss41-probe`
|
|
71
|
+
* because the call left as its owner. Creating ANOTHER agent is ordinary work, so it is wrapped and
|
|
72
|
+
* the capability gate decides.
|
|
73
|
+
*/
|
|
74
|
+
function createsAnotherAgent(action, input, active) {
|
|
75
|
+
if (action !== 'agent-create' || !active || active.expired)
|
|
76
|
+
return false;
|
|
77
|
+
// Without an `id` the server mints a random one, so the new agent cannot be the signed-in one.
|
|
78
|
+
return String(input?.id ?? '').trim() !== active.agentId;
|
|
79
|
+
}
|
|
65
80
|
/**
|
|
66
81
|
* ⚠⚠ ACTIONS THE DAEMON ANSWERS ITSELF — THEY NEVER REACH A WORKSPACE, SO THEY CANNOT BE WRAPPED.
|
|
67
82
|
*
|
|
@@ -165,7 +180,7 @@ export function alreadyAttributed(action, input) {
|
|
|
165
180
|
export function activeAgentWrap(action, input, values) {
|
|
166
181
|
if (alreadyAttributed(action, input))
|
|
167
182
|
return { action, input };
|
|
168
|
-
if (
|
|
183
|
+
if (DAEMON_LOCAL_ACTIONS.has(action))
|
|
169
184
|
return { action, input };
|
|
170
185
|
let active = null;
|
|
171
186
|
try {
|
|
@@ -174,6 +189,8 @@ export function activeAgentWrap(action, input, values) {
|
|
|
174
189
|
catch {
|
|
175
190
|
return { action, input };
|
|
176
191
|
}
|
|
192
|
+
if (BOOTSTRAP_ACTIONS.has(action) && !createsAnotherAgent(action, input, active))
|
|
193
|
+
return { action, input };
|
|
177
194
|
if (!active || active.expired)
|
|
178
195
|
return { action, input };
|
|
179
196
|
return {
|
|
@@ -182,7 +199,8 @@ export function activeAgentWrap(action, input, values) {
|
|
|
182
199
|
agentId: active.agentId,
|
|
183
200
|
action,
|
|
184
201
|
input,
|
|
185
|
-
|
|
202
|
+
// This session's own work session (AWO-394), not the record every terminal shares.
|
|
203
|
+
...(active.sessionId ? { sessionId: active.sessionId } : {}),
|
|
186
204
|
// ⚠ WHICH DOOR — `cli:call` vs `cli:ticket` vs … See `cliDoor()` and
|
|
187
205
|
// `agent_activity.source`. Set here, at the ONE wrap every CLI path shares, so a new
|
|
188
206
|
// verb is labelled without its author having to know this exists.
|
package/dist/agent-scope.js
CHANGED
|
@@ -29,9 +29,43 @@
|
|
|
29
29
|
* `cd` is recorded.
|
|
30
30
|
*/
|
|
31
31
|
export function deriveAgentScope(env = process.env) {
|
|
32
|
+
/**
|
|
33
|
+
* ⚠⚠ A HARNESS SESSION ID OUTRANKS `BEEHAVEN_AGENT` (AWO-394). The env var names WHO — and
|
|
34
|
+
* `activeAgentName` still reads it first for that — but it is not a per-session handle: the
|
|
35
|
+
* daemon spawns every desktop Claude session with `BEEHAVEN_AGENT=<desktop principal>`, so they
|
|
36
|
+
* all derived ONE scope and shared one cursor and one work session. The session id is what
|
|
37
|
+
* tells two conversations of one agent apart.
|
|
38
|
+
*/
|
|
39
|
+
const harness = harnessScope(env);
|
|
40
|
+
if (harness && harness !== 'claude')
|
|
41
|
+
return harness;
|
|
32
42
|
const explicit = (env.BEEHAVEN_AGENT || '').trim();
|
|
33
43
|
if (explicit)
|
|
34
|
-
return explicit; // an explicit choice
|
|
44
|
+
return explicit; // an explicit choice, absent a session id
|
|
45
|
+
if (harness)
|
|
46
|
+
return harness;
|
|
47
|
+
/**
|
|
48
|
+
* ⚠ NO MARKER AND NO TTY IS STILL NOT A PERSON. Key on the PARENT pid — stable for the
|
|
49
|
+
* life of one process tree, different for a concurrent one. Not perfect (pids are reused
|
|
50
|
+
* eventually), but the failure mode of a collision here is two runs of the SAME tool,
|
|
51
|
+
* which is far milder than the shared-file failure it replaces: two DIFFERENT agents.
|
|
52
|
+
*/
|
|
53
|
+
if (!process.stdin.isTTY && !process.stdout.isTTY)
|
|
54
|
+
return `pid-${process.ppid}`;
|
|
55
|
+
/**
|
|
56
|
+
* ⚠ A TERMINAL TAB IS ITS OWN SCOPE WHEN THE TERMINAL SAYS WHICH TAB (AWO-394). Every human
|
|
57
|
+
* terminal used to derive `''`, so one `agent login` in any tab made EVERY tab that agent — and
|
|
58
|
+
* the login's own advice ("other terminals: export BEEHAVEN_AGENT") was moot. These ids are set
|
|
59
|
+
* per tab/pane by the terminal itself. Without one, `''` keeps the old shared behaviour.
|
|
60
|
+
*
|
|
61
|
+
* ⚠ A HUMAN WHO HAS NOT LOGGED IN AS AN AGENT STILL SHARES `target.json` — `cli-state`
|
|
62
|
+
* maps an unbound `term-*` scope back to the shared cursor, so nobody's `cd` moves.
|
|
63
|
+
*/
|
|
64
|
+
const tab = terminalTabId(env);
|
|
65
|
+
return tab ? `term-${tab}` : ''; // a human at a terminal
|
|
66
|
+
}
|
|
67
|
+
/** The harness session scope (`claude-…`, `codex-…`, `gemini-…`), or '' — see `deriveAgentScope`. */
|
|
68
|
+
function harnessScope(env) {
|
|
35
69
|
// Claude Code — its own marker (`daemon-omni.ts` already relies on it: "the SDK refuses to
|
|
36
70
|
// start when CLAUDECODE=1 is set … it's how it refuses to recurse"), plus a per-session id.
|
|
37
71
|
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) {
|
|
@@ -45,15 +79,26 @@ export function deriveAgentScope(env = process.env) {
|
|
|
45
79
|
if (env.GEMINI_CLI_SESSION_ID) {
|
|
46
80
|
return `gemini-${String(env.GEMINI_CLI_SESSION_ID).replace(/[^A-Za-z0-9]/g, '').slice(0, 12)}`;
|
|
47
81
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Which terminal tab or pane this is, from the id the terminal itself exports — or ''.
|
|
86
|
+
* macOS Terminal / iTerm2 (`TERM_SESSION_ID`, `ITERM_SESSION_ID`), Windows Terminal, tmux, kitty,
|
|
87
|
+
* WezTerm. Hashed to a short token: some carry paths or GUIDs, and it lands in a filename.
|
|
88
|
+
*/
|
|
89
|
+
export function terminalTabId(env = process.env) {
|
|
90
|
+
const raw = env.TMUX_PANE && env.TMUX ? `tmux${env.TMUX}${env.TMUX_PANE}`
|
|
91
|
+
: env.ITERM_SESSION_ID || env.TERM_SESSION_ID || env.WT_SESSION
|
|
92
|
+
|| (env.KITTY_WINDOW_ID ? `kitty${env.KITTY_WINDOW_ID}` : '')
|
|
93
|
+
|| (env.WEZTERM_PANE ? `wezterm${env.WEZTERM_PANE}` : '');
|
|
94
|
+
if (!raw)
|
|
95
|
+
return '';
|
|
96
|
+
let h = 2166136261; // FNV-1a — stable, dependency-free
|
|
97
|
+
for (let i = 0; i < raw.length; i++) {
|
|
98
|
+
h ^= raw.charCodeAt(i);
|
|
99
|
+
h = Math.imul(h, 16777619);
|
|
100
|
+
}
|
|
101
|
+
return (h >>> 0).toString(36);
|
|
57
102
|
}
|
|
58
103
|
export function principalKind(input) {
|
|
59
104
|
const now = input.now ?? Date.now();
|
package/dist/agent-state.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* (`agent:mint`) and the CLI just stores the resulting record.
|
|
12
12
|
*/
|
|
13
13
|
import { deriveAgentScope } from './agent-scope.js';
|
|
14
|
+
import { readSessionBinding } from './session-binding.js';
|
|
14
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, chmodSync, } from 'node:fs';
|
|
15
16
|
import { homedir } from 'node:os';
|
|
16
17
|
import { join } from 'node:path';
|
|
@@ -230,6 +231,17 @@ export function bindAgentScope(name, scope, opts = {}) {
|
|
|
230
231
|
}
|
|
231
232
|
catch { /* binding is a convenience; a failed write must not fail the login */ }
|
|
232
233
|
}
|
|
234
|
+
/** Forget that `scope` was logged in as `name` — `agent logout` in that session (AWO-394). */
|
|
235
|
+
export function unbindAgentScope(name, scope) {
|
|
236
|
+
try {
|
|
237
|
+
const rec = loadAgent(name);
|
|
238
|
+
const prev = Array.isArray(rec?.boundScopes) ? rec.boundScopes : [];
|
|
239
|
+
if (!rec || !prev.includes(scope))
|
|
240
|
+
return;
|
|
241
|
+
saveAgent(name, { ...rec, boundScopes: prev.filter((s) => s !== scope) });
|
|
242
|
+
}
|
|
243
|
+
catch { /* best-effort */ }
|
|
244
|
+
}
|
|
233
245
|
/**
|
|
234
246
|
* The stable half of a process scope — the TOOL rather than the conversation.
|
|
235
247
|
*
|
|
@@ -240,6 +252,9 @@ export function bindAgentScope(name, scope, opts = {}) {
|
|
|
240
252
|
export function scopeFamilyKey(scope) {
|
|
241
253
|
if (!scope)
|
|
242
254
|
return 'family:terminal';
|
|
255
|
+
// A terminal TAB (AWO-394) inherits nothing: one tab's login must not become another tab's identity.
|
|
256
|
+
if (scope.startsWith('term-'))
|
|
257
|
+
return null;
|
|
243
258
|
const m = /^(claude|codex|gemini)-/.exec(scope);
|
|
244
259
|
if (m)
|
|
245
260
|
return `family:${m[1]}`;
|
|
@@ -254,6 +269,14 @@ export function scopeFamilyKey(scope) {
|
|
|
254
269
|
*/
|
|
255
270
|
export function agentForScope(scope = '') {
|
|
256
271
|
try {
|
|
272
|
+
/**
|
|
273
|
+
* ⚠ THE SESSION BINDING WINS (AWO-394). `agent login` writes `sessions/<scope>.json`, and that
|
|
274
|
+
* exact record — not a list on a record shared by every terminal using the name — is what this
|
|
275
|
+
* session is. `boundScopes` below stays for sessions bound before it existed and for the family.
|
|
276
|
+
*/
|
|
277
|
+
const session = scope ? readSessionBinding(scope) : null;
|
|
278
|
+
if (session && loadAgent(session.agent))
|
|
279
|
+
return session.agent;
|
|
257
280
|
const all = listAgents();
|
|
258
281
|
const boundTo = (key) => all
|
|
259
282
|
.filter(({ record }) => Array.isArray(record.boundScopes)
|
|
@@ -369,6 +392,18 @@ export function activeAgentName(values) {
|
|
|
369
392
|
return null;
|
|
370
393
|
}
|
|
371
394
|
}
|
|
395
|
+
/** The session binding of THIS process, when it belongs to `name` (or to whoever is active). */
|
|
396
|
+
export function activeSessionBinding(name) {
|
|
397
|
+
try {
|
|
398
|
+
const b = readSessionBinding(deriveAgentScope());
|
|
399
|
+
if (!b)
|
|
400
|
+
return null;
|
|
401
|
+
return !name || b.agent === name ? b : null;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
372
407
|
/**
|
|
373
408
|
* Resolve the active agent identity for this invocation, or null when no
|
|
374
409
|
* agent is selected. Used by the call path to attach the token + route the
|
|
@@ -381,6 +416,13 @@ export function resolveActiveAgent(values) {
|
|
|
381
416
|
const record = loadAgent(name);
|
|
382
417
|
if (!record)
|
|
383
418
|
return null;
|
|
419
|
+
const session = activeSessionBinding(name);
|
|
420
|
+
/**
|
|
421
|
+
* ⚠ THE SESSION'S OWN WORK SESSION, NOT THE SHARED RECORD'S (AWO-394). `record.sessionId` is one
|
|
422
|
+
* field for every terminal using this name, so a second terminal's login replaced the first's and
|
|
423
|
+
* both then wrote under it. A session bound by `agent login` carries its own.
|
|
424
|
+
*/
|
|
425
|
+
const sessionId = session ? session.sessionId : record.sessionId;
|
|
384
426
|
return {
|
|
385
427
|
name,
|
|
386
428
|
token: record.token,
|
|
@@ -389,6 +431,8 @@ export function resolveActiveAgent(values) {
|
|
|
389
431
|
avatarId: record.avatarId,
|
|
390
432
|
record,
|
|
391
433
|
expired: typeof record.expiresAt === 'number' && record.expiresAt <= Date.now(),
|
|
434
|
+
...(sessionId ? { sessionId } : {}),
|
|
435
|
+
...(session ? { session } : {}),
|
|
392
436
|
};
|
|
393
437
|
}
|
|
394
438
|
/**
|
package/dist/agents-md-text.js
CHANGED
|
@@ -1,64 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agents-md-text — the Clearly block for a PROJECT's AGENTS.md, as `beehaven agents-md` writes it
|
|
3
|
-
* (CAN2-66).
|
|
3
|
+
* (CAN2-66, AWO-401).
|
|
4
4
|
*
|
|
5
5
|
* ⚠⚠ A COPY, PINNED. The source of truth is the monorepo's `lib/connections/agentsMd.ts`, which the
|
|
6
|
-
* Integrations page, each agent's details and the MCP `clearly-agents-md`
|
|
7
|
-
* package cannot import it (it ships on its own), so the text is repeated here
|
|
8
|
-
* `lib/__tests__/agentsMdCliParity.test.mjs` compares the two OUTPUTS, name
|
|
9
|
-
* Change both or the test fails — a stale copy does not render wrong, it
|
|
6
|
+
* Integrations page, each agent's details, the /cli and /mcp guides and the MCP `clearly-agents-md`
|
|
7
|
+
* prompt render. This package cannot import it (it ships on its own), so the text is repeated here
|
|
8
|
+
* and the monorepo's `lib/__tests__/agentsMdCliParity.test.mjs` compares the two OUTPUTS, name
|
|
9
|
+
* argument included. Change both or the test fails — a stale copy does not render wrong, it
|
|
10
|
+
* instructs wrong.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ ONE TRACKED BLOCK FOR MCP AND THE CLI (founder 2026-10-28): every task is tracked in Clearly,
|
|
13
|
+
* and each step names the MCP tool and the CLI command. The per-route blocks before it are removed
|
|
14
|
+
* when this one is written (`LEGACY_BLOCK_MARKERS`).
|
|
10
15
|
*
|
|
11
16
|
* ⚠ NO IMPORTS, so that test can load this file directly.
|
|
12
17
|
*/
|
|
13
|
-
export const
|
|
14
|
-
export const
|
|
15
|
-
|
|
16
|
-
export const
|
|
18
|
+
export const AGENTS_MD_START = '<!-- clearly:start -->';
|
|
19
|
+
export const AGENTS_MD_END = '<!-- clearly:end -->';
|
|
20
|
+
/** The per-route markers used before AWO-401. Writers remove these blocks; nothing writes them. */
|
|
21
|
+
export const LEGACY_BLOCK_MARKERS = [
|
|
22
|
+
['<!-- clearly:cli:start -->', '<!-- clearly:cli:end -->'],
|
|
23
|
+
['<!-- clearly:mcp:start -->', '<!-- clearly:mcp:end -->'],
|
|
24
|
+
];
|
|
25
|
+
/** How Claude Code runs the MCP server's `clearly-agents-md` prompt, which writes the block. */
|
|
26
|
+
export const MCP_AGENTS_MD_SLASH = '/mcp__clearly__clearly-agents-md';
|
|
17
27
|
/** What goes in CLAUDE.md so Claude Code reads the AGENTS.md block too. */
|
|
18
28
|
export const CLAUDE_MD_IMPORT = '@AGENTS.md';
|
|
19
|
-
/**
|
|
20
|
-
export
|
|
29
|
+
/** Where the block goes, said once for every surface. */
|
|
30
|
+
export const AGENTS_MD_HOW = 'Every agent that opens the repository reads AGENTS.md, so the block makes each session track its work in Clearly. Claude Code reads CLAUDE.md — give it one line.';
|
|
31
|
+
/** The MCP tools the block names. Each must be on the server's `tools/list` (a Worker test pins it). */
|
|
32
|
+
export const MCP_BLOCK_TOOLS = ['clearly_catalog', 'clearly_read', 'clearly_grep', 'clearly_create', 'clearly_edit', 'clearly_comment'];
|
|
33
|
+
/**
|
|
34
|
+
* The block. `agentName` personalises the CLI sign-in line for one agent (its details view);
|
|
35
|
+
* without it the agent is told to use its own name.
|
|
36
|
+
*/
|
|
37
|
+
export function agentsMdBlock(agentName) {
|
|
21
38
|
const name = (agentName ?? '').trim();
|
|
22
39
|
const login = name
|
|
23
|
-
? `\`beehaven agent login ${name} --label "<
|
|
24
|
-
: '`beehaven agent login <name> --label "<
|
|
40
|
+
? `\`beehaven agent login ${name} --label "<the task>"\` — you are ${name} in this workspace —`
|
|
41
|
+
: '`beehaven agent login <your-name> --label "<the task>"` (claude, codex, gemini…)';
|
|
25
42
|
return [
|
|
26
|
-
|
|
27
|
-
'<!-- managed-by: clearly. Run `beehaven agents-md` to update; edit outside this block. -->',
|
|
43
|
+
AGENTS_MD_START,
|
|
44
|
+
'<!-- managed-by: clearly. Run `beehaven agents-md` or the `clearly-agents-md` MCP prompt to update; edit outside this block. -->',
|
|
28
45
|
'',
|
|
29
|
-
'## Clearly workspace',
|
|
46
|
+
'## Clearly workspace: track every task',
|
|
30
47
|
'',
|
|
31
|
-
|
|
32
|
-
'
|
|
48
|
+
"This project's plans, tickets, documents and designs live in Clearly. Track every task there,",
|
|
49
|
+
'even when the person does not ask. Use whichever route this session has: the `clearly_*` MCP tools,',
|
|
50
|
+
"or the `beehaven` CLI (`beehaven actions` lists actions; `beehaven call <action> '<json>'` runs one).",
|
|
33
51
|
'',
|
|
34
|
-
|
|
52
|
+
'### Start of every session',
|
|
53
|
+
'',
|
|
54
|
+
`- CLI: sign in first with ${login} and read the brief it prints.`,
|
|
35
55
|
' If it says the daemon is not running: `beehaven start --headless`.',
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'
|
|
56
|
+
' MCP: you are already signed in as the agent chosen when Clearly was connected.',
|
|
57
|
+
"- Read this project's open tickets and its latest handoff before starting (`clearly_grep` /",
|
|
58
|
+
' `clearly_read`, or the brief).',
|
|
59
|
+
'',
|
|
60
|
+
'### Every task has a ticket',
|
|
61
|
+
'',
|
|
62
|
+
'1. Search first (`clearly_grep`, `clearly_catalog`, or `beehaven call ticket-list`). Reuse the project,',
|
|
63
|
+
' board and ticket that already own the work; create a board only if the project has none.',
|
|
64
|
+
'2. Create the ticket before changing code or files (`clearly_create` with `type: "ticket"` and',
|
|
65
|
+
' `fields.boardId`, or `beehaven call ticket-create`). Say in its body what done means.',
|
|
66
|
+
'3. Move it to in-progress when work begins (`clearly_edit`, or `beehaven call ticket-update`).',
|
|
67
|
+
'4. File what you make (documents, designs, notes) into the project and link it from the ticket.',
|
|
68
|
+
' Record decisions, open questions and progress as comments (`clearly_comment`).',
|
|
69
|
+
'5. Mark it done only after verifying the result, with the resolution and any remaining risk.',
|
|
49
70
|
'',
|
|
50
|
-
'
|
|
71
|
+
'### End of every session',
|
|
51
72
|
'',
|
|
52
|
-
'
|
|
53
|
-
'
|
|
73
|
+
'- Leave a handoff: what you did and what is next. CLI: `beehaven agent handoff "<done · next>"`.',
|
|
74
|
+
' MCP: a comment on the work ticket.',
|
|
54
75
|
'',
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
|
|
58
|
-
' (`CLR-42`), an id or an exact title. The extension names the type (`.md` document, `.csv`',
|
|
59
|
-
' sheet, `.board.json` board); a trailing slash means a project.',
|
|
60
|
-
'- Search before you write, and write decisions back so the next session inherits them.',
|
|
61
|
-
'- If a job is not covered by a listed tool, say so rather than guessing an action name.',
|
|
62
|
-
MCP_BLOCK_END,
|
|
76
|
+
'If this session has neither the `clearly_*` tools nor `beehaven`, stop and ask the person to connect',
|
|
77
|
+
'Clearly (clearly.sh/docs/mcp or clearly.sh/docs/cli). Do not skip the tracking.',
|
|
78
|
+
AGENTS_MD_END,
|
|
63
79
|
].join('\n');
|
|
64
80
|
}
|
package/dist/build-channel.js
CHANGED
|
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
|
|
|
53
53
|
* ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
|
|
54
54
|
* carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
|
|
55
55
|
*/
|
|
56
|
-
export const BUILD_SHA = '
|
|
56
|
+
export const BUILD_SHA = 'f866489dd2f1';
|
|
57
57
|
/** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
|
|
58
|
-
export const BUILD_TIME = '2026-10-
|
|
58
|
+
export const BUILD_TIME = '2026-10-28T16:02Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
package/dist/cli-state.js
CHANGED
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
* target is what stops the inheritance. Pin it, or pass `--building` per call.
|
|
42
42
|
*/
|
|
43
43
|
import { deriveAgentScope } from './agent-scope.js';
|
|
44
|
-
import { agentForScope } from './agent-state.js';
|
|
44
|
+
import { agentForScope, loadAgent } from './agent-state.js';
|
|
45
|
+
import { readSessionBinding, scopeKey } from './session-binding.js';
|
|
45
46
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from 'fs';
|
|
46
47
|
import { join } from 'path';
|
|
47
48
|
import { homedir } from 'os';
|
|
@@ -92,14 +93,55 @@ export function effectiveScope() {
|
|
|
92
93
|
const raw = deriveAgentScope();
|
|
93
94
|
if (!raw)
|
|
94
95
|
return ''; // a human — the shared file
|
|
96
|
+
/**
|
|
97
|
+
* ⚠⚠ A SESSION BOUND BY `agent login` HAS ITS OWN CURSOR (AWO-394) — keyed on the session, not
|
|
98
|
+
* the agent name. Keyed on the name, two terminals logged in as one agent shared a workspace:
|
|
99
|
+
* one `connect` moved both. The workspace a NEW session starts in is chosen at login (the
|
|
100
|
+
* identity's last workspace, or `--workspace`), which keeps what the name key was for.
|
|
101
|
+
*/
|
|
102
|
+
if (readSessionBinding(raw))
|
|
103
|
+
return raw;
|
|
95
104
|
try {
|
|
105
|
+
// A session bound before session bindings existed keeps its agent-keyed cursor until it logs in again.
|
|
96
106
|
const bound = agentForScope(raw);
|
|
97
|
-
if (bound)
|
|
98
|
-
|
|
107
|
+
if (bound && !raw.startsWith('term-')) {
|
|
108
|
+
// …but only an EXACT binding. A session that merely inherited the family's identity starts
|
|
109
|
+
// at home in its own cursor — it must not inherit someone else's workspace with the name.
|
|
110
|
+
const rec = loadAgent(bound);
|
|
111
|
+
if (Array.isArray(rec?.boundScopes) && rec.boundScopes.includes(raw))
|
|
112
|
+
return bound;
|
|
113
|
+
}
|
|
99
114
|
}
|
|
100
115
|
catch { /* no agents dir, or a bad record — fall back to the process scope */ }
|
|
116
|
+
// A terminal tab nobody logged in from is the human: the shared cursor, exactly as before.
|
|
117
|
+
if (raw.startsWith('term-'))
|
|
118
|
+
return '';
|
|
101
119
|
return raw;
|
|
102
120
|
}
|
|
121
|
+
/** The cursor file of a given session scope — `agent login` seeds a NEW session's cursor here. */
|
|
122
|
+
export function writeTargetForScope(scope, address, kind) {
|
|
123
|
+
const key = scopeKey(scope);
|
|
124
|
+
if (!key)
|
|
125
|
+
return;
|
|
126
|
+
ensureStateDir();
|
|
127
|
+
const file = join(STATE_DIR, `target-${key}.json`);
|
|
128
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
129
|
+
writeFileSync(tmp, JSON.stringify({ address, connectedAt: Date.now(), kind: kind ?? inferKind(address) }, null, 2), { mode: 0o600 });
|
|
130
|
+
renameSync(tmp, file);
|
|
131
|
+
}
|
|
132
|
+
/** Read the cursor of a given scope without making it this process's (the legacy agent-keyed file). */
|
|
133
|
+
export function readTargetForKey(key) {
|
|
134
|
+
try {
|
|
135
|
+
const safe = scopeKey(key);
|
|
136
|
+
if (!safe)
|
|
137
|
+
return null;
|
|
138
|
+
const parsed = JSON.parse(readFileSync(join(STATE_DIR, `target-${safe}.json`), 'utf-8'));
|
|
139
|
+
return typeof parsed?.address === 'string' && parsed.address ? parsed : null;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
103
145
|
function targetFileFor(agent = effectiveScope()) {
|
|
104
146
|
const safe = (agent || '').trim().replace(/[^A-Za-z0-9_-]/g, '');
|
|
105
147
|
return join(STATE_DIR, safe ? `target-${safe}.json` : 'target.json');
|
package/dist/cli.js
CHANGED
|
@@ -849,48 +849,9 @@ async function printStatus() {
|
|
|
849
849
|
* One function, because four hand-written copies is how one missing `else` became four.
|
|
850
850
|
*/
|
|
851
851
|
async function resolveTargetBuilding(values) {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const t = getTarget();
|
|
856
|
-
if (t?.address?.includes('/')) {
|
|
857
|
-
const [bType, ...bIdParts] = t.address.split('/');
|
|
858
|
-
values.building = `${bType}:${bIdParts.join('/')}`;
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
if (t && isHomeAddress(t.address)) {
|
|
862
|
-
/**
|
|
863
|
-
* ⚠ HOME IS A POSITIVE STATEMENT, NOT AN ABSENCE. The daemon's focus is a routing
|
|
864
|
-
* optimisation; the file is where the user said to be. If they disagree the daemon is
|
|
865
|
-
* stale — drop it, so the call lands where `pwd` says it will, and SAY so.
|
|
866
|
-
*/
|
|
867
|
-
try {
|
|
868
|
-
const { daemonGetConnection, daemonDisconnect } = await import('./daemon-client.js');
|
|
869
|
-
const conn = await daemonGetConnection();
|
|
870
|
-
if (conn?.buildingId) {
|
|
871
|
-
await daemonDisconnect();
|
|
872
|
-
console.error(`\x1b[2m (dropped a stale daemon focus on ${conn.buildingType}/${conn.buildingId}`
|
|
873
|
-
+ ` — your target is home)\x1b[0m`);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
catch { /* no daemon, or it refused — home is the default route anyway */ }
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
if (!t) {
|
|
880
|
-
/**
|
|
881
|
-
* ⚠ NO TARGET EVER SET — inherit the daemon's connection. The machine-global fallback
|
|
882
|
-
* `cli-state.ts` warns about: another room, the Mac app or a human's terminal can set
|
|
883
|
-
* it, so an agent that never runs `connect` inherits someone else's cursor. Kept for
|
|
884
|
-
* back-compat; `connect home` is what opts out, and this fix makes that opt-out real.
|
|
885
|
-
*/
|
|
886
|
-
try {
|
|
887
|
-
const { daemonGetConnection } = await import('./daemon-client.js');
|
|
888
|
-
const conn = await daemonGetConnection();
|
|
889
|
-
if (conn)
|
|
890
|
-
values.building = `${conn.buildingType}:${conn.buildingId}`;
|
|
891
|
-
}
|
|
892
|
-
catch { /* fall through to home */ }
|
|
893
|
-
}
|
|
852
|
+
// ⚠ ONE resolver for every verb, typed or raw (AWO-393/394) — see session-target.ts.
|
|
853
|
+
const { resolveTargetBuilding: resolve } = await import('./session-target.js');
|
|
854
|
+
await resolve(values);
|
|
894
855
|
}
|
|
895
856
|
/**
|
|
896
857
|
* ⚠⚠ AN IDENTIFIABLE AGENT MUST LOG IN BEFORE IT ACTS.
|
|
@@ -1103,8 +1064,10 @@ async function requireAgentSession(command, values) {
|
|
|
1103
1064
|
cfg = {};
|
|
1104
1065
|
}
|
|
1105
1066
|
// ⚠ ONE DECISION, EXECUTED BY A GUARD (`principalKind`). This branch does nothing but act on it.
|
|
1067
|
+
const { activeSessionBinding: sessionForGate } = await import('./agent-state.js');
|
|
1106
1068
|
const who = principalKind({
|
|
1107
|
-
|
|
1069
|
+
// This session's own work session first (AWO-394); the shared record's for sessions bound before it.
|
|
1070
|
+
agentSessionId: sessionForGate(name)?.sessionId ?? rec?.sessionId,
|
|
1108
1071
|
agentExpiresAt: rec?.expiresAt,
|
|
1109
1072
|
agentRuntime: isAgentRuntime(),
|
|
1110
1073
|
accountUserId: cfg.userId,
|
|
@@ -1377,7 +1340,7 @@ async function run() {
|
|
|
1377
1340
|
state: { type: 'string' },
|
|
1378
1341
|
goal: { type: 'string' },
|
|
1379
1342
|
remove: { type: 'boolean', default: false },
|
|
1380
|
-
// `agents-md`:
|
|
1343
|
+
// `agents-md`: `--mcp` is accepted for older instructions (one block since AWO-401); `--print` prints it.
|
|
1381
1344
|
mcp: { type: 'boolean', default: false },
|
|
1382
1345
|
print: { type: 'boolean', default: false },
|
|
1383
1346
|
/**
|
|
@@ -2237,6 +2200,14 @@ async function run() {
|
|
|
2237
2200
|
// workspace via daemonRpc('shell:exec', …) — see
|
|
2238
2201
|
// commands/shell.ts:resolveDefaultBuilding + execRemote.
|
|
2239
2202
|
const { HOME_ADDRESSES: HOME_ADDRS, CANONICAL_HOME_ADDRESS } = await import('./cli-state.js');
|
|
2203
|
+
/**
|
|
2204
|
+
* ⚠⚠ A SIGNED-IN SESSION MOVES ONLY ITSELF (AWO-394). The daemon's focus is ONE variable for
|
|
2205
|
+
* the machine, so `connect` used to re-point every other terminal that had no cursor of its
|
|
2206
|
+
* own — agent A connecting to a Shopify store moved agent B. A session's own cursor file is
|
|
2207
|
+
* the whole of its routing; the daemon is not told.
|
|
2208
|
+
*/
|
|
2209
|
+
const { activeSessionBinding: sessionOf } = await import('./agent-state.js');
|
|
2210
|
+
const ownSession = sessionOf();
|
|
2240
2211
|
if (HOME_ADDRS.has(target)) {
|
|
2241
2212
|
const { setTarget } = await import('./cli-state.js');
|
|
2242
2213
|
// Canonical label is 'workspace' (no "home DO" — the daemon's relay is to the
|
|
@@ -2247,7 +2218,11 @@ async function run() {
|
|
|
2247
2218
|
setTarget(CANONICAL_HOME_ADDRESS, 'cloud');
|
|
2248
2219
|
// Best-effort daemon-side cleanup of any prior connect()
|
|
2249
2220
|
// focus — we want home to be the default routing target.
|
|
2250
|
-
if (
|
|
2221
|
+
if (ownSession) {
|
|
2222
|
+
const { moveAgentSession } = await import('./commands/agent.js');
|
|
2223
|
+
await moveAgentSession(CANONICAL_HOME_ADDRESS);
|
|
2224
|
+
}
|
|
2225
|
+
else if (daemonUp) {
|
|
2251
2226
|
try {
|
|
2252
2227
|
await daemonDisconnect();
|
|
2253
2228
|
}
|
|
@@ -2290,6 +2265,15 @@ async function run() {
|
|
|
2290
2265
|
// where we meant to be, and we can retry without a re-`cd`.
|
|
2291
2266
|
const { setTarget } = await import('./cli-state.js');
|
|
2292
2267
|
setTarget(`${bType}/${bId}`, 'cloud');
|
|
2268
|
+
if (ownSession) {
|
|
2269
|
+
const { moveAgentSession } = await import('./commands/agent.js');
|
|
2270
|
+
await moveAgentSession(`${bType}/${bId}`);
|
|
2271
|
+
if (bType === 'shopifyStore' || bType === 'store') {
|
|
2272
|
+
console.log(color.dim(` Store DOs take operator calls through: beehaven admin do ${bType}/${bId} <action> '<json>' (platform admins)`));
|
|
2273
|
+
}
|
|
2274
|
+
console.log(` ${color.green('✓')} This session → ${color.green(`${bType}/${bId}`)}`);
|
|
2275
|
+
break;
|
|
2276
|
+
}
|
|
2293
2277
|
try {
|
|
2294
2278
|
await daemonConnect(bType, bId);
|
|
2295
2279
|
}
|
|
@@ -2399,7 +2383,13 @@ async function run() {
|
|
|
2399
2383
|
const { clearTarget: clearDisconn } = await import('./cli-state.js');
|
|
2400
2384
|
clearDisconn();
|
|
2401
2385
|
const { isDaemonRunning: isDaemonRunningDisc, daemonDisconnect: daemonDisconnectDisc } = await import('./daemon-client.js');
|
|
2402
|
-
|
|
2386
|
+
const { activeSessionBinding: sessionOfDisc } = await import('./agent-state.js');
|
|
2387
|
+
if (sessionOfDisc()) {
|
|
2388
|
+
// AWO-394 — a signed-in session goes home alone; the machine-wide focus is someone else's.
|
|
2389
|
+
const { moveAgentSession } = await import('./commands/agent.js');
|
|
2390
|
+
await moveAgentSession('workspace');
|
|
2391
|
+
}
|
|
2392
|
+
else if (await isDaemonRunningDisc()) {
|
|
2403
2393
|
try {
|
|
2404
2394
|
await daemonDisconnectDisc();
|
|
2405
2395
|
}
|
|
@@ -2457,7 +2447,22 @@ async function run() {
|
|
|
2457
2447
|
// ⚠ `values` HERE TOO — `beehaven status --agent alice` must report who that call would
|
|
2458
2448
|
// run as, not who the shell happens to be bound to. See `requireAgentSession`.
|
|
2459
2449
|
const who = activeAgentName(values);
|
|
2460
|
-
|
|
2450
|
+
const { activeSessionBinding } = await import('./agent-state.js');
|
|
2451
|
+
const { describeAddress, currentEnvName } = await import('./session-binding.js');
|
|
2452
|
+
const bound = activeSessionBinding(who);
|
|
2453
|
+
if (who && bound) {
|
|
2454
|
+
// AWO-394 — one session: identity + workspace + env, owned by this scope.
|
|
2455
|
+
const { loadAgent } = await import('./agent-state.js');
|
|
2456
|
+
const r = loadAgent(who);
|
|
2457
|
+
const gone = typeof r?.expiresAt === 'number' && r.expiresAt <= Date.now();
|
|
2458
|
+
const envNow = currentEnvName();
|
|
2459
|
+
console.log(gone
|
|
2460
|
+
? ` Agent: ${color.green(who)} ${'\x1b[31m'}TOKEN EXPIRED\x1b[0m${color.dim(' — calls are refused until `beehaven agent login ' + who + '`')}`
|
|
2461
|
+
: ` Agent: ${color.green(who)}${color.dim(' (this session — every call here runs as this agent)')}`);
|
|
2462
|
+
console.log(` Session: ${color.green(bound.scope)}${color.dim(` work session ${bound.sessionId ? bound.sessionId.slice(0, 8) : 'none'} · in ${describeAddress(bound.workspace)}`)}`);
|
|
2463
|
+
console.log(` Env: ${bound.env === envNow ? color.green(bound.env) : `\x1b[31m${bound.env} — daemon is on ${envNow}; calls are refused\x1b[0m`}`);
|
|
2464
|
+
}
|
|
2465
|
+
else if (who && eff === who) {
|
|
2461
2466
|
// Identity, workspace session and cursor are one handle.
|
|
2462
2467
|
const { loadAgent } = await import('./agent-state.js');
|
|
2463
2468
|
const r = loadAgent(who);
|
|
@@ -2472,6 +2477,11 @@ async function run() {
|
|
|
2472
2477
|
: ` Agent: ${color.green(who)}${color.dim(' (logged in — calls run as this agent)')}`);
|
|
2473
2478
|
console.log(` Scope: ${color.green(eff)}${color.dim(' (cursor follows the agent, not the session)')}`);
|
|
2474
2479
|
}
|
|
2480
|
+
else if (raw.startsWith('term-')) {
|
|
2481
|
+
// AWO-394 — a tab nobody signed in from is the person, on the shared cursor.
|
|
2482
|
+
console.log(` Scope: ${color.green(raw)}${color.dim(' (this tab — `beehaven agent login <name>` here signs in THIS tab only)')}`);
|
|
2483
|
+
console.log(` Agent: ${color.dim('none — calls run as you, on the cursor your other terminals share')}`);
|
|
2484
|
+
}
|
|
2475
2485
|
else if (raw) {
|
|
2476
2486
|
console.log(` Scope: ${color.green(raw)}${color.dim(' (private cursor — concurrent agents cannot clobber it)')}`);
|
|
2477
2487
|
console.log(` Agent: ${color.dim('none — calls run as you. `beehaven agent login <name>` to act as an agent')}`);
|
|
@@ -236,14 +236,25 @@ export function installAgentSkills() {
|
|
|
236
236
|
* ⚠ NO STAMP ⇒ TREATED AS UNTOUCHED. Every skill installed before stamping existed has
|
|
237
237
|
* none, and assuming an edit there would freeze them permanently — updates would silently
|
|
238
238
|
* stop landing, which is the failure mode this whole change exists to remove.
|
|
239
|
+
*
|
|
240
|
+
* ⚠ IDENTICAL TO WHAT SHIPS ⇒ CURRENT, WHATEVER THE STAMP SAYS (PPO-476). The Mac app writes
|
|
241
|
+
* these same files on every launch, and a copy can only equal the release by coming from
|
|
242
|
+
* it. Asked first, a stale stamp called every skill "edited locally" and none was ever
|
|
243
|
+
* re-stamped, so the next real update never landed either.
|
|
239
244
|
*/
|
|
240
245
|
if (existing) {
|
|
246
|
+
const local = localSha(destDir, skillFiles);
|
|
247
|
+
if (entry?.sha && local === entry.sha) {
|
|
248
|
+
stamp(destDir, skillFiles, warnings);
|
|
249
|
+
steps.push(`${agent}/${name}=current`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
241
252
|
let stamped = '';
|
|
242
253
|
try {
|
|
243
254
|
stamped = JSON.parse(fs.readFileSync(path.join(destDir, STAMP), 'utf8')).sha ?? '';
|
|
244
255
|
}
|
|
245
256
|
catch { /* unstamped */ }
|
|
246
|
-
if (stamped &&
|
|
257
|
+
if (stamped && local !== stamped) {
|
|
247
258
|
warnings.push(`${agent}/${name}: edited locally — left alone (delete it to take the shipped copy)`);
|
|
248
259
|
steps.push(`${agent}/${name}=locally-modified`);
|
|
249
260
|
continue;
|