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
package/dist/commands/agent.js
CHANGED
|
@@ -91,6 +91,60 @@ async function endWorkspaceSession(record, summary) {
|
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/** `team/<id>` → the `--building` form `resolveBuilding` takes; home → undefined. */
|
|
95
|
+
function buildingFor(address) {
|
|
96
|
+
if (!address || !address.includes('/'))
|
|
97
|
+
return undefined;
|
|
98
|
+
const i = address.indexOf('/');
|
|
99
|
+
return `${address.slice(0, i)}:${address.slice(i + 1)}`;
|
|
100
|
+
}
|
|
101
|
+
/** Workspace DOs hold agent work sessions; a store or a local app does not. */
|
|
102
|
+
function holdsWorkSessions(address) {
|
|
103
|
+
return !address.includes('/') || /^(team|workspace)\//.test(address);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Run an action in a given workspace (AWO-394) — the session lives in the workspace it works in,
|
|
107
|
+
* not in whichever one the daemon happens to dial. Home goes direct; others through the proxy.
|
|
108
|
+
*/
|
|
109
|
+
async function rpcIn(address, action, input, timeout) {
|
|
110
|
+
const { resolveBuilding } = await import('./workspace.js');
|
|
111
|
+
const routed = resolveBuilding(action, input, buildingFor(address));
|
|
112
|
+
return rpcOnce(routed.action, routed.input, timeout);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* "hive2 (workspace/98bef9…)" rather than a bare id — what an agent reads to know where it is.
|
|
116
|
+
* Best-effort: the account roster (`workspace:list`), falling back to the address itself.
|
|
117
|
+
*/
|
|
118
|
+
async function workspaceLabel(address) {
|
|
119
|
+
const { describeAddress } = await import('../session-binding.js');
|
|
120
|
+
const plain = describeAddress(address);
|
|
121
|
+
if (address.includes('/') && !/^(team|workspace)\//.test(address))
|
|
122
|
+
return plain;
|
|
123
|
+
try {
|
|
124
|
+
const { daemonRpc } = await import('../daemon-client.js');
|
|
125
|
+
const res = await Promise.race([daemonRpc('workspace:list', {}), new Promise((r) => setTimeout(() => r(null), 4_000))]);
|
|
126
|
+
const inner = res?.result ?? res;
|
|
127
|
+
const id = address.includes('/') ? address.slice(address.indexOf('/') + 1) : String(inner?.current ?? '');
|
|
128
|
+
const hit = (inner?.workspaces ?? []).find((w) => String(w?.id ?? '') === id);
|
|
129
|
+
if (hit?.name)
|
|
130
|
+
return address.includes('/') ? `${hit.name} (${plain})` : `${hit.name} — home (your default workspace)`;
|
|
131
|
+
}
|
|
132
|
+
catch { /* the roster is a nicety */ }
|
|
133
|
+
return plain;
|
|
134
|
+
}
|
|
135
|
+
/** End one work session where it lives. Best-effort, like `endWorkspaceSession`. */
|
|
136
|
+
async function endSessionIn(address, sessionId, agentId, summary) {
|
|
137
|
+
try {
|
|
138
|
+
const r = await rpcIn(address, 'agent-session-end', {
|
|
139
|
+
id: sessionId, agentId, ...(summary?.trim() ? { summary: summary.trim() } : {}),
|
|
140
|
+
}, 5_000);
|
|
141
|
+
const result = r?.result ?? r;
|
|
142
|
+
return result?.ok !== false && !result?.error;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
94
148
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
95
149
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
96
150
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -701,7 +755,38 @@ async function agentLogin(args, values) {
|
|
|
701
755
|
* actually began. `existingRecord` is read before the write for exactly this.
|
|
702
756
|
*/
|
|
703
757
|
const birthday = existingRecord?.birthday ?? new Date().toISOString();
|
|
704
|
-
|
|
758
|
+
/**
|
|
759
|
+
* ⚠⚠ ONE SESSION = THIS PROCESS SCOPE (AWO-394). A re-login replaces the session THIS scope had,
|
|
760
|
+
* and nothing else: it used to end `record.sessionId` — one field shared by every terminal using
|
|
761
|
+
* the name — so logging in from a second terminal closed the first terminal's session and both
|
|
762
|
+
* then wrote under the new one.
|
|
763
|
+
*/
|
|
764
|
+
const { deriveAgentScope } = await import('../agent-scope.js');
|
|
765
|
+
const { readSessionBinding, writeSessionBinding, currentEnvName, normalizeWorkspaceAddress, describeAddress, } = await import('../session-binding.js');
|
|
766
|
+
const { writeTargetForScope, readTargetForKey } = await import('../cli-state.js');
|
|
767
|
+
const scope = deriveAgentScope();
|
|
768
|
+
const priorBinding = scope ? readSessionBinding(scope) : null;
|
|
769
|
+
if (priorBinding?.sessionId)
|
|
770
|
+
await endSessionIn(priorBinding.workspace, priorBinding.sessionId, minted.agentId);
|
|
771
|
+
/**
|
|
772
|
+
* WHERE THIS SESSION WORKS: `--workspace`, else where this identity last worked, else home. The
|
|
773
|
+
* identity's last workspace is what keeps a durable agent from losing its place each conversation
|
|
774
|
+
* — the reason the cursor used to be keyed on the agent name — without letting one live session
|
|
775
|
+
* move another.
|
|
776
|
+
*/
|
|
777
|
+
const requestedWorkspace = typeof values.workspace === 'string' ? values.workspace.trim() : '';
|
|
778
|
+
const normalizedRequest = requestedWorkspace ? normalizeWorkspaceAddress(requestedWorkspace) : null;
|
|
779
|
+
if (requestedWorkspace && !normalizedRequest) {
|
|
780
|
+
console.error(` Unrecognised workspace "${requestedWorkspace}".`);
|
|
781
|
+
console.error(' Use: home · team/<id> · workspace/<id> · a 64-character workspace id (beehaven workspaces lists them)');
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
const legacyCursor = readTargetForKey(name)?.address;
|
|
785
|
+
const chosenWorkspace = normalizedRequest
|
|
786
|
+
|| existingRecord?.lastWorkspace
|
|
787
|
+
|| (legacyCursor ? normalizeWorkspaceAddress(legacyCursor) : null)
|
|
788
|
+
|| 'workspace';
|
|
789
|
+
const sessionEnv = currentEnvName();
|
|
705
790
|
/**
|
|
706
791
|
* ⚠⚠ FILL THE LIFETIME FIELDS FROM THE TOKEN — the cloud mint path does not
|
|
707
792
|
* return them and a record without `expiresAt` can never be seen as expired.
|
|
@@ -740,8 +825,9 @@ async function agentLogin(args, values) {
|
|
|
740
825
|
* resuming is least recoverable.
|
|
741
826
|
*/
|
|
742
827
|
...(existingRecord?.boundScopes ? { boundScopes: existingRecord.boundScopes } : {}),
|
|
743
|
-
...(
|
|
828
|
+
...(existingRecord?.sessionId ? { sessionId: existingRecord.sessionId } : {}),
|
|
744
829
|
...(existingRecord?.lastLoginAt ? { lastLoginAt: existingRecord.lastLoginAt } : {}),
|
|
830
|
+
lastWorkspace: chosenWorkspace,
|
|
745
831
|
};
|
|
746
832
|
saveAgent(name, record);
|
|
747
833
|
/**
|
|
@@ -754,10 +840,14 @@ async function agentLogin(args, values) {
|
|
|
754
840
|
* instead of silently replaced. The family (what un-logged-in sessions inherit) is claimed
|
|
755
841
|
* below, only once a session actually starts.
|
|
756
842
|
*/
|
|
757
|
-
const { deriveAgentScope } = await import('../agent-scope.js');
|
|
758
843
|
const { bindAgentScope, agentForScope, loginSessionOutcome } = await import('../agent-state.js');
|
|
759
|
-
const scope = deriveAgentScope();
|
|
760
844
|
bindAgentScope(name, scope, { family: false });
|
|
845
|
+
// The session record: identity + workspace + env, owned by this scope alone. Written before the
|
|
846
|
+
// workspace answers for the same reason the binding is — a refused login must still own its scope.
|
|
847
|
+
if (scope) {
|
|
848
|
+
writeSessionBinding({ scope, agent: name, workspace: chosenWorkspace, env: sessionEnv, boundAt: Date.now() });
|
|
849
|
+
writeTargetForScope(scope, chosenWorkspace, chosenWorkspace === 'workspace' ? 'cloud' : undefined);
|
|
850
|
+
}
|
|
761
851
|
// Sync to Electron store if present
|
|
762
852
|
try {
|
|
763
853
|
const p = join(homedir(), 'Library', 'Application Support', 'clearly-desktop', 'config.json');
|
|
@@ -821,18 +911,36 @@ async function agentLogin(args, values) {
|
|
|
821
911
|
// `loginSessionOutcome` — and is reported, never swallowed.
|
|
822
912
|
let started;
|
|
823
913
|
let thrown;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
914
|
+
const sessionInput = {
|
|
915
|
+
agentId: record.agentId,
|
|
916
|
+
client: String(values.client ?? 'cli').trim() || 'cli',
|
|
917
|
+
label: String(values.label ?? '').trim() || undefined,
|
|
918
|
+
expiresAt: record.expiresAt,
|
|
919
|
+
};
|
|
920
|
+
if (holdsWorkSessions(chosenWorkspace)) {
|
|
921
|
+
try {
|
|
922
|
+
// ⚠ IN THE SESSION'S WORKSPACE (AWO-394) — it used to open in the daemon's default
|
|
923
|
+
// workspace whatever the cursor said, and that id then rode on calls to other workspaces.
|
|
924
|
+
started = await rpcIn(chosenWorkspace, 'agent-session-start', sessionInput, 15_000);
|
|
925
|
+
const first = loginSessionOutcome(started);
|
|
926
|
+
// An identity is the ACCOUNT's; each workspace admits it once. Same self-heal `call` does.
|
|
927
|
+
if (first.kind === 'refused' && chosenWorkspace !== 'workspace' && /unknown agent|not admitted|unknown-agent/i.test(`${first.scope} ${first.reason}`)) {
|
|
928
|
+
const admitted = await rpcIn(chosenWorkspace, 'agent-admit', { agentId: record.agentId }, 20_000).catch(() => null);
|
|
929
|
+
if ((admitted?.result ?? admitted)?.ok)
|
|
930
|
+
started = await rpcIn(chosenWorkspace, 'agent-session-start', sessionInput, 15_000);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
thrown = err ?? new Error('agent-session-start failed');
|
|
935
|
+
}
|
|
831
936
|
}
|
|
832
|
-
|
|
833
|
-
|
|
937
|
+
const outcome = holdsWorkSessions(chosenWorkspace)
|
|
938
|
+
? loginSessionOutcome(started, thrown)
|
|
939
|
+
: { kind: 'unreachable', reason: 'a store keeps no agent work sessions' };
|
|
940
|
+
if (outcome.kind === 'started' && scope) {
|
|
941
|
+
const { updateSessionBinding } = await import('../session-binding.js');
|
|
942
|
+
updateSessionBinding(scope, { sessionId: outcome.sessionId });
|
|
834
943
|
}
|
|
835
|
-
const outcome = loginSessionOutcome(started, thrown);
|
|
836
944
|
if (outcome.kind === 'started') {
|
|
837
945
|
saveAgentSession(name, outcome.sessionId);
|
|
838
946
|
/**
|
|
@@ -876,8 +984,11 @@ async function agentLogin(args, values) {
|
|
|
876
984
|
if (scope) {
|
|
877
985
|
const resolved = agentForScope(scope);
|
|
878
986
|
if (resolved === name) {
|
|
879
|
-
console.log(`
|
|
880
|
-
console.log(dim('
|
|
987
|
+
console.log(` This session ${cyan(scope)}`);
|
|
988
|
+
console.log(` Agent ${cyan(name)}${dim(' every beehaven command here runs as this agent — nothing to pass per call')}`);
|
|
989
|
+
console.log(` Workspace ${cyan(await workspaceLabel(chosenWorkspace))}${dim(' switch: beehaven connect <workspace>')}`);
|
|
990
|
+
console.log(` Env ${cyan(sessionEnv)}${dim(' calls are refused if the daemon moves to another env')}`);
|
|
991
|
+
console.log(dim(' Other sessions and terminal tabs keep their own agent and workspace.'));
|
|
881
992
|
}
|
|
882
993
|
else {
|
|
883
994
|
console.error(` ${yellow('⚠')} Could not bind this session (${scope}) to ${name}; calls resolve to ${resolved ?? 'nobody'}.`);
|
|
@@ -886,9 +997,10 @@ async function agentLogin(args, values) {
|
|
|
886
997
|
}
|
|
887
998
|
}
|
|
888
999
|
else {
|
|
889
|
-
|
|
1000
|
+
// No per-session handle: a terminal that does not say which tab it is.
|
|
1001
|
+
console.log(' This terminal has no session id, so the sign-in is shared by every such terminal.');
|
|
1002
|
+
console.log(' To keep it to THIS terminal:');
|
|
890
1003
|
console.log(` ${cyan(`eval "$(beehaven agent use ${name})"`)}`);
|
|
891
|
-
console.log(dim(` or: export BEEHAVEN_AGENT=${name}`));
|
|
892
1004
|
}
|
|
893
1005
|
}
|
|
894
1006
|
console.log('');
|
|
@@ -1159,6 +1271,14 @@ function agentWhoami(values) {
|
|
|
1159
1271
|
console.log(` birthday: ${r.birthday ? formatBirthday(r.birthday) : 'unknown (created before birthdays)'}`);
|
|
1160
1272
|
console.log(` voice: ${r.voice?.provider ? (r.voice.voiceId ? `${r.voice.provider}:${r.voice.voiceId}` : `${r.voice.provider} (base voice)`) : 'unbound — speak picks whatever is reachable'}`);
|
|
1161
1273
|
console.log(` expires: ${formatRemaining(r.expiresAt)}`);
|
|
1274
|
+
if (active.session) {
|
|
1275
|
+
console.log(` session: ${active.session.scope} (work session ${active.session.sessionId ? active.session.sessionId.slice(0, 8) : 'none'})`);
|
|
1276
|
+
console.log(` workspace: ${active.session.workspace}`);
|
|
1277
|
+
console.log(` env: ${active.session.env}`);
|
|
1278
|
+
}
|
|
1279
|
+
else {
|
|
1280
|
+
console.log(dim(' session: none bound here — `beehaven agent login ' + active.name + '` binds this session'));
|
|
1281
|
+
}
|
|
1162
1282
|
if (active.expired)
|
|
1163
1283
|
console.log(dim(` refresh: beehaven agent login ${active.name}`));
|
|
1164
1284
|
console.log('');
|
|
@@ -1184,6 +1304,53 @@ async function agentStatus(args, values) {
|
|
|
1184
1304
|
}
|
|
1185
1305
|
console.log(` ${green('✓')} ${cyan(name)} status: ${String(result.message ?? message)}`);
|
|
1186
1306
|
}
|
|
1307
|
+
// ── connect, for a signed-in session ─────────────────
|
|
1308
|
+
/**
|
|
1309
|
+
* `beehaven connect <address>` in a signed-in session (AWO-394): move THIS session — its cursor,
|
|
1310
|
+
* its work session and its binding — and nothing else. The cursor file is already this session's;
|
|
1311
|
+
* this moves the work session to the workspace it now works in (a session lives in one workspace's
|
|
1312
|
+
* DO, and its id used to ride on calls to others), and remembers the workspace on the identity so
|
|
1313
|
+
* the next login starts there.
|
|
1314
|
+
*/
|
|
1315
|
+
export async function moveAgentSession(address) {
|
|
1316
|
+
const { activeSessionBinding } = await import('../agent-state.js');
|
|
1317
|
+
const { updateSessionBinding, describeAddress } = await import('../session-binding.js');
|
|
1318
|
+
const bound = activeSessionBinding();
|
|
1319
|
+
if (!bound)
|
|
1320
|
+
return;
|
|
1321
|
+
const record = loadAgent(bound.agent);
|
|
1322
|
+
if (!record)
|
|
1323
|
+
return;
|
|
1324
|
+
if (bound.workspace === address)
|
|
1325
|
+
return;
|
|
1326
|
+
if (bound.sessionId)
|
|
1327
|
+
await endSessionIn(bound.workspace, bound.sessionId, record.agentId);
|
|
1328
|
+
let sessionId;
|
|
1329
|
+
if (holdsWorkSessions(address)) {
|
|
1330
|
+
const input = { agentId: record.agentId, client: 'cli', expiresAt: record.expiresAt };
|
|
1331
|
+
const { loginSessionOutcome } = await import('../agent-state.js');
|
|
1332
|
+
try {
|
|
1333
|
+
let r = await rpcIn(address, 'agent-session-start', input, 15_000);
|
|
1334
|
+
let o = loginSessionOutcome(r);
|
|
1335
|
+
if (o.kind === 'refused' && address !== 'workspace' && /unknown agent|not admitted|unknown-agent/i.test(`${o.scope} ${o.reason}`)) {
|
|
1336
|
+
const admitted = await rpcIn(address, 'agent-admit', { agentId: record.agentId }, 20_000).catch(() => null);
|
|
1337
|
+
if ((admitted?.result ?? admitted)?.ok) {
|
|
1338
|
+
console.error(dim(` ${bound.agent} joined this workspace.`));
|
|
1339
|
+
r = await rpcIn(address, 'agent-session-start', input, 15_000);
|
|
1340
|
+
o = loginSessionOutcome(r);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (o.kind === 'started')
|
|
1344
|
+
sessionId = o.sessionId;
|
|
1345
|
+
else if (o.kind === 'refused')
|
|
1346
|
+
console.error(` ${yellow('⚠')} ${bound.agent} has no work session here: ${o.reason}`);
|
|
1347
|
+
}
|
|
1348
|
+
catch { /* unreachable — calls still route; the next login opens a session */ }
|
|
1349
|
+
}
|
|
1350
|
+
updateSessionBinding(bound.scope, { workspace: address, sessionId });
|
|
1351
|
+
saveAgent(bound.agent, { ...record, lastWorkspace: address });
|
|
1352
|
+
console.log(dim(` Session ${bound.scope} (${bound.agent}) now works in ${await workspaceLabel(address)}${sessionId ? ` · work session ${sessionId.slice(0, 8)}` : ''}. Other sessions are unaffected.`));
|
|
1353
|
+
}
|
|
1187
1354
|
// ── handoff ──────────────────────────────────────────
|
|
1188
1355
|
/**
|
|
1189
1356
|
* End the current work session WITH a note — the one thing the next login (this agent's, or the
|
|
@@ -1203,6 +1370,22 @@ async function agentHandoff(args, values) {
|
|
|
1203
1370
|
process.exit(1);
|
|
1204
1371
|
}
|
|
1205
1372
|
const record = loadAgent(name);
|
|
1373
|
+
const { activeSessionBinding } = await import('../agent-state.js');
|
|
1374
|
+
const bound = activeSessionBinding(name);
|
|
1375
|
+
if (bound?.sessionId && record) {
|
|
1376
|
+
// AWO-394 — hand off THIS session, where it lives; other sessions of the name are untouched.
|
|
1377
|
+
if (!await endSessionIn(bound.workspace, bound.sessionId, record.agentId, summary)) {
|
|
1378
|
+
console.error(` ${yellow('⚠')} The workspace did not accept the handoff; the session is still open.`);
|
|
1379
|
+
process.exitCode = 1;
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const { updateSessionBinding } = await import('../session-binding.js');
|
|
1383
|
+
updateSessionBinding(bound.scope, { sessionId: undefined });
|
|
1384
|
+
if (record.sessionId === bound.sessionId)
|
|
1385
|
+
clearAgentSessionId(name);
|
|
1386
|
+
console.log(` ${green('✓')} Handed off session ${cyan(bound.sessionId.slice(0, 8))} — the next login for ${cyan(name)} reads this first.`);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1206
1389
|
if (!record?.sessionId) {
|
|
1207
1390
|
console.error(` ${yellow('⚠')} ${cyan(name)} has no open work session on this device.`);
|
|
1208
1391
|
console.error(dim(` Start one with: beehaven agent login ${name} --label "<work>" --client cli`));
|
|
@@ -1236,18 +1419,42 @@ async function agentLogout(args, values) {
|
|
|
1236
1419
|
// it stays in the workspace (source of truth) AND on this device, so you can
|
|
1237
1420
|
// log back in. To remove the current workspace projection use `agent delete <name>`;
|
|
1238
1421
|
// to remove the portable account identity use `agent delete <name> --account --yes`.
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1422
|
+
/**
|
|
1423
|
+
* ⚠ AWO-394 — signing out ends THIS session (its binding, its work session, its cursor) and
|
|
1424
|
+
* leaves every other session signed in as the same name running. It used to clear the shared
|
|
1425
|
+
* record's session, which locked every other terminal using the name out at once.
|
|
1426
|
+
*/
|
|
1427
|
+
const { activeSessionBinding, unbindAgentScope } = await import('../agent-state.js');
|
|
1428
|
+
const { clearSessionBinding, listSessionBindings } = await import('../session-binding.js');
|
|
1429
|
+
const bound = activeSessionBinding(name);
|
|
1430
|
+
if (bound) {
|
|
1431
|
+
if (bound.sessionId)
|
|
1432
|
+
await endSessionIn(bound.workspace, bound.sessionId, record.agentId);
|
|
1433
|
+
clearSessionBinding(bound.scope);
|
|
1434
|
+
// …and this scope stops resolving to the name, or the next call here would still act as it.
|
|
1435
|
+
unbindAgentScope(name, bound.scope);
|
|
1436
|
+
if (record.sessionId && record.sessionId === bound.sessionId)
|
|
1437
|
+
clearAgentSessionId(name);
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
await endWorkspaceSession(record);
|
|
1441
|
+
clearAgentSession(name);
|
|
1442
|
+
}
|
|
1443
|
+
const others = listSessionBindings().filter((b) => b.agent === name);
|
|
1444
|
+
if (!others.length) {
|
|
1445
|
+
try {
|
|
1446
|
+
await rpcOnce('agent:disconnect', { agentId: record.agentId ?? name }, 5_000);
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
/* daemon down — nothing to close */
|
|
1450
|
+
}
|
|
1245
1451
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1452
|
+
console.log(` ${green('✓')} Signed out agent "${name}" ${bound ? 'in this session' : ''} — session ended, identity kept.`);
|
|
1453
|
+
if (others.length)
|
|
1454
|
+
console.log(dim(` ${others.length} other session${others.length === 1 ? ' is' : 's are'} still signed in as ${name}.`));
|
|
1248
1455
|
console.log(dim(` Log back in: beehaven agent login ${name} · workspace remove: beehaven agent delete ${name}`));
|
|
1249
|
-
if (
|
|
1250
|
-
console.log(dim(`
|
|
1456
|
+
if (process.env.BEEHAVEN_AGENT === name && !values.agent) {
|
|
1457
|
+
console.log(dim(` BEEHAVEN_AGENT still names ${name} in this shell — run: unset BEEHAVEN_AGENT`));
|
|
1251
1458
|
}
|
|
1252
1459
|
}
|
|
1253
1460
|
// ── delete (destructive — removes the identity from the workspace + device) ──
|
|
@@ -8,17 +8,81 @@
|
|
|
8
8
|
* is its own command the person (or their agent, asked first) runs, not a side effect of install.
|
|
9
9
|
*
|
|
10
10
|
* ⚠ THE MARKERS ARE THE UPDATE MECHANISM, as on every other surface (lib/connections/agentsMd.ts):
|
|
11
|
-
* re-running replaces only what lies between our markers and leaves the rest byte for byte.
|
|
12
|
-
*
|
|
11
|
+
* re-running replaces only what lies between our markers and leaves the rest byte for byte.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ ONE TRACKED BLOCK FOR BOTH ROUTES (AWO-401). The block names the MCP tool and the CLI command
|
|
14
|
+
* for each step, so `--mcp` is kept only so older instructions still work; it writes the same block.
|
|
15
|
+
* Writing it removes the per-route blocks earlier versions wrote (`LEGACY_BLOCK_MARKERS`), so a repo
|
|
16
|
+
* that had both converges on one.
|
|
13
17
|
*
|
|
14
18
|
* ⚠ AGENTS.md IS THE FILE; CLAUDE.md POINTS AT IT with one `@AGENTS.md` line — one copy to keep
|
|
15
19
|
* current. An existing CLAUDE.md gains the line only if it lacks it; nothing else in it is touched.
|
|
20
|
+
* The project's `.claude/settings.json` gains `CLAUDE_BASH_RULE` the same way (AWO-401).
|
|
16
21
|
*/
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
18
23
|
import { join, resolve } from 'path';
|
|
19
24
|
import { execFileSync } from 'child_process';
|
|
20
25
|
import { color } from '../spinner.js';
|
|
21
|
-
import {
|
|
26
|
+
import { AGENTS_MD_START, AGENTS_MD_END, LEGACY_BLOCK_MARKERS, CLAUDE_MD_IMPORT, agentsMdBlock } from '../agents-md-text.js';
|
|
27
|
+
/**
|
|
28
|
+
* The Claude Code permission that lets an agent run `beehaven` without a prompt per call (AWO-401).
|
|
29
|
+
* The tracked block makes several calls a task; a prompt on each one pushes people to turn tracking
|
|
30
|
+
* off. Written to the PROJECT's `.claude/settings.json` (committed with AGENTS.md, so it is the
|
|
31
|
+
* team's rule, not a change to anyone's user-wide settings). Claude Code matches each part of a
|
|
32
|
+
* chained command separately, so the rule never approves `beehaven … && something-else`.
|
|
33
|
+
*/
|
|
34
|
+
export const CLAUDE_BASH_RULE = 'Bash(beehaven:*)';
|
|
35
|
+
/**
|
|
36
|
+
* Settings text with the rule added (or removed). `null` text means no file. A file that is not a
|
|
37
|
+
* JSON object is left alone (`skipped`) — never rewritten from a guess.
|
|
38
|
+
*/
|
|
39
|
+
export function editClaudeSettings(text, remove = false) {
|
|
40
|
+
if (text === null) {
|
|
41
|
+
if (remove)
|
|
42
|
+
return { next: null, change: 'absent' };
|
|
43
|
+
return { next: JSON.stringify({ permissions: { allow: [CLAUDE_BASH_RULE] } }, null, 2) + '\n', change: 'created' };
|
|
44
|
+
}
|
|
45
|
+
let data;
|
|
46
|
+
try {
|
|
47
|
+
data = JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { next: text, change: 'skipped' };
|
|
51
|
+
}
|
|
52
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
53
|
+
return { next: text, change: 'skipped' };
|
|
54
|
+
const perms = data.permissions && typeof data.permissions === 'object' && !Array.isArray(data.permissions) ? data.permissions : null;
|
|
55
|
+
const allow = perms && Array.isArray(perms.allow) ? perms.allow : [];
|
|
56
|
+
if (remove) {
|
|
57
|
+
if (!allow.includes(CLAUDE_BASH_RULE))
|
|
58
|
+
return { next: text, change: 'absent' };
|
|
59
|
+
perms.allow = allow.filter((r) => r !== CLAUDE_BASH_RULE);
|
|
60
|
+
if (!perms.allow.length)
|
|
61
|
+
delete perms.allow;
|
|
62
|
+
if (!Object.keys(perms).length)
|
|
63
|
+
delete data.permissions;
|
|
64
|
+
return { next: Object.keys(data).length ? JSON.stringify(data, null, 2) + '\n' : null, change: 'removed' };
|
|
65
|
+
}
|
|
66
|
+
if (allow.includes(CLAUDE_BASH_RULE))
|
|
67
|
+
return { next: text, change: 'current' };
|
|
68
|
+
if (perms && perms.allow !== undefined && !Array.isArray(perms.allow))
|
|
69
|
+
return { next: text, change: 'skipped' };
|
|
70
|
+
data.permissions = { ...(perms ?? {}), allow: [...allow, CLAUDE_BASH_RULE] };
|
|
71
|
+
return { next: JSON.stringify(data, null, 2) + '\n', change: 'updated' };
|
|
72
|
+
}
|
|
73
|
+
/** Apply `editClaudeSettings` to `<root>/.claude/settings.json`. */
|
|
74
|
+
function writeClaudeSettings(root, remove) {
|
|
75
|
+
const path = join(root, '.claude', 'settings.json');
|
|
76
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
77
|
+
const { next, change } = editClaudeSettings(before, remove);
|
|
78
|
+
if (next === null && before !== null)
|
|
79
|
+
rmSync(path);
|
|
80
|
+
else if (next !== null && next !== before) {
|
|
81
|
+
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
82
|
+
writeFileSync(path, next);
|
|
83
|
+
}
|
|
84
|
+
return { path, change };
|
|
85
|
+
}
|
|
22
86
|
/** Our block replaced between its markers, or appended after one blank line. Keeps CRLF files CRLF. */
|
|
23
87
|
export function spliceBlock(text, block, start, end) {
|
|
24
88
|
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
@@ -57,22 +121,42 @@ export function projectRoot(dir) {
|
|
|
57
121
|
}
|
|
58
122
|
}
|
|
59
123
|
/** Write (or remove) the block in `<root>/AGENTS.md` and make CLAUDE.md import it. */
|
|
124
|
+
/** The text with every per-route block from before AWO-401 taken out. */
|
|
125
|
+
export function removeLegacyBlocks(text) {
|
|
126
|
+
return LEGACY_BLOCK_MARKERS.reduce((t, [start, end]) => removeBlock(t, start, end), text);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The block written into `text`: in place of our current block, else in place of the first legacy
|
|
130
|
+
* block (so a migrated repo keeps its layout), else appended. Any other legacy block is removed.
|
|
131
|
+
*/
|
|
132
|
+
export function upsertAgentsMdBlock(text, block) {
|
|
133
|
+
if (text.includes(AGENTS_MD_START))
|
|
134
|
+
return spliceBlock(removeLegacyBlocks(text), block, AGENTS_MD_START, AGENTS_MD_END);
|
|
135
|
+
const legacy = LEGACY_BLOCK_MARKERS.find(([start, end]) => {
|
|
136
|
+
const i = text.indexOf(start);
|
|
137
|
+
return i >= 0 && text.indexOf(end, i) >= 0;
|
|
138
|
+
});
|
|
139
|
+
if (legacy)
|
|
140
|
+
return removeLegacyBlocks(spliceBlock(text, block, legacy[0], legacy[1]));
|
|
141
|
+
return spliceBlock(text, block, AGENTS_MD_START, AGENTS_MD_END);
|
|
142
|
+
}
|
|
143
|
+
/** `mcp` is accepted for older instructions and changes nothing: there is one block (AWO-401). */
|
|
60
144
|
export function writeProjectAgentsMd(opts) {
|
|
61
145
|
const root = projectRoot(resolve(opts.dir));
|
|
62
|
-
const [start, end] = opts.mcp ? [MCP_BLOCK_START, MCP_BLOCK_END] : [CLI_BLOCK_START, CLI_BLOCK_END];
|
|
63
146
|
const agentsPath = join(root, 'AGENTS.md');
|
|
64
147
|
const claudePath = join(root, 'CLAUDE.md');
|
|
65
148
|
const files = [];
|
|
66
149
|
const had = existsSync(agentsPath);
|
|
67
150
|
const before = had ? readFileSync(agentsPath, 'utf8') : '';
|
|
68
151
|
if (opts.remove) {
|
|
69
|
-
const next = removeBlock(before,
|
|
152
|
+
const next = removeLegacyBlocks(removeBlock(before, AGENTS_MD_START, AGENTS_MD_END));
|
|
70
153
|
if (next !== before)
|
|
71
154
|
writeFileSync(agentsPath, next);
|
|
72
155
|
files.push({ path: agentsPath, change: next !== before ? 'removed' : 'absent' });
|
|
156
|
+
files.push(writeClaudeSettings(root, true));
|
|
73
157
|
return { root, files };
|
|
74
158
|
}
|
|
75
|
-
const next =
|
|
159
|
+
const next = upsertAgentsMdBlock(before, agentsMdBlock());
|
|
76
160
|
if (next !== before)
|
|
77
161
|
writeFileSync(agentsPath, next);
|
|
78
162
|
files.push({ path: agentsPath, change: !had ? 'created' : next !== before ? 'updated' : 'current' });
|
|
@@ -89,11 +173,12 @@ export function writeProjectAgentsMd(opts) {
|
|
|
89
173
|
else {
|
|
90
174
|
files.push({ path: claudePath, change: 'current' });
|
|
91
175
|
}
|
|
176
|
+
files.push(writeClaudeSettings(root, false));
|
|
92
177
|
return { root, files };
|
|
93
178
|
}
|
|
94
179
|
export function cmdAgentsMd(dir, opts) {
|
|
95
180
|
if (opts.print) {
|
|
96
|
-
process.stdout.write((
|
|
181
|
+
process.stdout.write(agentsMdBlock() + '\n');
|
|
97
182
|
return 0;
|
|
98
183
|
}
|
|
99
184
|
let r;
|
|
@@ -106,10 +191,11 @@ export function cmdAgentsMd(dir, opts) {
|
|
|
106
191
|
}
|
|
107
192
|
console.log('');
|
|
108
193
|
for (const f of r.files) {
|
|
109
|
-
const mark = f.change === 'current' || f.change === 'absent' ? color.dim('·') : color.green('✓');
|
|
110
|
-
|
|
194
|
+
const mark = f.change === 'current' || f.change === 'absent' ? color.dim('·') : f.change === 'skipped' ? color.yellow('!') : color.green('✓');
|
|
195
|
+
const note = f.change === 'skipped' ? ` — not valid JSON, so left unchanged; add "${CLAUDE_BASH_RULE}" to permissions.allow yourself` : '';
|
|
196
|
+
console.log(` ${mark} ${f.path} ${color.dim(f.change)}${note}`);
|
|
111
197
|
}
|
|
112
|
-
const changed = r.files.some((f) => f.change !== 'current' && f.change !== 'absent');
|
|
198
|
+
const changed = r.files.some((f) => f.change !== 'current' && f.change !== 'absent' && f.change !== 'skipped');
|
|
113
199
|
if (changed && !opts.remove) {
|
|
114
200
|
console.log(color.dim(`\n Commit ${r.files.map((f) => f.path.slice(r.root.length + 1)).join(' and ')} so every agent that opens this project knows the workspace.`));
|
|
115
201
|
}
|
package/dist/commands/help.js
CHANGED
|
@@ -414,23 +414,26 @@ const PAGES = {
|
|
|
414
414
|
related: ['login', 'guide'],
|
|
415
415
|
},
|
|
416
416
|
'agents-md': {
|
|
417
|
-
synopsis: 'beehaven agents-md [dir] [--
|
|
417
|
+
synopsis: 'beehaven agents-md [dir] [--print] [--remove]',
|
|
418
418
|
description: `Add the Clearly block to this project's AGENTS.md, so every agent that opens the
|
|
419
|
-
repository — a teammate's Codex or Claude Code, a cloud agent, CI —
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
419
|
+
repository — a teammate's Codex or Claude Code, a cloud agent, CI — tracks its work in the
|
|
420
|
+
workspace: sign in, a ticket for every task, a handoff at the end. One block serves agents on
|
|
421
|
+
the CLI and over MCP. Writes at the git root (or the directory given), and makes CLAUDE.md
|
|
422
|
+
import it with one \`@AGENTS.md\` line, and lets Claude Code run \`beehaven\` without a prompt per
|
|
423
|
+
call (\`Bash(beehaven:*)\` in the project's .claude/settings.json). Needs no login.
|
|
424
|
+
|
|
425
|
+
The block sits between Clearly's markers: run it again to update it, and everything else in
|
|
426
|
+
the file stays as it was. Older per-route Clearly blocks are replaced. \`beehaven login\`
|
|
427
|
+
already points the agents on THIS machine at Clearly (their global instruction files); this is
|
|
428
|
+
the shared, committed copy for everyone who opens the repository.`,
|
|
426
429
|
flags: [
|
|
427
|
-
{ name: '--mcp', desc: 'Write the block for agents connected over MCP instead of the CLI' },
|
|
428
430
|
{ name: '--print', desc: 'Print the block and write nothing' },
|
|
429
431
|
{ name: '--remove', desc: 'Take the block out again' },
|
|
432
|
+
{ name: '--mcp', desc: 'Accepted for older instructions; the block is the same for MCP and the CLI' },
|
|
430
433
|
],
|
|
431
434
|
examples: [
|
|
432
|
-
{ cmd: 'beehaven agents-md', note: 'AGENTS.md
|
|
433
|
-
{ cmd: 'beehaven agents-md --
|
|
435
|
+
{ cmd: 'beehaven agents-md', note: 'AGENTS.md, CLAUDE.md and .claude/settings.json at the repository root' },
|
|
436
|
+
{ cmd: 'beehaven agents-md --print', note: 'see the block without writing it' },
|
|
434
437
|
],
|
|
435
438
|
related: ['skills', 'login'],
|
|
436
439
|
},
|
|
@@ -725,8 +725,18 @@ export { activeAgentWrap, setCliDoor, cliDoor, alreadyAttributed, BOOTSTRAP_ACTI
|
|
|
725
725
|
* ambient identity and is correct for every verb that does not offer that flag.
|
|
726
726
|
*/
|
|
727
727
|
export async function agentRpc(action, input, timeout, values = {}) {
|
|
728
|
-
|
|
729
|
-
|
|
728
|
+
/**
|
|
729
|
+
* ⚠⚠ THE TYPED VERBS FOLLOW THE SESSION'S WORKSPACE, LIKE `call` (AWO-393). This sent a bare
|
|
730
|
+
* `rpcOnce` to the daemon's default workspace: after `connect team/<id>`, `beehaven call
|
|
731
|
+
* ticket-create` filed in the team and `beehaven ticket create` filed at home. Same resolver,
|
|
732
|
+
* same order as `cmdCall` — wrap as the agent first, then the building envelope around it.
|
|
733
|
+
*/
|
|
734
|
+
const routed = { ...values };
|
|
735
|
+
const { resolveTargetBuilding } = await import('../session-target.js');
|
|
736
|
+
await resolveTargetBuilding(routed);
|
|
737
|
+
const wrapped = activeAgentWrap(action, input, routed);
|
|
738
|
+
const resolved = resolveBuilding(wrapped.action, wrapped.input, routed.building);
|
|
739
|
+
return rpcOnce(resolved.action, resolved.input, timeout);
|
|
730
740
|
}
|
|
731
741
|
/**
|
|
732
742
|
* Does this result payload represent a failure? Mirrors `isFailurePayload` in the worker's
|
|
@@ -1260,7 +1270,13 @@ function printCallErrorHints(msg, action, values) {
|
|
|
1260
1270
|
if (!values.json) {
|
|
1261
1271
|
console.error('');
|
|
1262
1272
|
console.error('\x1b[2m Try:\x1b[0m');
|
|
1263
|
-
if (
|
|
1273
|
+
if (/admin:(store|team|workspace)-rpc/.test(s) && s.includes('not allowed')) {
|
|
1274
|
+
// A store/team target is reached through an admin:*-rpc proxy, which the workspace refuses
|
|
1275
|
+
// to members over the daemon. Platform admins have the HTTP door instead (PPO-444).
|
|
1276
|
+
console.error(`\x1b[2m beehaven admin do shopifyStore/<id> ${action} '<json>' # platform admins: the operator door\x1b[0m`);
|
|
1277
|
+
console.error(`\x1b[2m beehaven admin dashboard shopifyStore/<id> # state, logs, snapshots\x1b[0m`);
|
|
1278
|
+
}
|
|
1279
|
+
else if (s.includes('unknown action') || s.includes('404')) {
|
|
1264
1280
|
console.error(`\x1b[2m beehaven actions --search <name> # find the mounted action\x1b[0m`);
|
|
1265
1281
|
console.error(`\x1b[2m beehaven pwd # confirm you\'re on the right DO\x1b[0m`);
|
|
1266
1282
|
}
|