@phnx-labs/agents-cli 1.21.0 → 1.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +63 -40
  6. package/dist/commands/hooks.js +9 -45
  7. package/dist/commands/menubar.js +24 -24
  8. package/dist/commands/message.js +23 -3
  9. package/dist/commands/monitors.js +8 -6
  10. package/dist/commands/perf.d.ts +13 -0
  11. package/dist/commands/perf.js +80 -23
  12. package/dist/commands/projects.d.ts +11 -0
  13. package/dist/commands/projects.js +153 -21
  14. package/dist/commands/routines.js +46 -1
  15. package/dist/commands/ssh.js +69 -0
  16. package/dist/commands/trends.d.ts +2 -0
  17. package/dist/commands/trends.js +158 -0
  18. package/dist/commands/usage.d.ts +4 -4
  19. package/dist/commands/view.d.ts +6 -0
  20. package/dist/commands/view.js +90 -45
  21. package/dist/index.js +14 -1
  22. package/dist/lib/activity.d.ts +6 -0
  23. package/dist/lib/activity.js +3 -0
  24. package/dist/lib/agents.js +2 -2
  25. package/dist/lib/analytics/dashboard.d.ts +11 -0
  26. package/dist/lib/analytics/dashboard.js +31 -0
  27. package/dist/lib/analytics/recipes.d.ts +32 -0
  28. package/dist/lib/analytics/recipes.js +316 -0
  29. package/dist/lib/analytics/usage-db.d.ts +84 -0
  30. package/dist/lib/analytics/usage-db.js +301 -0
  31. package/dist/lib/browser/service.js +18 -0
  32. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  33. package/dist/lib/channels/resolve.d.ts +18 -1
  34. package/dist/lib/channels/resolve.js +15 -5
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/daemon.js +51 -14
  38. package/dist/lib/devices/health-report.d.ts +5 -0
  39. package/dist/lib/devices/health-report.js +3 -0
  40. package/dist/lib/feed-broadcast.d.ts +93 -12
  41. package/dist/lib/feed-broadcast.js +235 -30
  42. package/dist/lib/feed-post.d.ts +16 -2
  43. package/dist/lib/feed-post.js +35 -7
  44. package/dist/lib/feed.js +1 -1
  45. package/dist/lib/fleet-cache.d.ts +37 -0
  46. package/dist/lib/fleet-cache.js +40 -0
  47. package/dist/lib/fleet-status.d.ts +53 -0
  48. package/dist/lib/fleet-status.js +120 -0
  49. package/dist/lib/friction-heuristics.d.ts +32 -0
  50. package/dist/lib/friction-heuristics.js +47 -0
  51. package/dist/lib/hooks/cache.js +28 -6
  52. package/dist/lib/hooks/profile.d.ts +8 -0
  53. package/dist/lib/hooks/profile.js +14 -4
  54. package/dist/lib/hooks.d.ts +16 -1
  55. package/dist/lib/hooks.js +133 -18
  56. package/dist/lib/linear-cache.d.ts +63 -0
  57. package/dist/lib/linear-cache.js +146 -0
  58. package/dist/lib/linear-project-counts.d.ts +35 -5
  59. package/dist/lib/linear-project-counts.js +61 -16
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  64. package/dist/lib/menubar/install-menubar.js +36 -6
  65. package/dist/lib/monitors/config.d.ts +1 -1
  66. package/dist/lib/monitors/dispatch.d.ts +6 -2
  67. package/dist/lib/monitors/dispatch.js +10 -15
  68. package/dist/lib/notify.d.ts +42 -6
  69. package/dist/lib/notify.js +41 -32
  70. package/dist/lib/perf/db.d.ts +6 -1
  71. package/dist/lib/perf/db.js +35 -5
  72. package/dist/lib/perf/types.d.ts +10 -0
  73. package/dist/lib/project-doctor.d.ts +36 -0
  74. package/dist/lib/project-doctor.js +45 -0
  75. package/dist/lib/project-import.d.ts +11 -1
  76. package/dist/lib/project-import.js +17 -3
  77. package/dist/lib/project-status.d.ts +25 -5
  78. package/dist/lib/project-status.js +48 -6
  79. package/dist/lib/rotate.d.ts +27 -0
  80. package/dist/lib/rotate.js +44 -17
  81. package/dist/lib/routines.d.ts +16 -0
  82. package/dist/lib/routines.js +39 -0
  83. package/dist/lib/runner.js +34 -0
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/usage-db.d.ts +3 -63
  87. package/dist/lib/secrets/usage-db.js +46 -186
  88. package/dist/lib/session/db.d.ts +2 -1
  89. package/dist/lib/session/db.js +14 -3
  90. package/dist/lib/session/discover.d.ts +3 -0
  91. package/dist/lib/session/discover.js +8 -0
  92. package/dist/lib/session/types.d.ts +1 -0
  93. package/dist/lib/startup/command-registry.d.ts +1 -0
  94. package/dist/lib/startup/command-registry.js +2 -0
  95. package/dist/lib/state.d.ts +31 -3
  96. package/dist/lib/state.js +53 -10
  97. package/dist/lib/types.d.ts +14 -4
  98. package/dist/lib/usage-refresh.d.ts +106 -0
  99. package/dist/lib/usage-refresh.js +238 -0
  100. package/dist/lib/usage.d.ts +152 -17
  101. package/dist/lib/usage.js +393 -79
  102. package/package.json +1 -1
