@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.
- package/CHANGELOG.md +198 -0
- package/README.md +2 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +63 -40
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/monitors.js +8 -6
- package/dist/commands/perf.d.ts +13 -0
- package/dist/commands/perf.js +80 -23
- package/dist/commands/projects.d.ts +11 -0
- package/dist/commands/projects.js +153 -21
- package/dist/commands/routines.js +46 -1
- package/dist/commands/ssh.js +69 -0
- package/dist/commands/trends.d.ts +2 -0
- package/dist/commands/trends.js +158 -0
- package/dist/commands/usage.d.ts +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +90 -45
- package/dist/index.js +14 -1
- package/dist/lib/activity.d.ts +6 -0
- package/dist/lib/activity.js +3 -0
- package/dist/lib/agents.js +2 -2
- package/dist/lib/analytics/dashboard.d.ts +11 -0
- package/dist/lib/analytics/dashboard.js +31 -0
- package/dist/lib/analytics/recipes.d.ts +32 -0
- package/dist/lib/analytics/recipes.js +316 -0
- package/dist/lib/analytics/usage-db.d.ts +84 -0
- package/dist/lib/analytics/usage-db.js +301 -0
- package/dist/lib/browser/service.js +18 -0
- package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- package/dist/lib/cli-resources.d.ts +20 -0
- package/dist/lib/cli-resources.js +48 -1
- package/dist/lib/daemon.js +51 -14
- package/dist/lib/devices/health-report.d.ts +5 -0
- package/dist/lib/devices/health-report.js +3 -0
- package/dist/lib/feed-broadcast.d.ts +93 -12
- package/dist/lib/feed-broadcast.js +235 -30
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.js +1 -1
- package/dist/lib/fleet-cache.d.ts +37 -0
- package/dist/lib/fleet-cache.js +40 -0
- package/dist/lib/fleet-status.d.ts +53 -0
- package/dist/lib/fleet-status.js +120 -0
- package/dist/lib/friction-heuristics.d.ts +32 -0
- package/dist/lib/friction-heuristics.js +47 -0
- package/dist/lib/hooks/cache.js +28 -6
- package/dist/lib/hooks/profile.d.ts +8 -0
- package/dist/lib/hooks/profile.js +14 -4
- package/dist/lib/hooks.d.ts +16 -1
- package/dist/lib/hooks.js +133 -18
- package/dist/lib/linear-cache.d.ts +63 -0
- package/dist/lib/linear-cache.js +146 -0
- package/dist/lib/linear-project-counts.d.ts +35 -5
- package/dist/lib/linear-project-counts.js +61 -16
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +7 -0
- package/dist/lib/menubar/install-menubar.js +36 -6
- package/dist/lib/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- package/dist/lib/perf/db.d.ts +6 -1
- package/dist/lib/perf/db.js +35 -5
- package/dist/lib/perf/types.d.ts +10 -0
- package/dist/lib/project-doctor.d.ts +36 -0
- package/dist/lib/project-doctor.js +45 -0
- package/dist/lib/project-import.d.ts +11 -1
- package/dist/lib/project-import.js +17 -3
- package/dist/lib/project-status.d.ts +25 -5
- package/dist/lib/project-status.js +48 -6
- package/dist/lib/rotate.d.ts +27 -0
- package/dist/lib/rotate.js +44 -17
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +39 -0
- package/dist/lib/runner.js +34 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/usage-db.d.ts +3 -63
- package/dist/lib/secrets/usage-db.js +46 -186
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +14 -3
- package/dist/lib/session/discover.d.ts +3 -0
- package/dist/lib/session/discover.js +8 -0
- package/dist/lib/session/types.d.ts +1 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +31 -3
- package/dist/lib/state.js +53 -10
- package/dist/lib/types.d.ts +14 -4
- package/dist/lib/usage-refresh.d.ts +106 -0
- package/dist/lib/usage-refresh.js +238 -0
- package/dist/lib/usage.d.ts +152 -17
- package/dist/lib/usage.js +393 -79
- 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
|
-
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 (!
|
|
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
|
-
*
|
|
176
|
-
* that
|
|
177
|
-
*
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
return { name, ok: false, error: tail || `exited ${result.status}` };
|
|
395
|
+
else {
|
|
396
|
+
outcomes.push(runCommandSink(sink.name, sink.argv ?? [], timeoutMs));
|
|
192
397
|
}
|
|
193
|
-
|
|
194
|
-
|
|
398
|
+
}
|
|
399
|
+
return outcomes;
|
|
195
400
|
}
|
package/dist/lib/feed-post.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/lib/feed-post.js
CHANGED
|
@@ -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 <
|
|
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
|
|
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
|
|
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 {};
|