beehaven 0.8.22 → 0.8.27

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.
@@ -25,7 +25,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
25
25
  import { join, dirname } from 'node:path';
26
26
  import { homedir } from 'node:os';
27
27
  import { rpcOnce } from '../ipc-client.js';
28
- import { saveAgent, loadAgent, listAgents, deleteAgent, findAgentByAvatar, isValidAgentName, resolveActiveAgent, activeAgentName, saveAgentSession, clearAgentSession, decodeAgentTokenClaims, } from '../agent-state.js';
28
+ import { saveAgent, loadAgent, listAgents, deleteAgent, findAgentByAvatar, isValidAgentName, resolveActiveAgent, activeAgentName, saveAgentSession, clearAgentSession, clearAgentSessionId, decodeAgentTokenClaims, } from '../agent-state.js';
29
29
  const VALID_KINDS = ['claude-code', 'daemon-agent', 'clearly-ai'];
30
30
  export const FREE_SKINS = [
31
31
  'classic',
@@ -64,6 +64,16 @@ function normalizeCliAvatarSkin(raw) {
64
64
  * socket alone cannot update the Durable Object row, so logout used to leave an `active` session
65
65
  * behind until the server's idle sweep noticed it. Best-effort keeps offline logout usable; the
66
66
  * 30-minute server reaper is the fallback when the relay is unavailable. */
67
+ /**
68
+ * End the agent's workspace work session.
69
+ *
70
+ * ⚠⚠ `summary` IS THE AGENT'S HANDOFF NOTE, NEVER A LIFECYCLE REASON (AWO-225). The next login's
71
+ * brief prints a session's summary as its payload — "what I did, what's next". Every lifecycle end
72
+ * used to pass its reason here ("Replaced by a new login session for this agent identity.",
73
+ * "Agent explicitly logged out."), so the payload slot only ever held boilerplate. Worse, the
74
+ * Worker accepts a summary on an ALREADY-ENDED session, so a login after `agent handoff` replaced
75
+ * the agent's real note with "Replaced by a new login session". Only `agent handoff` passes one.
76
+ */
67
77
  async function endWorkspaceSession(record, summary) {
68
78
  const sessionId = String(record?.sessionId ?? '').trim();
69
79
  if (!sessionId)
@@ -72,7 +82,7 @@ async function endWorkspaceSession(record, summary) {
72
82
  const response = await rpcOnce('agent-session-end', {
73
83
  id: sessionId,
74
84
  ...(record?.agentId ? { agentId: record.agentId } : {}),
75
- summary,
85
+ ...(summary?.trim() ? { summary: summary.trim() } : {}),
76
86
  }, 5_000);
77
87
  const result = response?.result ?? response;
78
88
  return result?.ok !== false && !result?.error;
@@ -106,6 +116,8 @@ export async function cmdAgentSub(positionals, values) {
106
116
  return agentStatus(rest, values);
107
117
  case 'logout':
108
118
  return agentLogout(rest, values);
119
+ case 'handoff':
120
+ return agentHandoff(rest, values);
109
121
  case 'delete':
110
122
  case 'remove':
111
123
  return agentDelete(rest, values);
@@ -134,6 +146,7 @@ export function isAgentIdentitySub(sub) {
134
146
  sub === 'whoami' ||
135
147
  sub === 'status' ||
136
148
  sub === 'logout' ||
149
+ sub === 'handoff' ||
137
150
  sub === 'delete' ||
138
151
  sub === 'remove' ||
139
152
  sub === 'rebind' ||
@@ -161,6 +174,7 @@ function printAgentUsage() {
161
174
  console.log(' list list stored identities');
162
175
  console.log(' whoami show the active identity');
163
176
  console.log(' status [<name>] --message <text> set the persistent desktop status label');
177
+ console.log(' handoff "<summary>" end the work session with a note the next login reads first');
164
178
  console.log(' logout [<name>] end the session, KEEP the identity (default: active)');
165
179
  console.log(' delete <name> archive this workspace projection + remove this device copy');
166
180
  console.log(' delete <name> --account --yes delete the portable account identity + this device copy');
@@ -687,7 +701,7 @@ async function agentLogin(args, values) {
687
701
  * actually began. `existingRecord` is read before the write for exactly this.
688
702
  */
689
703
  const birthday = existingRecord?.birthday ?? new Date().toISOString();
690
- const previousSessionEnded = await endWorkspaceSession(existingRecord, 'Replaced by a new login session for this agent identity.');
704
+ const previousSessionEnded = await endWorkspaceSession(existingRecord);
691
705
  /**
692
706
  * ⚠⚠ FILL THE LIFETIME FIELDS FROM THE TOKEN — the cloud mint path does not
693
707
  * return them and a record without `expiresAt` can never be seen as expired.
@@ -845,7 +859,11 @@ async function agentLogin(args, values) {
845
859
  console.error(` ${yellow('⚠')} Signed in, but this workspace refused ${cyan(name)}: ${outcome.reason}`);
846
860
  if (/revoked|not active|unknown agent|not admitted/i.test(`${outcome.scope} ${outcome.reason}`)) {
847
861
  console.error(dim(` The agent is archived or not admitted here. Restore it in Settings → Agent access,`));
848
- console.error(dim(` or log in as an active agent (beehaven call agent-list '{}' shows the roster).`));
862
+ // ⚠ The workspace names the live identity in its refusal (PPO-190). The old advice —
863
+ // `beehaven call agent-list` — ran AS this refused identity and was refused in turn.
864
+ if (!/Log in as one:/.test(outcome.reason)) {
865
+ console.error(dim(` or log in as an active agent: beehaven agent login <name> (the Settings → Agent access page lists them).`));
866
+ }
849
867
  }
850
868
  console.error(dim(` Calls from this session run as ${name} and will be refused — they will NOT fall back to another agent.`));
851
869
  process.exitCode = 1;
@@ -876,104 +894,182 @@ async function agentLogin(args, values) {
876
894
  console.log('');
877
895
  }
878
896
  /**
879
- * The login digest.
897
+ * Summaries older CLIs wrote when a session ended for a lifecycle reason. They are not handoff
898
+ * notes (AWO-225), so the brief does not print them as one; sessions that carry them still show
899
+ * their label and counts. Kept until no client in the field writes them.
900
+ */
901
+ const LIFECYCLE_SUMMARIES = new Set([
902
+ 'Replaced by a new login session for this agent identity.',
903
+ 'Agent explicitly logged out.',
904
+ 'Agent identity deleted.',
905
+ 'Account agent identity deleted.',
906
+ ]);
907
+ const briefAgo = (ts, now) => {
908
+ const d = now - ts;
909
+ if (d < 60_000)
910
+ return 'just now';
911
+ if (d < 3_600_000)
912
+ return `${Math.round(d / 60_000)}m ago`;
913
+ if (d < 86_400_000)
914
+ return `${Math.round(d / 3_600_000)}h ago`;
915
+ return `${Math.round(d / 86_400_000)}d ago`;
916
+ };
917
+ const oneLine = (v, max) => {
918
+ const t = String(v ?? '').replace(/\s+/g, ' ').trim();
919
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
920
+ };
921
+ /**
922
+ * The login digest, as lines (AWO-225: pure, so it can be tested without a workspace).
880
923
  *
881
924
  * Everything here answers a question a returning colleague asks before touching anything, and
882
925
  * NOTHING here is printed when it is empty — a brief padded with "0 sessions · 0 memories ·
883
926
  * nothing assigned" is a wall an agent learns to skip, which costs the one thing the digest
884
927
  * has to buy: being read.
928
+ *
929
+ * Order is the order of obligation: what someone asked you, what you left yourself, what is yours
930
+ * to do, what is waiting on a person, then where you were and what moved while you were away.
885
931
  */
886
- function printBrief(brief) {
932
+ export function formatBrief(brief, now = Date.now()) {
933
+ const out = [];
887
934
  if (!brief)
888
- return;
935
+ return out;
936
+ const section = (title, count) => {
937
+ out.push('');
938
+ out.push(` ${bold(title)}${count === undefined ? '' : ` ${dim(`(${count})`)}`}`);
939
+ };
940
+ const ws = brief.workspace ?? {};
889
941
  const sessions = brief.sessions?.recent ?? [];
890
942
  const assigned = brief.assignedToMe ?? [];
891
943
  const pending = brief.awaitingApproval ?? [];
892
944
  const memories = brief.memories ?? [];
893
945
  const focus = brief.focus ?? [];
946
+ const recents = brief.recents ?? [];
947
+ const drafts = brief.drafts ?? [];
948
+ const changed = ws.changed ?? [];
949
+ const otherAgents = ws.otherAgents ?? [];
950
+ const openDecisions = ws.decisions?.open ?? [];
951
+ const pressure = brief.reviewPressure;
894
952
  /**
895
953
  * ⚠ SS-41 — ADDRESSED TO YOU, AND PRINTED FIRST. Voicemail and @mentions are somebody speaking
896
954
  * TO this agent; everything else in the brief is ambient. Both stay until acknowledged.
897
955
  */
898
- const mentions = brief.workspace?.mentions ?? [];
899
- const voicemail = brief.workspace?.voicemail ?? [];
956
+ const mentions = ws.mentions ?? [];
957
+ const voicemail = ws.voicemail ?? [];
900
958
  /** WIB-1 — people and agents writing TO this agent. Oldest first; reading one clears it. */
901
- const inbox = brief.workspace?.inboxThreads ?? [];
959
+ const inbox = ws.inboxThreads ?? [];
902
960
  if (mentions.length || voicemail.length || inbox.length) {
903
- console.log('');
904
- console.log(` ${bold('Addressed to you')} ${dim(`(${mentions.length + voicemail.length + inbox.length})`)}`);
961
+ section('Addressed to you', mentions.length + voicemail.length + inbox.length);
905
962
  for (const t of inbox.slice(0, 5)) {
906
- console.log(` ${dim('·')} ${cyan('message')} from ${t.from}: “${t.subject}” ${dim(`${t.unread} unread · ${t.threadId}`)}`);
907
- console.log(` ${String(t.preview ?? '').replace(/\s+/g, ' ').slice(0, 120)}`);
963
+ out.push(` ${dim('·')} ${cyan('message')} from ${t.from}: “${t.subject}” ${dim(`${t.unread} unread · ${t.threadId}`)}`);
964
+ out.push(` ${oneLine(t.preview, 120)}`);
908
965
  }
909
966
  if (inbox.length)
910
- console.log(dim(` Read with \`beehaven inbox read <id>\`, answer with \`beehaven inbox reply <id> "…"\`, then \`beehaven inbox done <id>\`.`));
967
+ out.push(dim(` Read with \`beehaven inbox read <id>\`, answer with \`beehaven inbox reply <id> "…"\`, then \`beehaven inbox done <id>\`.`));
911
968
  for (const v of voicemail.slice(0, 3)) {
912
- console.log(` ${dim('·')} ${cyan('voicemail')} from ${v.from}: ${String(v.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`);
969
+ out.push(` ${dim('·')} ${cyan('voicemail')} from ${v.from}: ${oneLine(v.text, 100)}`);
913
970
  }
914
971
  for (const m of mentions.slice(0, 5)) {
915
972
  const on = m.on ?? {};
916
- console.log(` ${dim('·')} ${cyan('@mention')} from ${m.from} on ${on.kind || 'item'} “${on.title || on.id || ''}” ${dim(String(m.id).slice(0, 8))}`);
917
- console.log(` ${String(m.text ?? '').replace(/\s+/g, ' ').slice(0, 120)}`);
973
+ out.push(` ${dim('·')} ${cyan('@mention')} from ${m.from} on ${on.kind || 'item'} “${on.title || on.id || ''}” ${dim(String(m.id).slice(0, 8))}`);
974
+ out.push(` ${oneLine(m.text, 120)}`);
918
975
  }
919
976
  if (mentions.length)
920
- console.log(dim(` Act, reply with \`beehaven comment <ref> "…"\`, then \`beehaven mention ack <id>\`.`));
977
+ out.push(dim(` Act, reply with \`beehaven comment <ref> "…"\`, then \`beehaven mention ack <id>\`.`));
921
978
  }
922
- if (!sessions.length && !assigned.length && !pending.length && !memories.length && !focus.length) {
923
- if (!mentions.length && !voicemail.length && !inbox.length)
924
- console.log(dim(' First visit — nothing to catch up on.'));
925
- return;
926
- }
927
- const ago = (ts) => {
928
- const d = Date.now() - ts;
929
- if (d < 60_000)
930
- return 'just now';
931
- if (d < 3_600_000)
932
- return `${Math.round(d / 60_000)}m ago`;
933
- if (d < 86_400_000)
934
- return `${Math.round(d / 3_600_000)}h ago`;
935
- return `${Math.round(d / 86_400_000)}d ago`;
936
- };
979
+ // ── What you left yourself. The handoff note is the payload; counts are context. ──
937
980
  if (sessions.length) {
938
- console.log('');
939
- console.log(` ${bold('Last time you were here')}`);
981
+ section('Last time you were here');
940
982
  for (const s of sessions.slice(0, 3)) {
941
- const when = s.startedAt ? ago(s.startedAt) : '';
983
+ const when = s.startedAt ? briefAgo(s.startedAt, now) : '';
942
984
  const counts = `${s.actions} action${s.actions === 1 ? '' : 's'}${s.errors ? `, ${s.errors} error${s.errors === 1 ? '' : 's'}` : ''}`;
943
- console.log(` ${dim('·')} ${s.label || '(unlabelled)'} ${dim(`— ${when}, ${counts}`)}`);
944
- // The summary is the payload. It is the one thing carrying reasoning rather than counts.
945
- if (s.summary)
946
- console.log(` ${s.summary}`);
947
- }
948
- }
949
- if (focus.length) {
950
- console.log('');
951
- console.log(` ${bold('You were working on')}`);
952
- for (const f of focus.slice(0, 3)) {
953
- console.log(` ${dim('·')} ${f.title || f.id} ${dim(`(${f.kind}, ${f.selected.length} selected)`)}`);
985
+ out.push(` ${dim('·')} ${s.label || '(unlabelled)'} ${dim(`— ${when}, ${counts}`)}`);
986
+ const note = String(s.summary ?? '').trim();
987
+ if (note && !LIFECYCLE_SUMMARIES.has(note))
988
+ out.push(` ${cyan('handoff:')} ${oneLine(note, 400)}`);
954
989
  }
955
990
  }
956
991
  if (assigned.length) {
957
- console.log('');
958
- console.log(` ${bold('Assigned to you')} ${dim(`(${assigned.length})`)}`);
992
+ section('Assigned to you', assigned.length);
959
993
  for (const t of assigned.slice(0, 5)) {
960
- console.log(` ${dim('·')} ${t.key ? cyan(t.key) + ' ' : ''}${t.title} ${dim(`[${t.status}]`)}`);
994
+ out.push(` ${dim('·')} ${t.key ? cyan(t.key) + ' ' : ''}${t.title} ${dim(`[${t.status}]`)}`);
961
995
  }
962
996
  }
963
997
  if (pending.length) {
964
- console.log('');
965
- console.log(` ${bold('Waiting on a human')} ${dim(`(${pending.length})`)}`);
998
+ section('Waiting on a human', pending.length);
966
999
  for (const c of pending.slice(0, 4)) {
967
- console.log(` ${dim('·')} ${c.title || c.id} ${dim(c.kind ? `(${c.kind})` : '')}`);
1000
+ out.push(` ${dim('·')} ${c.title || c.id} ${dim(c.kind ? `(${c.kind})` : '')}`);
1001
+ }
1002
+ }
1003
+ /** WDP-9 — decisions still waiting on a person. Current decisions are context the agent can
1004
+ * re-read with `agent-brief`; open ones change what it should do next. */
1005
+ if (openDecisions.length) {
1006
+ section('Open decisions', openDecisions.length);
1007
+ for (const d of openDecisions.slice(0, 4)) {
1008
+ out.push(` ${dim('·')} ${d.key ? cyan(d.key) + ' ' : ''}${d.title} ${dim(`[${d.state}]`)}`);
1009
+ }
1010
+ }
1011
+ // ── Where you were. Focus is what you had selected; recents are the things themselves. ──
1012
+ if (focus.length) {
1013
+ section('You were working on');
1014
+ for (const f of focus.slice(0, 3)) {
1015
+ out.push(` ${dim('·')} ${f.title || f.id} ${dim(`(${f.kind}, ${f.selected.length} selected)`)}`);
1016
+ }
1017
+ }
1018
+ const shown = new Set([...focus, ...assigned].map((x) => String(x?.id ?? '')));
1019
+ const whereLeft = recents.filter((r) => r?.id && !shown.has(String(r.id)) && r.title).slice(0, 4);
1020
+ if (whereLeft.length) {
1021
+ section('Where you left off');
1022
+ for (const r of whereLeft) {
1023
+ const what = [r.kind, [r.reason || 'touched', r.at ? briefAgo(r.at, now) : ''].filter(Boolean).join(' ')].filter(Boolean).join(', ');
1024
+ out.push(` ${dim('·')} ${oneLine(r.title, 80)} ${dim(`(${what})`)}`);
1025
+ }
1026
+ }
1027
+ // ── What moved while you were away, and who moved it (`by` is the useful field). ──
1028
+ if (changed.length) {
1029
+ const waiting = ws.waiting ?? {};
1030
+ const total = Number(waiting.changedByPeople ?? 0) + Number(waiting.changedByAgents ?? 0) || changed.length;
1031
+ section('Changed since you were here', total);
1032
+ for (const c of changed.slice(0, 4)) {
1033
+ const who = c.actor ? `${c.actor}${c.by === 'agent' ? ' (agent)' : ''}` : c.by === 'agent' ? 'an agent' : 'a person';
1034
+ out.push(` ${dim('·')} ${oneLine(c.title || c.id, 70)} ${dim(`(${c.kind}) — ${c.op || 'changed'} by ${who}${c.at ? `, ${briefAgo(c.at, now)}` : ''}`)}`);
1035
+ }
1036
+ }
1037
+ if (otherAgents.length) {
1038
+ const names = otherAgents.slice(0, 4).map((a) => `${a.agentId} ${dim(`(${a.actions} action${a.actions === 1 ? '' : 's'}${a.last ? `, ${briefAgo(a.last, now)}` : ''})`)}`);
1039
+ out.push('');
1040
+ out.push(` ${bold('Also working here')} ${names.join(', ')}`);
1041
+ }
1042
+ // ── What you have not shared yet. Filing is sharing; an unfiled draft is invisible to the team. ──
1043
+ if (drafts.length) {
1044
+ section('Your unfiled drafts', drafts.length);
1045
+ for (const d of drafts.slice(0, 3)) {
1046
+ out.push(` ${dim('·')} ${d.key ? cyan(d.key) + ' ' : ''}${oneLine(d.title, 80)}${d.updatedAt ? dim(` — ${briefAgo(d.updatedAt, now)}`) : ''}`);
968
1047
  }
1048
+ out.push(dim(' Private to you until filed into a project.'));
1049
+ }
1050
+ if (pressure?.overBudget && pressure.instruction) {
1051
+ out.push('');
1052
+ out.push(` ${yellow('⚠')} ${pressure.instruction}`);
969
1053
  }
970
1054
  if (memories.length) {
971
- console.log('');
972
- console.log(` ${bold('You noted')}`);
1055
+ section('You noted');
973
1056
  for (const m of memories.slice(0, 4)) {
974
- console.log(` ${dim('·')} ${cyan(m.key)}: ${m.value.slice(0, 90)}${m.value.length > 90 ? '…' : ''}`);
1057
+ out.push(` ${dim('·')} ${cyan(m.key)}: ${oneLine(m.value, 90)}`);
975
1058
  }
976
1059
  }
1060
+ if (!out.length)
1061
+ out.push(dim(' First visit — nothing to catch up on.'));
1062
+ // The one way a session leaves the next one something to read. Printed every time: a handoff
1063
+ // you have to remember to write is a handoff most sessions will not leave.
1064
+ out.push('');
1065
+ out.push(dim(` Before you stop: beehaven agent handoff "what you did · what is next"`));
1066
+ return out;
1067
+ }
1068
+ function printBrief(brief) {
1069
+ if (!brief)
1070
+ return;
1071
+ for (const line of formatBrief(brief))
1072
+ console.log(line);
977
1073
  }
978
1074
  // ── use (prints the export line for eval) ────────────
979
1075
  async function agentUse(args) {
@@ -1088,6 +1184,41 @@ async function agentStatus(args, values) {
1088
1184
  }
1089
1185
  console.log(` ${green('✓')} ${cyan(name)} status: ${String(result.message ?? message)}`);
1090
1186
  }
1187
+ // ── handoff ──────────────────────────────────────────
1188
+ /**
1189
+ * End the current work session WITH a note — the one thing the next login (this agent's, or the
1190
+ * colleague it hands to) reads first, under "Last time you were here" (AWO-225).
1191
+ *
1192
+ * Unlike `logout`, the identity stays signed in and bound: an agent hands off a stretch of work,
1193
+ * not its seat. Only the ended session's id is forgotten (`clearAgentSessionId`), so the next
1194
+ * `agent login` starts a fresh session and this terminal still resolves to this agent.
1195
+ */
1196
+ async function agentHandoff(args, values) {
1197
+ const name = activeAgentName(values);
1198
+ const summary = (args.join(' ') || String(values.summary ?? '')).trim();
1199
+ if (!name || !summary) {
1200
+ console.error(' Usage: beehaven agent handoff "<what you did · what is next>" [--agent <name>]');
1201
+ if (!name)
1202
+ console.error(' No active agent: log in first, or pass --agent <name>.');
1203
+ process.exit(1);
1204
+ }
1205
+ const record = loadAgent(name);
1206
+ if (!record?.sessionId) {
1207
+ console.error(` ${yellow('⚠')} ${cyan(name)} has no open work session on this device.`);
1208
+ console.error(dim(` Start one with: beehaven agent login ${name} --label "<work>" --client cli`));
1209
+ process.exitCode = 1;
1210
+ return;
1211
+ }
1212
+ const ended = await endWorkspaceSession(record, summary);
1213
+ if (!ended) {
1214
+ console.error(` ${yellow('⚠')} The workspace did not accept the handoff; the session is still open.`);
1215
+ console.error(dim(' Check `beehaven status`, then run the same command again.'));
1216
+ process.exitCode = 1;
1217
+ return;
1218
+ }
1219
+ clearAgentSessionId(name);
1220
+ console.log(` ${green('✓')} Handed off session ${cyan(String(record.sessionId).slice(0, 8))} — the next login for ${cyan(name)} reads this first.`);
1221
+ }
1091
1222
  // ── logout ───────────────────────────────────────────
1092
1223
  async function agentLogout(args, values) {
1093
1224
  const name = args[0] ?? activeAgentName(values);
@@ -1105,7 +1236,7 @@ async function agentLogout(args, values) {
1105
1236
  // it stays in the workspace (source of truth) AND on this device, so you can
1106
1237
  // log back in. To remove the current workspace projection use `agent delete <name>`;
1107
1238
  // to remove the portable account identity use `agent delete <name> --account --yes`.
1108
- await endWorkspaceSession(record, 'Agent explicitly logged out.');
1239
+ await endWorkspaceSession(record);
1109
1240
  try {
1110
1241
  await rpcOnce('agent:disconnect', { agentId: record.agentId ?? name }, 5_000);
1111
1242
  }
@@ -1130,7 +1261,7 @@ async function agentDelete(args, values) {
1130
1261
  if (values.account === true)
1131
1262
  return agentAccountDelete(args, values);
1132
1263
  const record = loadAgent(name);
1133
- await endWorkspaceSession(record, 'Agent identity deleted.');
1264
+ await endWorkspaceSession(record);
1134
1265
  // 1. Remove from the WORKSPACE (source of truth) — the `agent-delete` RPC
1135
1266
  // soft-deletes (archived). Resolve by the record's agentId, else the name.
1136
1267
  let cloud = false;
@@ -1189,7 +1320,7 @@ async function agentAccountDelete(args, values) {
1189
1320
  console.error(` ${result.error || `Could not delete account agent "${name}".`}`);
1190
1321
  process.exit(1);
1191
1322
  }
1192
- await endWorkspaceSession(record, 'Account agent identity deleted.');
1323
+ await endWorkspaceSession(record);
1193
1324
  try {
1194
1325
  await rpcOnce('agent:disconnect', { agentId }, 5_000);
1195
1326
  }
@@ -25,6 +25,50 @@ function coordinateFlag(raw, label) {
25
25
  throw new Error(`${label} must be a number`);
26
26
  return value;
27
27
  }
28
+ /**
29
+ * ⚠ NATIVE SIZE IS READ HERE, FROM THE FILE'S OWN HEADER. The docs promise "omit both size flags to
30
+ * use the image's native dimensions", but nothing measured the image, so the server's placeholder
31
+ * box won: a 1032×320 PNG landed as a 400×400 node with its aspect squashed (CDS-26). PNG, GIF,
32
+ * JPEG and WebP carry their size in the first bytes; AVIF (ISO-BMFF) falls back to the server.
33
+ */
34
+ export function imageSize(buf, mime) {
35
+ try {
36
+ if (mime === 'image/png' && buf.length >= 24 && buf.toString('ascii', 12, 16) === 'IHDR') {
37
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
38
+ }
39
+ if (mime === 'image/gif' && buf.length >= 10)
40
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
41
+ if (mime === 'image/webp' && buf.length >= 30 && buf.toString('ascii', 8, 12) === 'WEBP') {
42
+ const kind = buf.toString('ascii', 12, 16);
43
+ if (kind === 'VP8X')
44
+ return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
45
+ if (kind === 'VP8L') {
46
+ const b = buf.readUInt32LE(21);
47
+ return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 };
48
+ }
49
+ if (kind === 'VP8 ')
50
+ return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
51
+ }
52
+ if (mime === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8) {
53
+ let i = 2;
54
+ while (i + 9 < buf.length) {
55
+ if (buf[i] !== 0xff) {
56
+ i++;
57
+ continue;
58
+ }
59
+ const marker = buf[i + 1];
60
+ const len = buf.readUInt16BE(i + 2);
61
+ // SOF0–SOF15 except DHT(C4), JPG(C8), DAC(CC) carry the frame size.
62
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
63
+ return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
64
+ }
65
+ i += 2 + len;
66
+ }
67
+ }
68
+ }
69
+ catch { /* fall back to the server's default */ }
70
+ return null;
71
+ }
28
72
  function absoluteRelayUrl(url) {
29
73
  if (!url.startsWith('/'))
30
74
  return url;
@@ -54,12 +98,19 @@ export async function cmdCanvasAddImage(positionals, values) {
54
98
  throw new Error(`Image must be 1 byte to 100 MB (${stat.size} bytes given)`);
55
99
  const x = coordinateFlag(values.x, '--x');
56
100
  const y = coordinateFlag(values.y, '--y');
57
- const width = positiveFlag(values.width, '--width');
58
- const height = positiveFlag(values.height, '--height');
101
+ let width = positiveFlag(values.width, '--width');
102
+ let height = positiveFlag(values.height, '--height');
59
103
  if ((x === undefined) !== (y === undefined))
60
104
  throw new Error('Pass --x and --y together');
61
105
  if ((width === undefined) !== (height === undefined))
62
106
  throw new Error('Pass --width and --height together');
107
+ if (width === undefined) {
108
+ const native = imageSize(readFileSync(path), mimeType);
109
+ if (native && native.width > 0 && native.height > 0) {
110
+ width = native.width;
111
+ height = native.height;
112
+ }
113
+ }
63
114
  // Refuse an unknown target before putting bytes in storage for a canvas that cannot use them.
64
115
  const target = await rpcOnce('composition-detail', { id: compositionId }, 30_000);
65
116
  if (target?.error || target?.composition?.id !== compositionId) {
@@ -93,7 +144,10 @@ export async function cmdCanvasAddImage(positionals, values) {
93
144
  if (placed?.ok !== true || !placed?.nodeId && !placed?.blockId) {
94
145
  throw new Error(`Image uploaded but could not be placed: ${placed?.error ?? 'unknown error'}. Asset key: ${key}`);
95
146
  }
96
- const result = { ok: true, compositionId, nodeId: placed.nodeId ?? placed.blockId, imageFileId: recorded?.id, name };
147
+ // ⚠ `src` IS RETURNED — reusing an upload (a brand logo, a second placement) needed a scene read
148
+ // to find the URL the node stores (CDS-27).
149
+ const src = String(placed?.src ?? placed?.url ?? placed?.node?.src ?? absoluteRelayUrl(String(intent.publicUrl)));
150
+ const result = { ok: true, compositionId, nodeId: placed.nodeId ?? placed.blockId, imageFileId: recorded?.id, name, src, ...(width !== undefined ? { width, height } : {}) };
97
151
  if (values.json)
98
152
  console.log(JSON.stringify(result, null, 2));
99
153
  else
@@ -243,7 +243,7 @@ async function cmdCanvasPublishLanding(positionals, values) {
243
243
  }
244
244
  const block = embed?.block ?? embed?.result?.block;
245
245
  console.log(` ✓ Published: site comp ${siteCompositionId} embedded as block ${block?.id ?? '?'} on pasteboard ${targetCompositionId}`);
246
- console.log(` ✓ Open it: https://clearly.sh/c/${targetCompositionId}`);
246
+ console.log(` ✓ Open it: https://www.clearly.sh/c/${targetCompositionId}`);
247
247
  return print(embed, values);
248
248
  }
249
249
  async function cmdCanvasPasteText(positionals, values) {
@@ -401,7 +401,7 @@ export async function cmdComposePush(_values) {
401
401
  }
402
402
  console.log('');
403
403
  console.log(` Composition live at:`);
404
- console.log(` https://clearly.sh/app/compositions/${link.compositionId}`);
404
+ console.log(` https://www.clearly.sh/app/compositions/${link.compositionId}`);
405
405
  console.log('');
406
406
  console.log(` Create a public share link with:`);
407
407
  console.log(` beehaven compose share --password <pw>`);
@@ -466,7 +466,7 @@ export async function cmdComposeShare(values) {
466
466
  process.exit(1);
467
467
  }
468
468
  console.log(` \u2713 Share link created`);
469
- console.log(` https://clearly.sh/r/${res.id}`);
469
+ console.log(` https://www.clearly.sh/r/${res.id}`);
470
470
  if (input.password)
471
471
  console.log(` Password: ${input.password}`);
472
472
  if (input.expiresAt)
@@ -138,6 +138,38 @@ export async function startHeadlessDaemon(values) {
138
138
  ? ` Ready — socket answering.`
139
139
  : ` Started, but not answering yet after ${Math.round(DAEMON_READY_TIMEOUT_MS / 1000)}s. 'beehaven status' shows state.`);
140
140
  }
141
+ /**
142
+ * ⚠⚠ WHO IS ACTUALLY SERVING AFTER A RESPAWN. `restart` used to print the PID it spawned and stop
143
+ * there. On a Mac with the Clearly app open, the tray supervisor respawns ITS daemon the moment the
144
+ * old one dies — so the CLI's spawn lost the single-instance race ("Refusing to start — daemon
145
+ * already running"), exited, and the user was told "Daemon restarted (PID 5706)" about a process
146
+ * that no longer existed (observed 2026-10-01; the next call said "Cannot connect to daemon").
147
+ * This waits for a usable socket and reports the PID that is really live.
148
+ */
149
+ export async function waitForServingDaemon(spawnedPid) {
150
+ const { isDaemonRunning } = await import('../daemon-client.js');
151
+ const alive = (pid) => { if (!pid)
152
+ return false; try {
153
+ process.kill(pid, 0);
154
+ return true;
155
+ }
156
+ catch {
157
+ return false;
158
+ } };
159
+ const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
160
+ let ready = false;
161
+ while (Date.now() < deadline) {
162
+ await new Promise((r) => setTimeout(r, DAEMON_POLL_MS));
163
+ if (await isDaemonRunning()) {
164
+ ready = true;
165
+ break;
166
+ }
167
+ // The spawn died and nobody else took over: no point waiting out the clock.
168
+ if (!alive(spawnedPid) && findLiveDaemons().length === 0)
169
+ break;
170
+ }
171
+ return { ready, spawnedAlive: alive(spawnedPid), livePids: findLiveDaemons() };
172
+ }
141
173
  export async function cmdDaemon(sub, values) {
142
174
  switch (sub) {
143
175
  case 'start':
@@ -1790,6 +1790,7 @@ const PAGES = {
1790
1790
  agent use <name> — prints \`export BEEHAVEN_AGENT=<name>\`; eval it
1791
1791
  agent list — list stored identities, mark the active one
1792
1792
  agent whoami — show the active identity + TTL
1793
+ agent handoff "<summary>" — end the work session with a note; the next login prints it first
1793
1794
  agent logout [<name>]— end its local session, keep the identity
1794
1795
  agent delete <name> — archive the current workspace projection + remove this device copy
1795
1796
  agent delete <name> --account --yes
@@ -1825,6 +1826,7 @@ const PAGES = {
1825
1826
  { cmd: 'beehaven say "hi from monet"', note: "speaks via monet's avatar" },
1826
1827
  { cmd: 'beehaven agent rebind monet --avatar studio-2' },
1827
1828
  { cmd: 'beehaven agent whoami' },
1829
+ { cmd: 'beehaven agent handoff "Fixed AWO-224 login; next: release the CLI"', note: 'leave the next session a note' },
1828
1830
  { cmd: 'beehaven agent "what can you do?"', note: 'REPL one-shot' },
1829
1831
  ],
1830
1832
  related: ['app', 'pipe', 'pwd'],
@@ -6,7 +6,7 @@
6
6
  * `checkForUpdates` printed, unconditionally:
7
7
  *
8
8
  * Update available: 0.7.6 → 0.7.8
9
- * Run curl -fsSL https://clearly.sh/install.sh | sh to update
9
+ * Run curl -fsSL https://www.clearly.sh/install.sh | sh to update
10
10
  *
11
11
  * For anyone whose `beehaven` comes from the Mac app — which is EVERY ordinary user, since the
12
12
  * app bundles the CLI and installs the wrapper — following that instruction used to be actively
@@ -117,7 +117,7 @@ export function runSelfUpdate(kind = installKind()) {
117
117
  return { ok: false, message: updateHint(kind) };
118
118
  }
119
119
  const feed = (process.env.BEEHAVEN_CLI_FEED || 'https://downloads.clearly.sh/cli/').replace(/\/+$/, '');
120
- const installerUrl = process.env.BEEHAVEN_INSTALL_URL || 'https://clearly.sh/install.sh';
120
+ const installerUrl = process.env.BEEHAVEN_INSTALL_URL || 'https://www.clearly.sh/install.sh';
121
121
  const temp = mkdtempSync(join(tmpdir(), 'beehaven-self-update-'));
122
122
  try {
123
123
  const manifestPath = join(temp, 'latest.json');
@@ -860,6 +860,22 @@ export async function cmdCall(action, rawInput, values) {
860
860
  input = { ...input, action: canonical, name: canonical };
861
861
  }
862
862
  }
863
+ // ⚠ `type` IS THE WIRE'S ACTION NAME (PPO-190): a top-level `type` would be overwritten by the
864
+ // daemon's `{ ...input, type: action }` and lost with `ok: true`. Move it to the verb's own field
865
+ // or refuse with the right spelling — never drop it in silence. See reserved-type.ts.
866
+ {
867
+ const { reconcileReservedType } = await import('../reserved-type.js');
868
+ const r = reconcileReservedType(action, input);
869
+ if (r.kind === 'refused') {
870
+ console.error(` ${r.error}`);
871
+ process.exit(1);
872
+ }
873
+ if (r.kind === 'moved') {
874
+ input = r.input;
875
+ if (!values.json)
876
+ console.error(` · ${r.note}`);
877
+ }
878
+ }
863
879
  // Confirm spend BEFORE resolving a building or touching the daemon — a cancelled call must
864
880
  // cost nothing and behave identically whether the input came from argv, a file, or stdin.
865
881
  // It runs after the parse because the gate is input-aware: `engine:'handroll'` and any call
@@ -3049,15 +3065,20 @@ export async function cmdBroadcast(rawMsg, values) {
3049
3065
  * calling all along (`Avatar.ts` `replyWithOmni`), which is why Omni was
3050
3066
  * reachable from the desktop companion and not from the terminal.
3051
3067
  *
3052
- * The payload matters as much as the name: `agentId: 'omni'` selects the
3053
- * orchestrator, and `wait: true` is what makes this a one-shot rather than a
3068
+ * The payload matters as much as the name: `agentId: 'builtin:clearly'` selects the
3069
+ * workspace's built-in assistant, and `wait: true` is what makes this a one-shot rather than a
3054
3070
  * fire-and-forget that prints an ack and exits before the reply exists.
3071
+ *
3072
+ * ⚠ AWO-226: it sent `'omni'`, the built-in's old name. Since PPO-291 an untyped row reads as an
3073
+ * external identity, and workspaces old enough to carry the legacy `omni` row refused every
3074
+ * `beehaven agent "…"` with "Omni is an external agent" (staging home, 2026-10-06).
3075
+ * `builtin:clearly` is the id the server always resolves to the built-in, on old and new Workers.
3055
3076
  */
3056
3077
  export async function cmdAgent(prompt, values) {
3057
3078
  try {
3058
3079
  const result = await rpcOnce('agent-prompt', {
3059
3080
  message: prompt,
3060
- agentId: 'omni',
3081
+ agentId: 'builtin:clearly',
3061
3082
  wait: true,
3062
3083
  // ⚠ NOT `cwd`. The old call passed the shell's working directory, which
3063
3084
  // the action does not accept — a local path is meaningless to a cloud