@@ -23,8 +23,20 @@
23
23
  *
24
24
  * Delivery is best-effort and reported: a sink that fails prints a warning and
25
25
  * the post still stands. Losing a mirror must never cost the operator the post.
26
+ *
27
+ * A second sink shape (RUSH-2123) delivers **in-process** through the same
28
+ * channel-provider registry `agents send` uses (`channel:` instead of
29
+ * `command:`) — no spawn, no argv templating. `channel: owner` is the address
30
+ * alias that expands to `notify.owner.{channel,to}`, matching `agents notify`.
31
+ * When the operator has never written a `feed.broadcast` block at all, an
32
+ * important-level post falls back to that owner address implicitly
33
+ * ({@link effectiveBroadcastConfig}) rather than reaching nobody — see that
34
+ * function's doc for why this was a silent failure before.
26
35
  */
27
36
  import { spawnSync } from 'child_process';
37
+ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
38
+ import { lookupTransport } from './channels/resolve.js';
39
+ import { registerBuiltinProviders } from './channels/providers/index.js';
28
40
  const LEVEL_RANK = { milestone: 0, important: 1 };
29
41
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
30
42
  export function parseFeedPostLevel(raw) {
@@ -50,8 +62,12 @@ export function parseFeedPostLevel(raw) {
50
62
  export function blockBroadcastContext(block, extras = {}) {
51
63
  const ask = block.questions?.[0]?.text?.trim() || 'agent is blocked';
52
64
  const links = [block.pr].filter((l) => !!l && /^https?:\/\//i.test(l));
65
+ // Prefer explicit title/body from the feed post; fall back to the ask as body.
66
+ const title = extras.title?.trim() || undefined;
67
+ const text = extras.body?.trim() || ask;
53
68
  return {
54
- text: ask,
69
+ ...(title ? { title } : {}),
70
+ text,
55
71
  level: 'important',
56
72
  ticket: block.ticket,
57
73
  project: extras.project,
@@ -92,24 +108,118 @@ export function blockDeliveryFailure(blocked, outcomes) {
92
108
  }
93
109
  const PLACEHOLDER = /\{([a-z]+)\}/g;
94
110
  /**
95
- * A human-facing one-liner for a messaging sink: what project, what happened,
96
- * and the link to go read more. Leading with the project is deliberate — a
97
- * message that opens with an agent name tells the reader nothing about which of
98
- * their projects just moved.
111
+ * Short host label for a phone line strip user@ and domain so
112
+ * `muqsit@mac-mini.tailnet.ts.net` reads as `mac-mini`.
113
+ */
114
+ export function shortHost(host) {
115
+ if (!host?.trim())
116
+ return undefined;
117
+ let h = host.trim();
118
+ const at = h.lastIndexOf('@');
119
+ if (at !== -1)
120
+ h = h.slice(at + 1);
121
+ const dot = h.indexOf('.');
122
+ if (dot > 0)
123
+ h = h.slice(0, dot);
124
+ return h || undefined;
125
+ }
126
+ /** First 8 hex chars of a session id for the footer (readable, not a full uuid). */
127
+ export function shortSessionChunk(session) {
128
+ if (!session?.trim())
129
+ return undefined;
130
+ const hex = session.replace(/-/g, '').toLowerCase();
131
+ const chunk = hex.replace(/[^a-f0-9]/g, '').slice(0, 8);
132
+ return chunk || undefined;
133
+ }
134
+ /**
135
+ * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
136
+ * Collapses whitespace; does not invent meaning.
137
+ */
138
+ export function scrubOutboundDashes(text) {
139
+ return text
140
+ .replace(/\u2014/g, ' - ')
141
+ .replace(/\u2013/g, ' - ')
142
+ .replace(/[ \t]+\n/g, '\n')
143
+ .replace(/\n{3,}/g, '\n\n')
144
+ .replace(/[ \t]{2,}/g, ' ')
145
+ .trim();
146
+ }
147
+ /**
148
+ * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
149
+ *
150
+ * Sent from grok/a02da0e2 on mac-mini
151
+ *
152
+ * Agent name first; session chunk for disambiguation when many groks run;
153
+ * host last. Skip the uninformative default label `agent`.
154
+ */
155
+ export function composeBroadcastFooter(ctx) {
156
+ const agent = ctx.agent?.trim();
157
+ const agentLabel = agent && agent !== 'agent' ? agent : undefined;
158
+ const session = shortSessionChunk(ctx.session);
159
+ const host = shortHost(ctx.host);
160
+ let who;
161
+ if (agentLabel && session)
162
+ who = `${agentLabel}/${session}`;
163
+ else if (agentLabel)
164
+ who = agentLabel;
165
+ else if (session)
166
+ who = session;
167
+ if (who && host)
168
+ return `Sent from ${who} on ${host}`;
169
+ if (who)
170
+ return `Sent from ${who}`;
171
+ if (host)
172
+ return `Sent from host ${host}`;
173
+ return undefined;
174
+ }
175
+ /**
176
+ * Human-facing body for a messaging sink (`{message}`).
177
+ *
178
+ * ```
179
+ * Title in a few words
180
+ *
181
+ * Body of what happened or the ask.
182
+ *
183
+ * Sent from grok/a02da0e2 on mac-mini
184
+ * agents focus a02da0e2 (blocks only)
185
+ * https://… (optional attach URL)
186
+ * ```
187
+ *
188
+ * Title first (scannable subject). Blank line. Body. Footer provenance so a
189
+ * fleet of agents is attributable without crowding the ask. Prefer `{message}`
190
+ * over bare `{text}` in messaging sinks.
99
191
  */
100
192
  export function composeBroadcastMessage(ctx) {
101
- const head = ctx.project ? `${ctx.project} · ${ctx.text}` : ctx.text;
193
+ const title = scrubOutboundDashes(ctx.title ?? '');
194
+ const body = scrubOutboundDashes(ctx.text ?? '');
195
+ // Title preferred; if an older post has no title, body alone still sends.
196
+ const head = title || body;
197
+ const mid = title && body && title !== body ? body : undefined;
198
+ const footer = composeBroadcastFooter(ctx);
102
199
  const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
103
- // A block's second line is the command that unblocks it. The operator reading
104
- // this on a phone should not have to go find the session the one action they
105
- // must take travels with the ask. A status post has no such action, so it keeps
106
- // the link there instead.
107
- const tail = [ctx.focus, link].filter(Boolean);
108
- return tail.length ? `${head}\n${tail.join('\n')}` : head;
200
+ // Block focus and link trail after the "Sent from" footer so the human
201
+ // sentence stays at the top and the action/link are still one glance away.
202
+ const trail = [footer, ctx.focus, link].filter(Boolean);
203
+ const parts = [];
204
+ if (head)
205
+ parts.push(head);
206
+ if (mid) {
207
+ // Blank line between subject and body (title, then space, then message).
208
+ parts.push('');
209
+ parts.push(mid);
210
+ }
211
+ if (trail.length) {
212
+ // Blank line before the footer block (iPhone "Sent from my iPhone" spacing).
213
+ if (parts.length)
214
+ parts.push('');
215
+ parts.push(trail.join('\n'));
216
+ }
217
+ return parts.join('\n').trim();
109
218
  }
110
219
  /** The values a template may reference, resolved once per post. */
111
220
  function templateVars(ctx) {
112
221
  return {
222
+ title: ctx.title,
113
223
  text: ctx.text,
114
224
  ticket: ctx.ticket,
115
225
  project: ctx.project,
@@ -153,17 +263,39 @@ export function renderSinkArgv(template, ctx) {
153
263
  /**
154
264
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
155
265
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
266
+ *
267
+ * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
268
+ * one level check for both shapes, so a dry-run plan is truthful regardless of
269
+ * which shape an operator's sink uses.
156
270
  */
157
271
  export function planFeedBroadcast(config, ctx) {
158
272
  if (!config)
159
273
  return [];
160
274
  const planned = [];
161
275
  for (const [name, sink] of Object.entries(config)) {
162
- if (!Array.isArray(sink?.command) || sink.command.length === 0)
276
+ if (!sink)
163
277
  continue;
164
278
  const min = sink.minLevel ?? 'milestone';
165
279
  if (LEVEL_RANK[ctx.level] < LEVEL_RANK[min])
166
280
  continue;
281
+ const channel = sink.channel?.trim();
282
+ if (channel) {
283
+ // The owner alias resolves its recipient from notify.owner at delivery
284
+ // time; any other channel name needs an explicit recipient now, or the
285
+ // sink can never fire with a hole in it (same contract as a missing argv
286
+ // placeholder below).
287
+ if (!isOwnerAlias(channel) && !sink.to?.trim())
288
+ continue;
289
+ planned.push({
290
+ name,
291
+ channel,
292
+ to: isOwnerAlias(channel) ? undefined : sink.to.trim(),
293
+ text: composeBroadcastMessage(ctx),
294
+ });
295
+ continue;
296
+ }
297
+ if (!Array.isArray(sink.command) || sink.command.length === 0)
298
+ continue;
167
299
  const argv = renderSinkArgv(sink.command, ctx);
168
300
  if (!argv)
169
301
  continue;
@@ -172,24 +304,97 @@ export function planFeedBroadcast(config, ctx) {
172
304
  return planned;
173
305
  }
174
306
  /**
175
- * Run the planned sinks. Each is a direct spawn with a bounded lifetime; a sink
176
- * that fails or is not installed is reported, never thrown the post is already
177
- * written and must not be undone by a mirror that could not be reached.
307
+ * The effective sink config for a post: the operator's `feed.broadcast`, or
308
+ * when that is unset or empty an implicit fallback straight to
309
+ * `notify.owner`, for a post worth interrupting someone over.
310
+ *
311
+ * Before this, `broadcastPostedEvent`/`broadcastBlock` returned early the
312
+ * moment `feed.broadcast` was empty, even when `notify.owner` was fully
313
+ * configured — so the common case (an operator who set up owner notifications
314
+ * but never wrote a `feed.broadcast` block) produced a `--blocked` post that
315
+ * looked recorded and reached nobody. `agents notify` already treats
316
+ * `notify.owner` as the default human destination; this makes an important
317
+ * feed post/block use that same default instead of requiring a second,
318
+ * redundant config block that says the same thing.
319
+ *
320
+ * The fallback only fires for `important` — a routine `milestone` post stays
321
+ * record-only, matching the `minLevel` contract every declared sink already
322
+ * follows. An operator-declared `feed.broadcast` (any non-empty config)
323
+ * always wins outright; the fallback never layers on top of it.
178
324
  */
179
- export function runFeedBroadcast(planned, timeoutMs = 20_000) {
180
- return planned.map(({ name, argv }) => {
181
- const result = spawnSync(argv[0], argv.slice(1), {
182
- encoding: 'utf-8',
183
- timeout: timeoutMs,
184
- stdio: ['ignore', 'pipe', 'pipe'],
185
- });
186
- if (result.error) {
187
- return { name, ok: false, error: result.error.message };
325
+ export function effectiveBroadcastConfig(config, level, meta) {
326
+ if (config && Object.keys(config).length > 0)
327
+ return config;
328
+ if (level !== 'important')
329
+ return undefined;
330
+ if (!readOwnerDest(meta))
331
+ return undefined;
332
+ return { owner: { channel: 'owner' } };
333
+ }
334
+ function runCommandSink(name, argv, timeoutMs) {
335
+ const result = spawnSync(argv[0], argv.slice(1), {
336
+ encoding: 'utf-8',
337
+ timeout: timeoutMs,
338
+ stdio: ['ignore', 'pipe', 'pipe'],
339
+ });
340
+ if (result.error) {
341
+ return { name, ok: false, error: result.error.message };
342
+ }
343
+ if (result.status !== 0) {
344
+ const tail = (result.stderr || result.stdout || '').trim().split('\n').slice(-1)[0];
345
+ return { name, ok: false, error: tail || `exited ${result.status}` };
346
+ }
347
+ return { name, ok: true };
348
+ }
349
+ /**
350
+ * Deliver one `channel:` sink through the real provider registry —
351
+ * `resolveSendEnvelope` reuses `agents notify`'s owner-alias expansion, and
352
+ * `deliverEnvelope` is the same seam `agents send` calls. A bad channel name
353
+ * is checked with `lookupTransport` (the non-throwing lookup) BEFORE handing
354
+ * off to `deliverEnvelope`: that function's own resolution `die()`s on an
355
+ * unregistered provider, which is the right answer for an interactive `agents
356
+ * send` typo but would take the whole broadcast fan-out down with it here —
357
+ * one misconfigured sink must report a failure, not kill the process running
358
+ * every other sink.
359
+ */
360
+ async function runChannelSink(sink, meta) {
361
+ const name = sink.name;
362
+ // Registration is idempotent and normally happens inside deliverEnvelope();
363
+ // it has to happen before the lookupTransport pre-check below too, or the
364
+ // very first channel sink in a process would report "no channel provider"
365
+ // for a name that is, in fact, registered.
366
+ registerBuiltinProviders();
367
+ const owner = isOwnerAlias(sink.channel);
368
+ const resolved = resolveSendEnvelope({
369
+ text: sink.text ?? '',
370
+ channel: owner ? undefined : sink.channel,
371
+ to: owner ? 'owner' : sink.to,
372
+ ownerMode: owner,
373
+ }, meta);
374
+ if (!resolved.ok)
375
+ return { name, ok: false, error: resolved.error };
376
+ const { provider, error } = lookupTransport(resolved.envelope.channel, meta);
377
+ if (!provider)
378
+ return { name, ok: false, error };
379
+ const result = await deliverEnvelope(resolved.envelope, meta);
380
+ return result.ok ? { name, ok: true } : { name, ok: false, error: result.error };
381
+ }
382
+ /**
383
+ * Run the planned sinks. A `command:` sink is a direct spawn with a bounded
384
+ * lifetime; a `channel:` sink delivers in-process. Either way a sink that
385
+ * fails or is not installed/registered is reported, never thrown — the post
386
+ * is already written and must not be undone by a mirror that could not be
387
+ * reached.
388
+ */
389
+ export async function runFeedBroadcast(planned, meta, timeoutMs = 20_000) {
390
+ const outcomes = [];
391
+ for (const sink of planned) {
392
+ if (sink.channel) {
393
+ outcomes.push(await runChannelSink(sink, meta));
188
394
  }
189
- if (result.status !== 0) {
190
- const tail = (result.stderr || result.stdout || '').trim().split('\n').slice(-1)[0];
191
- return { name, ok: false, error: tail || `exited ${result.status}` };
395
+ else {
396
+ outcomes.push(runCommandSink(sink.name, sink.argv ?? [], timeoutMs));
192
397
  }
193
- return { name, ok: true };
194
- });
398
+ }
399
+ return outcomes;
195
400
  }
@@ -2,8 +2,15 @@ import { type ActivityEvent, type Attachment } from './activity.js';
2
2
  import { type PidSessionEntry } from './session/pid-registry.js';
3
3
  /** Soft cap so a runaway agent can't flood the activity lane with essays. */
4
4
  export declare const STATUS_POST_MAX_CHARS = 500;
5
+ /** Title is a phone subject line - about four or five words, not a paragraph. */
6
+ export declare const STATUS_TITLE_MAX_CHARS = 60;
5
7
  export interface FeedPostInput {
6
- /** Human-readable progress text (required). Domain-agnostic free text. */
8
+ /**
9
+ * Short subject (required for new posts). ~4–5 words. Phone broadcasts put
10
+ * this on the first line so a scan names the topic before the body.
11
+ */
12
+ title: string;
13
+ /** Body text (required). Domain-agnostic free text — what happened / the ask. */
7
14
  text: string;
8
15
  /** Override session id (escape hatch for scripts/tests). Prefer auto-resolve. */
9
16
  sessionId?: string;
@@ -92,9 +99,16 @@ export declare function buildAttachments(tokens: string[] | undefined, ctx: {
92
99
  sessionId: string;
93
100
  updateId: string;
94
101
  }): Attachment[];
102
+ /**
103
+ * Collapse whitespace and strip em/en dashes (house rule: no em-dashes in
104
+ * agent-authored outbound copy — phones and plain text render them poorly).
105
+ */
106
+ export declare function scrubDashes(text: string): string;
95
107
  export declare function normalizeStatusText(text: string): string;
108
+ /** Normalize a short subject line for a post. */
109
+ export declare function normalizeStatusTitle(title: string): string;
96
110
  /**
97
111
  * Append a `status.posted` milestone for the calling agent.
98
- * Throws if text is empty or session identity cannot be resolved.
112
+ * Throws if title/text is empty or session identity cannot be resolved.
99
113
  */
100
114
  export declare function postFeedStatus(input: FeedPostInput): FeedPostResult;
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Agent status posts — deliberate progress messages into the activity stream.
3
3
  *
4
- * Surface: `agents feed post <text>` (agent-callable; humans watch via
5
- * `agents feed` / `agents activity` / `agents events --module activity`).
4
+ * Surface: `agents feed post --title <subject> <body>` (agent-callable; humans
5
+ * watch via `agents feed` / `agents activity` / `agents events --module activity`).
6
6
  *
7
7
  * Identity is automatic: session id, agent, cwd, launch/pid/tmux provenance
8
8
  * are resolved from the process environment and the per-pid launch registry
9
- * (`lib/session/pid-registry.ts`). The agent only authors free-form text
10
- * no domain-specific flags (tickets, URLs, tracks).
9
+ * (`lib/session/pid-registry.ts`). The agent authors a short title + body
10
+ * no domain-specific flags (tickets, URLs, tracks). Phone `{message}` ends with
11
+ * a "Sent from agent/session on host" footer.
11
12
  *
12
13
  * Storage: append-only activity log as a `status.posted` milestone. Does NOT
13
14
  * open a feed block (blocks remain "needs you" state only).
@@ -23,6 +24,8 @@ import { isValidMailboxId } from './mailbox.js';
23
24
  import { listPidSessionEntries, readPidSessionEntry, } from './session/pid-registry.js';
24
25
  /** Soft cap so a runaway agent can't flood the activity lane with essays. */
25
26
  export const STATUS_POST_MAX_CHARS = 500;
27
+ /** Title is a phone subject line - about four or five words, not a paragraph. */
28
+ export const STATUS_TITLE_MAX_CHARS = 60;
26
29
  /**
27
30
  * Resolve who is posting. Order:
28
31
  * 1. Explicit --session flag
@@ -241,22 +244,46 @@ export function buildAttachments(tokens, ctx) {
241
244
  }
242
245
  return out;
243
246
  }
247
+ /**
248
+ * Collapse whitespace and strip em/en dashes (house rule: no em-dashes in
249
+ * agent-authored outbound copy — phones and plain text render them poorly).
250
+ */
251
+ export function scrubDashes(text) {
252
+ return text
253
+ .replace(/\u2014/g, ' - ') // em dash —
254
+ .replace(/\u2013/g, ' - ') // en dash –
255
+ .replace(/\s+/g, ' ')
256
+ .trim();
257
+ }
244
258
  export function normalizeStatusText(text) {
245
- const collapsed = text.replace(/\s+/g, ' ').trim();
259
+ const collapsed = scrubDashes(text);
246
260
  if (!collapsed)
247
261
  return '';
248
262
  if (collapsed.length <= STATUS_POST_MAX_CHARS)
249
263
  return collapsed;
250
264
  return `${collapsed.slice(0, STATUS_POST_MAX_CHARS - 1)}…`;
251
265
  }
266
+ /** Normalize a short subject line for a post. */
267
+ export function normalizeStatusTitle(title) {
268
+ const collapsed = scrubDashes(title);
269
+ if (!collapsed)
270
+ return '';
271
+ if (collapsed.length <= STATUS_TITLE_MAX_CHARS)
272
+ return collapsed;
273
+ return `${collapsed.slice(0, STATUS_TITLE_MAX_CHARS - 1)}…`;
274
+ }
252
275
  /**
253
276
  * Append a `status.posted` milestone for the calling agent.
254
- * Throws if text is empty or session identity cannot be resolved.
277
+ * Throws if title/text is empty or session identity cannot be resolved.
255
278
  */
256
279
  export function postFeedStatus(input) {
280
+ const title = normalizeStatusTitle(input.title ?? '');
257
281
  const detail = normalizeStatusText(input.text);
282
+ if (!title) {
283
+ throw new Error('Title is empty. Usage: agents feed post --title "Short subject" "what just happened"');
284
+ }
258
285
  if (!detail) {
259
- throw new Error('Status text is empty. Usage: agents feed post "what just happened"');
286
+ throw new Error('Status text is empty. Usage: agents feed post --title "Short subject" "what just happened"');
260
287
  }
261
288
  const identity = resolvePostIdentity(input);
262
289
  if (!identity) {
@@ -285,6 +312,7 @@ export function postFeedStatus(input) {
285
312
  cwd: identity.cwd,
286
313
  agent: identity.agent,
287
314
  tool: 'feed.post',
315
+ title,
288
316
  detail,
289
317
  ...(project ? { project } : {}),
290
318
  ...(identity.pid !== undefined ? { pid: identity.pid } : {}),
package/dist/lib/feed.js CHANGED
@@ -255,7 +255,7 @@ export function clearBlockLifecycle(blockId, root) {
255
255
  export function buildDeclaredBlock(agent, input) {
256
256
  const text = input.text.trim().replace(/\s+/g, ' ');
257
257
  if (!text) {
258
- throw new Error('Block text is empty. Usage: agents feed post "what you need from the user" --blocked');
258
+ throw new Error('Block text is empty. Usage: agents feed post --title "Short subject" "what you need from the user" --blocked');
259
259
  }
260
260
  const options = (input.options ?? [])
261
261
  .map((label) => label.trim())
@@ -0,0 +1,37 @@
1
+ /**
2
+ * One synchronous, disk-only read facade over the fleet/usage caches.
3
+ *
4
+ * These readers NEVER touch the network and NEVER SSH — they read the caches
5
+ * the daemon keeps warm (usage snapshot, projected headroom, fleet status) and
6
+ * return instantly. That is the whole point: the routing hot path
7
+ * (`agents run` → rotate.ts), device affinity (smart-launch.ts), and Factory can
8
+ * consult live-ish fleet state without paying a provider fetch or an ssh probe
9
+ * on a latency-sensitive path.
10
+ *
11
+ * Writers live elsewhere (the daemon's `runUsageRefresh` for usage/headroom,
12
+ * `runFleetStatusPublish` for the fleet-status mirror); this module is the read
13
+ * side only.
14
+ */
15
+ import { type UsageSnapshot } from './usage.js';
16
+ import { type FleetStatusRow } from './fleet-status.js';
17
+ /**
18
+ * The fleet-status union the daemon publishes (own row) and the fleet-status
19
+ * command unions (peer rows) — this host's stats + agent workload for every
20
+ * known host, keyed by host. Cache-only: a cold mirror yields an empty map.
21
+ */
22
+ export declare function readFleetStatus(): Record<string, FleetStatusRow>;
23
+ export type { FleetStatusRow };
24
+ /** The last cached usage snapshot for an account, or null. Cache-only. */
25
+ export declare function readAccountUsage(usageKey: string): UsageSnapshot | null;
26
+ /** An account's projected headroom, as published by the daemon refresher. */
27
+ export interface AccountHeadroom {
28
+ status: 'available' | 'rate_limited' | null;
29
+ /** Projected minutes until the session window caps; null = unknown/idle. */
30
+ minutesToLimit: number | null;
31
+ }
32
+ /**
33
+ * The daemon-computed headroom for an account, or null when nothing has been
34
+ * published yet. Cache-only — a cold cache simply yields null, and callers
35
+ * degrade to snapshot-only behavior.
36
+ */
37
+ export declare function readAccountHeadroom(usageKey: string): AccountHeadroom | null;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * One synchronous, disk-only read facade over the fleet/usage caches.
3
+ *
4
+ * These readers NEVER touch the network and NEVER SSH — they read the caches
5
+ * the daemon keeps warm (usage snapshot, projected headroom, fleet status) and
6
+ * return instantly. That is the whole point: the routing hot path
7
+ * (`agents run` → rotate.ts), device affinity (smart-launch.ts), and Factory can
8
+ * consult live-ish fleet state without paying a provider fetch or an ssh probe
9
+ * on a latency-sensitive path.
10
+ *
11
+ * Writers live elsewhere (the daemon's `runUsageRefresh` for usage/headroom,
12
+ * `runFleetStatusPublish` for the fleet-status mirror); this module is the read
13
+ * side only.
14
+ */
15
+ import { readClaudeUsageCache } from './usage.js';
16
+ import { readHeadroomEntry } from './usage-refresh.js';
17
+ import { readFleetStatus as readFleetStatusMirror } from './fleet-status.js';
18
+ /**
19
+ * The fleet-status union the daemon publishes (own row) and the fleet-status
20
+ * command unions (peer rows) — this host's stats + agent workload for every
21
+ * known host, keyed by host. Cache-only: a cold mirror yields an empty map.
22
+ */
23
+ export function readFleetStatus() {
24
+ return readFleetStatusMirror();
25
+ }
26
+ /** The last cached usage snapshot for an account, or null. Cache-only. */
27
+ export function readAccountUsage(usageKey) {
28
+ return readClaudeUsageCache(usageKey);
29
+ }
30
+ /**
31
+ * The daemon-computed headroom for an account, or null when nothing has been
32
+ * published yet. Cache-only — a cold cache simply yields null, and callers
33
+ * degrade to snapshot-only behavior.
34
+ */
35
+ export function readAccountHeadroom(usageKey) {
36
+ const entry = readHeadroomEntry(usageKey);
37
+ if (!entry)
38
+ return null;
39
+ return { status: entry.status, minutesToLimit: entry.minutesToLimit };
40
+ }
@@ -0,0 +1,53 @@
1
+ import { type DeviceStats } from './devices/health.js';
2
+ /** Live agent workload on a host. */
3
+ export interface FleetAgentCounts {
4
+ /** Sessions actively working (status === 'running'). */
5
+ running: number;
6
+ /** Total live sessions regardless of status (running/idle/input-required/…). */
7
+ live: number;
8
+ /** Running count broken down by context: terminal / teams / cloud / headless. */
9
+ byContext: Record<string, number>;
10
+ /** Running count broken down by agent CLI: claude / codex / cursor / … */
11
+ byAgent: Record<string, number>;
12
+ }
13
+ /** One host's published status row. */
14
+ export interface FleetStatusRow {
15
+ host: string;
16
+ agents: FleetAgentCounts;
17
+ /** Resource stats from the local probe; null when the probe produced nothing. */
18
+ stats: DeviceStats | null;
19
+ /** Epoch ms this row was computed. */
20
+ capturedAt: number;
21
+ }
22
+ /** Minimal shape needed to count workload — a subset of ActiveSession. */
23
+ type CountableSession = {
24
+ status?: string;
25
+ context?: string;
26
+ kind?: string;
27
+ pidAlive?: boolean;
28
+ };
29
+ /**
30
+ * Tally running-agent workload from a host's live sessions. "running" is the
31
+ * actively-working set (`status === 'running'`); `live` is every session the
32
+ * host is tracking. Pure so the tally is unit-tested without a live scan.
33
+ */
34
+ export declare function computeAgentCounts(sessions: ReadonlyArray<CountableSession>): FleetAgentCounts;
35
+ /**
36
+ * Probe THIS host's status — resource stats locally (no SSH) and agent workload
37
+ * from the local live-session set (`getActiveSessions({ localOnly: true })`,
38
+ * which never dials a remote host). Never throws: a failed sub-probe degrades to
39
+ * null stats / zero counts.
40
+ */
41
+ export declare function probeLocalFleetStatus(host: string, now?: number): Promise<FleetStatusRow>;
42
+ export declare function setFleetStatusMirrorPathForTest(mirrorPath: string | null): string | null;
43
+ /** Read the whole fleet-status mirror (best-effort; missing/corrupt → empty). */
44
+ export declare function readFleetStatus(): Record<string, FleetStatusRow>;
45
+ /** Merge rows into the mirror (best-effort; preserves other hosts' rows). */
46
+ export declare function writeFleetStatusRows(entries: Record<string, FleetStatusRow>): void;
47
+ /**
48
+ * Publish THIS host's row into the mirror (probe self, no SSH). The daemon calls
49
+ * this on its warm tick; it is the whole of the daemon's fleet-status duty now
50
+ * that cross-host probing is gone.
51
+ */
52
+ export declare function publishLocalFleetStatus(host: string): Promise<FleetStatusRow>;
53
+ export {};