@phnx-labs/agents-cli 1.20.83 → 1.20.85
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 +72 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +15 -0
- package/dist/commands/feed.js +135 -31
- package/dist/commands/sessions.js +12 -6
- package/dist/lib/activity.d.ts +8 -0
- package/dist/lib/activity.js +5 -0
- package/dist/lib/feed-broadcast.d.ts +66 -0
- package/dist/lib/feed-broadcast.js +131 -0
- package/dist/lib/hosts/remote-cmd.js +5 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/permissions.d.ts +21 -0
- package/dist/lib/permissions.js +57 -2
- package/dist/lib/run-notify.d.ts +27 -0
- package/dist/lib/run-notify.js +54 -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/session/active.d.ts +15 -0
- package/dist/lib/session/active.js +17 -0
- package/dist/lib/types.d.ts +10 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.85
|
|
4
|
+
|
|
5
|
+
- **`agents feed post` can now be mirrored to the systems you actually watch.**
|
|
6
|
+
A post was durable but local: an operator away from every terminal never saw
|
|
7
|
+
it, and the tracker that owns the work heard nothing. Declare sinks under
|
|
8
|
+
`feed.broadcast` in `agents.yaml` — argv templates, not built-in integrations —
|
|
9
|
+
and each post is fanned out to them. `--level important` marks a post worth
|
|
10
|
+
interrupting someone over, so a sink with `minLevel: important` never fires on
|
|
11
|
+
a routine "CI green"; a template referencing `{ticket}` is skipped when no
|
|
12
|
+
ticket is known, and the ticket is joined from the session index rather than
|
|
13
|
+
asked for as a flag. `{message}` composes the human line a messaging sink wants
|
|
14
|
+
— `<project> · <text>` plus the first attached URL — so an out-of-band ping
|
|
15
|
+
leads with the project and carries a clickable link. Delivery is best-effort
|
|
16
|
+
and reported per sink; a mirror that fails never costs you the post. Source:
|
|
17
|
+
`apps/cli/src/lib/feed-broadcast.ts`, `apps/cli/src/commands/feed.ts`,
|
|
18
|
+
`apps/cli/docs/06-observability.md`.
|
|
19
|
+
|
|
20
|
+
- **`agents feed --filter updates` now shows the progress posts agents actually
|
|
21
|
+
wrote, across the fleet.** The view read the most recent N activity events and
|
|
22
|
+
*then* kept `status.posted`, so routine `file.edited` churn filled the whole
|
|
23
|
+
slice — a box with six real posts rendered "0 posts" (and `--json` returned one
|
|
24
|
+
of six). `readRecentActivity` gained `events` / `tier` filters that apply before
|
|
25
|
+
the limit, so the limit counts posts; the same fix restores the milestone lane
|
|
26
|
+
under `agents feed`. The updates view also fans out over SSH like the block view
|
|
27
|
+
(`-H/--host`, `--device`, `--local` to opt out), because an agent posts on
|
|
28
|
+
whichever box ran it. Source: `apps/cli/src/lib/activity.ts`,
|
|
29
|
+
`apps/cli/src/commands/feed.ts`.
|
|
30
|
+
|
|
31
|
+
- **`agents run --notify` posts a desktop notification when a headless run
|
|
32
|
+
finishes, and menu-bar quick dispatch now uses it.** The dispatch panel used to
|
|
33
|
+
post its "finished"/"failed" notice from the MenubarHelper's own
|
|
34
|
+
process-termination callback, so a helper that restarted mid-run — an upgrade
|
|
35
|
+
replacing the bundle, a crash — took the callback with it while the run carried
|
|
36
|
+
on reparented to launchd, and the dispatch could never report back. The run
|
|
37
|
+
process owns the notice now: armed on its own `exit`, so it covers local,
|
|
38
|
+
`--host` and `--lease` dispatch alike and survives anything that happens to the
|
|
39
|
+
launcher. The helper's click actions also accept `url:<https…>` so a completion
|
|
40
|
+
notification can open the PR or ticket the run produced. Source:
|
|
41
|
+
`apps/cli/src/lib/run-notify.ts`, `apps/cli/src/commands/exec.ts`,
|
|
42
|
+
`apps/cli/menubar/Sources/MenubarHelper/AgentsCLI.swift`,
|
|
43
|
+
`apps/cli/menubar/Sources/MenubarHelper/PromptPanel.swift`.
|
|
44
|
+
|
|
45
|
+
## 1.20.84
|
|
46
|
+
|
|
47
|
+
- **Agent onboarding cheat sheet and docs drift guard.** Added
|
|
48
|
+
`apps/cli/docs/AGENT-CHEATSHEET.md` as a one-page on-ramp for agents, wired it
|
|
49
|
+
from `apps/cli/AGENTS.md` and `apps/cli/docs/README.md`, and added
|
|
50
|
+
`scripts/verify-docs.sh` (plus a `verify-docs` npm script and CI job) to catch
|
|
51
|
+
broken relative links and missing entry-point wiring before merge.
|
|
52
|
+
|
|
53
|
+
- **Codex can now build, test, and install without escalating to YOLO.** Codex's
|
|
54
|
+
`workspace-write` sandbox blocks `$HOME`, so `cargo build`, `go build`, `npm/bun install`,
|
|
55
|
+
`pip install` etc. failed on their out-of-workspace cache writes (`~/.cargo`, `GOCACHE`,
|
|
56
|
+
`~/.npm`, `~/.cache`, …) — which is what pushed people to `--mode full`
|
|
57
|
+
(`--dangerously-bypass-approvals-and-sandbox`). agents-cli now writes a platform-resolved
|
|
58
|
+
baseline of **regenerable toolchain caches** into Codex's `config.toml`
|
|
59
|
+
(`[sandbox_workspace_write].writable_roots`) on permission sync — `~/.cargo`, `~/.rustup`,
|
|
60
|
+
`~/.npm`, `~/.bun`, `~/go`, `~/.deno`, `~/.gradle`, `~/.m2`, `~/.gem`, plus `~/Library/Caches`
|
|
61
|
+
+ `~/Library/pnpm` on macOS or `~/.cache` + `~/.local/{share,state}` on Linux. Credential dirs
|
|
62
|
+
(`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config`, `~/.netrc`) are deliberately excluded, so
|
|
63
|
+
`--mode auto` stays a real sandbox — far narrower than danger-full-access. Any
|
|
64
|
+
`writable_roots` you set yourself are preserved (unioned, never clobbered). Source:
|
|
65
|
+
`apps/cli/src/lib/permissions.ts` (`codexDefaultWritableRoots`, `mergeCodexSandboxWrite`).
|
|
66
|
+
|
|
67
|
+
- **`agents sessions` team rows now show the team's target and teammate, not just
|
|
68
|
+
the slug.** Each teammate row reads `<team> · <teammate> · by <orchestrator> ·
|
|
69
|
+
<live turn | mission>`, where the mission is a one-line summary of the teammate's
|
|
70
|
+
spawn prompt (`assignedTask`, shown even before it has a transcript). Several
|
|
71
|
+
teams from one orchestrator stay legible (distinct team names) and each says what
|
|
72
|
+
it is for. `--active --json` carries `assignedTask`. Source:
|
|
73
|
+
`apps/cli/src/lib/session/active.ts`, `apps/cli/src/commands/sessions.ts`.
|
|
74
|
+
|
|
3
75
|
## 1.20.83
|
|
4
76
|
|
|
5
77
|
- **Routines now treat date-specific cron schedules as one-shot jobs (RUSH-2074).**
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/exec.js
CHANGED
|
@@ -367,6 +367,7 @@ export function registerRunCommand(program) {
|
|
|
367
367
|
.option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
|
|
368
368
|
.option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
|
|
369
369
|
.option('--name <slug>', 'Name the run — seeds the session label so it shows up as `<name>` in `agents sessions` and resolves by it (and `agents hosts logs <name>` for --host runs) instead of an opaque id. An agent-generated title later refines the label; your name shows until then. Optional.')
|
|
370
|
+
.option('--notify', 'Post a desktop notification when a headless run finishes. Fired by this process on exit, so it survives whatever launched the run (the menu bar dispatching it, a terminal you closed).')
|
|
370
371
|
.option('--verbose', 'Show detailed execution logs')
|
|
371
372
|
.option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
|
|
372
373
|
.option('--no-tmux', 'Spawn the agent directly instead of wrapping it in the shared tmux session. Same effect as --raw / AGENTS_NO_TMUX=1. Use this to see the agent\'s full startup output when a launch is failing.')
|
|
@@ -492,6 +493,20 @@ export function registerRunCommand(program) {
|
|
|
492
493
|
// a native flag, not a prompt. Run interactively.
|
|
493
494
|
prompt = undefined;
|
|
494
495
|
}
|
|
496
|
+
// --notify: post a desktop notification when this run finishes. Armed on
|
|
497
|
+
// process exit so it covers EVERY dispatch path below (local, --host,
|
|
498
|
+
// --lease, the error path) instead of one branch. Only for headless runs
|
|
499
|
+
// — an interactive run ends in front of the person who started it.
|
|
500
|
+
if (options.notify && prompt !== undefined) {
|
|
501
|
+
const { armRunFinishNotification } = await import('../lib/run-notify.js');
|
|
502
|
+
armRunFinishNotification({
|
|
503
|
+
agent: agentSpec,
|
|
504
|
+
name: options.name,
|
|
505
|
+
prompt,
|
|
506
|
+
cwd: options.cwd ?? process.cwd(),
|
|
507
|
+
host: options.host,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
495
510
|
// A trailing @ is an explicit request to choose one installed account.
|
|
496
511
|
// Strip only that terminal marker; concrete agent@version pins retain
|
|
497
512
|
// their existing meaning in every dispatch path below.
|
package/dist/commands/feed.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified } from '../lib/feed.js';
|
|
3
|
-
import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate } from '../lib/activity.js';
|
|
3
|
+
import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate, mergeActivityEvents, parseActivityPayload, } from '../lib/activity.js';
|
|
4
4
|
import { postFeedStatus } from '../lib/feed-post.js';
|
|
5
|
+
import { parseFeedPostLevel, planFeedBroadcast, runFeedBroadcast, } from '../lib/feed-broadcast.js';
|
|
6
|
+
import { getSessionById } from '../lib/session/db.js';
|
|
7
|
+
import { readMeta } from '../lib/state.js';
|
|
5
8
|
import { enrichBlocksFromSessions, groupBlocksByOutcome, isUnambiguousOutcomeAnswer, openBlocksForOutcome, stampBlockOutcomes, } from '../lib/feed-outcome.js';
|
|
6
9
|
import { classifyBlock, filterBlocksForFeed, suppressionDigest, } from '../lib/ask-classifier.js';
|
|
7
10
|
import { machineId, normalizeHost } from '../lib/machine-id.js';
|
|
@@ -234,6 +237,7 @@ export function registerFeedCommand(program) {
|
|
|
234
237
|
.argument('<text...>', 'What just happened — one short human line')
|
|
235
238
|
.option('--session <id>', 'Session id escape hatch (default: auto from env / pid registry)')
|
|
236
239
|
.option('--attach <path-or-url...>', 'Attach an artifact (local file or URL); repeatable')
|
|
240
|
+
.option('--level <level>', 'How loudly to broadcast: milestone (default) or important. Configured sinks with minLevel: important only fire on the latter.', 'milestone')
|
|
237
241
|
.option('--json', 'Emit the written event as JSON')
|
|
238
242
|
.addHelpText('after', `
|
|
239
243
|
Examples:
|
|
@@ -242,11 +246,19 @@ Examples:
|
|
|
242
246
|
agents feed post "cover render ready" --attach ./out/cover.png
|
|
243
247
|
agents feed post "ready for review" --json
|
|
244
248
|
|
|
249
|
+
# Worth interrupting someone over — reaches sinks gated on minLevel: important:
|
|
250
|
+
agents feed post "release blocked: npm token expired" --level important
|
|
251
|
+
|
|
245
252
|
# Outside a run, pass the session explicitly:
|
|
246
253
|
agents feed post "manual note" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47
|
|
247
254
|
|
|
248
255
|
Identity (session, agent, host, runtime, pid, launchId) is stamped automatically.
|
|
249
|
-
Domain facts (tickets, PRs) are not CLI flags —
|
|
256
|
+
Domain facts (tickets, PRs) are not CLI flags — the ticket is joined from the
|
|
257
|
+
session index at post time, so a broadcast sink can comment on it without the
|
|
258
|
+
agent having to remember it.
|
|
259
|
+
|
|
260
|
+
Configure where a post is mirrored under feed.broadcast in agents.yaml — see
|
|
261
|
+
docs/06-observability.md.
|
|
250
262
|
`)
|
|
251
263
|
.action((textParts, opts, cmd) => {
|
|
252
264
|
// Parent `feed` also declares `--json` (for the list view). Commander
|
|
@@ -255,19 +267,23 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
|
|
|
255
267
|
const flags = {
|
|
256
268
|
session: opts?.session ?? cmd?.opts?.()?.session,
|
|
257
269
|
attach: opts?.attach ?? cmd?.opts?.()?.attach,
|
|
270
|
+
level: opts?.level ?? cmd?.opts?.()?.level,
|
|
258
271
|
json: Boolean(opts?.json ?? cmd?.opts?.()?.json ?? cmd?.parent?.opts?.()?.json),
|
|
259
272
|
};
|
|
260
273
|
try {
|
|
274
|
+
const level = parseFeedPostLevel(flags.level);
|
|
261
275
|
const { event } = postFeedStatus({
|
|
262
276
|
text: Array.isArray(textParts) ? textParts.join(' ') : String(textParts ?? ''),
|
|
263
277
|
sessionId: flags.session,
|
|
264
278
|
attach: flags.attach,
|
|
265
279
|
});
|
|
280
|
+
const outcomes = broadcastPostedEvent(event, level);
|
|
266
281
|
if (flags.json) {
|
|
267
|
-
console.log(JSON.stringify(event, null, 2));
|
|
282
|
+
console.log(JSON.stringify(outcomes.length ? { ...event, broadcast: outcomes } : event, null, 2));
|
|
268
283
|
return;
|
|
269
284
|
}
|
|
270
285
|
console.log(formatProgressUpdate(event));
|
|
286
|
+
reportBroadcast(outcomes);
|
|
271
287
|
}
|
|
272
288
|
catch (err) {
|
|
273
289
|
console.error(chalk.red(err.message));
|
|
@@ -305,20 +321,39 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
|
|
|
305
321
|
}
|
|
306
322
|
}
|
|
307
323
|
}
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
324
|
+
// Trailing lane under the block views: `--filter all` appends the same
|
|
325
|
+
// fleet-wide updates section, anything else the compact local lane.
|
|
326
|
+
const renderTrailingActivity = async () => {
|
|
327
|
+
if (filter === 'all') {
|
|
328
|
+
console.log();
|
|
329
|
+
renderUpdatesView(await gatherStatusPosts({
|
|
330
|
+
limit: UPDATES_VIEW_LIMIT, hosts: opts.host, local: opts.local, includeLocal, self,
|
|
331
|
+
}));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (includeLocal)
|
|
335
|
+
renderActivityLane();
|
|
336
|
+
};
|
|
337
|
+
// Updates view: deliberate progress posts only (blocks are decisions, not
|
|
338
|
+
// announcements). Short-circuits the block pipeline — no dispatch policy —
|
|
339
|
+
// but fans out like the block view, because a post lands on whichever box
|
|
340
|
+
// ran the agent.
|
|
311
341
|
if (filter === 'updates') {
|
|
312
342
|
for (const warning of setupWarnings) {
|
|
313
343
|
console.error(chalk.yellow(`Feed hook setup warning: ${warning}`));
|
|
314
344
|
}
|
|
345
|
+
const updates = await gatherStatusPosts({
|
|
346
|
+
limit: opts.json ? UPDATES_JSON_LIMIT : UPDATES_VIEW_LIMIT,
|
|
347
|
+
hosts: opts.host,
|
|
348
|
+
local: opts.local,
|
|
349
|
+
includeLocal,
|
|
350
|
+
self,
|
|
351
|
+
});
|
|
315
352
|
if (opts.json) {
|
|
316
|
-
const updates = readRecentActivity({ sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000, limit: 100 })
|
|
317
|
-
.filter((e) => e.event === 'status.posted');
|
|
318
353
|
console.log(JSON.stringify(updates, null, 2));
|
|
319
354
|
return;
|
|
320
355
|
}
|
|
321
|
-
renderUpdatesView();
|
|
356
|
+
renderUpdatesView(updates);
|
|
322
357
|
return;
|
|
323
358
|
}
|
|
324
359
|
// Active sessions feed both the GC sweep and outcome enrichment (ticket/PR).
|
|
@@ -428,14 +463,7 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
|
|
|
428
463
|
}
|
|
429
464
|
if (blocks.length === 0) {
|
|
430
465
|
console.log(chalk.gray(digest ? 'No open blocks after stall suppression.' : 'No open blocks.'));
|
|
431
|
-
|
|
432
|
-
if (filter === 'all') {
|
|
433
|
-
console.log();
|
|
434
|
-
renderUpdatesView();
|
|
435
|
-
}
|
|
436
|
-
else
|
|
437
|
-
renderActivityLane();
|
|
438
|
-
}
|
|
466
|
+
await renderTrailingActivity();
|
|
439
467
|
return;
|
|
440
468
|
}
|
|
441
469
|
// Shared fleet-comms masthead (same family as `agents mailboxes`).
|
|
@@ -458,16 +486,45 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
|
|
|
458
486
|
});
|
|
459
487
|
for (const g of groups)
|
|
460
488
|
renderOutcomeGroup(g, self);
|
|
461
|
-
|
|
462
|
-
if (filter === 'all') {
|
|
463
|
-
console.log();
|
|
464
|
-
renderUpdatesView();
|
|
465
|
-
}
|
|
466
|
-
else
|
|
467
|
-
renderActivityLane();
|
|
468
|
-
}
|
|
489
|
+
await renderTrailingActivity();
|
|
469
490
|
});
|
|
470
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* Mirror a written post to the configured sinks (`feed.broadcast` in
|
|
494
|
+
* agents.yaml). The ticket is JOINED from the session index rather than asked
|
|
495
|
+
* for as a flag — it is a domain fact about the session, and an agent that has
|
|
496
|
+
* to remember a `--ticket` argument is an agent that will forget it. Returns the
|
|
497
|
+
* per-sink outcomes; an empty array means nothing is configured, which is the
|
|
498
|
+
* default and is not a failure.
|
|
499
|
+
*/
|
|
500
|
+
function broadcastPostedEvent(event, level) {
|
|
501
|
+
const config = readMeta().feed?.broadcast;
|
|
502
|
+
if (!config || Object.keys(config).length === 0)
|
|
503
|
+
return [];
|
|
504
|
+
const ticket = getSessionById(event.sessionId)?.ticketId;
|
|
505
|
+
const planned = planFeedBroadcast(config, {
|
|
506
|
+
text: event.detail ?? '',
|
|
507
|
+
level,
|
|
508
|
+
ticket,
|
|
509
|
+
project: event.project,
|
|
510
|
+
agent: event.agent,
|
|
511
|
+
host: event.host,
|
|
512
|
+
session: event.sessionId,
|
|
513
|
+
links: (event.attachments ?? [])
|
|
514
|
+
.map((a) => a.href)
|
|
515
|
+
.filter((href) => /^https?:\/\//i.test(href)),
|
|
516
|
+
});
|
|
517
|
+
return runFeedBroadcast(planned);
|
|
518
|
+
}
|
|
519
|
+
/** One line per sink that ran. Silent when nothing is configured. */
|
|
520
|
+
function reportBroadcast(outcomes) {
|
|
521
|
+
for (const o of outcomes) {
|
|
522
|
+
if (o.ok)
|
|
523
|
+
console.log(chalk.gray(` → ${o.name}`));
|
|
524
|
+
else
|
|
525
|
+
console.error(chalk.yellow(` → ${o.name} failed: ${o.error}`));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
471
528
|
/** Normalize a raw --filter value; unknown/empty falls back to the default. */
|
|
472
529
|
export function resolveFeedFilter(raw) {
|
|
473
530
|
const v = (raw ?? '').trim().toLowerCase();
|
|
@@ -490,15 +547,59 @@ function renderActivityEntry(ev) {
|
|
|
490
547
|
console.log(formatActivityLine(ev, { showHost: true }));
|
|
491
548
|
}
|
|
492
549
|
}
|
|
550
|
+
/** How far back the updates view looks for deliberate progress posts. */
|
|
551
|
+
const UPDATES_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
552
|
+
/** Posts kept per machine in the rendered view / the `--json` payload. */
|
|
553
|
+
const UPDATES_VIEW_LIMIT = 30;
|
|
554
|
+
const UPDATES_JSON_LIMIT = 100;
|
|
555
|
+
/**
|
|
556
|
+
* The most recent `limit` deliberate progress posts on THIS machine, newest
|
|
557
|
+
* first. The event filter is pushed into the reader so `limit` counts posts —
|
|
558
|
+
* slicing first and filtering after returned an empty view on a busy box, where
|
|
559
|
+
* routine `file.edited` hook events fill the whole slice.
|
|
560
|
+
*/
|
|
561
|
+
function readStatusPosts(limit) {
|
|
562
|
+
return readRecentActivity({
|
|
563
|
+
sinceMs: Date.now() - UPDATES_WINDOW_MS,
|
|
564
|
+
limit,
|
|
565
|
+
events: ['status.posted'],
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Progress posts across the fleet, newest first. An agent posts on whichever
|
|
570
|
+
* box it runs on, so a local-only read shows the operator a fraction of what
|
|
571
|
+
* the fleet reported. Peers are dialed with the same SSH fan-out the block view
|
|
572
|
+
* uses; `--local` (or the no-fanout env guard on a peer) keeps it to this box.
|
|
573
|
+
*/
|
|
574
|
+
async function gatherStatusPosts(opts) {
|
|
575
|
+
const local = opts.includeLocal ? readStatusPosts(opts.limit) : [];
|
|
576
|
+
const forceLocal = opts.local === true || process.env[FEED_NO_FANOUT_ENV] === '1';
|
|
577
|
+
if (forceLocal)
|
|
578
|
+
return local;
|
|
579
|
+
const remoteHosts = opts.hosts?.length ? remoteFeedHostsToDial(opts.hosts, opts.self) : undefined;
|
|
580
|
+
if (opts.hosts?.length && (!remoteHosts || remoteHosts.length === 0))
|
|
581
|
+
return local;
|
|
582
|
+
const remote = await gatherRemoteAgentsJson({
|
|
583
|
+
args: ['feed', '--filter', 'updates', '--json'],
|
|
584
|
+
noFanoutEnv: FEED_NO_FANOUT_ENV,
|
|
585
|
+
hosts: remoteHosts,
|
|
586
|
+
parse: parseActivityPayload,
|
|
587
|
+
});
|
|
588
|
+
return mergeActivityEvents(local, remote.items).slice(0, opts.limit);
|
|
589
|
+
}
|
|
493
590
|
/**
|
|
494
591
|
* Render the **Updates** view: deliberate progress posts only (`status.posted`),
|
|
495
592
|
* recency-ordered, with rich identity chips. Pure `file.edited` / git-hook noise
|
|
496
593
|
* is excluded so operators see announcements, not tool churn.
|
|
497
594
|
*/
|
|
498
|
-
function renderUpdatesView(
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
595
|
+
function renderUpdatesView(updates) {
|
|
596
|
+
const hosts = new Set(updates.map((e) => e.host).filter(Boolean));
|
|
597
|
+
console.log(masthead({
|
|
598
|
+
title: 'updates',
|
|
599
|
+
accent: 'cyan',
|
|
600
|
+
host: hosts.size > 1 ? `${hosts.size} machines` : (updates[0]?.host ?? machineId()),
|
|
601
|
+
right: `${updates.length} post${updates.length === 1 ? '' : 's'}`,
|
|
602
|
+
}));
|
|
502
603
|
console.log();
|
|
503
604
|
if (updates.length === 0) {
|
|
504
605
|
console.log(chalk.gray(' No progress updates yet. Agents post them with `agents feed post "…"`.'));
|
|
@@ -516,8 +617,11 @@ function renderUpdatesView(limit = 30) {
|
|
|
516
617
|
* when empty.
|
|
517
618
|
*/
|
|
518
619
|
function renderActivityLane(limit = 6) {
|
|
519
|
-
const events = readRecentActivity({
|
|
520
|
-
.
|
|
620
|
+
const events = readRecentActivity({
|
|
621
|
+
sinceMs: Date.now() - 24 * 60 * 60 * 1000,
|
|
622
|
+
limit,
|
|
623
|
+
tier: 'milestone',
|
|
624
|
+
});
|
|
521
625
|
if (events.length === 0)
|
|
522
626
|
return;
|
|
523
627
|
console.log(chalk.bold('\n recent activity'));
|
|
@@ -247,7 +247,13 @@ export function buildSessionDescription(s) {
|
|
|
247
247
|
return cleanPreview([todo, base].filter(Boolean).join(' · '));
|
|
248
248
|
}
|
|
249
249
|
if (s.context === 'teams') {
|
|
250
|
+
// A teams row identifies its TEAM, then the teammate within it, then who
|
|
251
|
+
// spun it up, then what it's working on — so several teams from one
|
|
252
|
+
// orchestrator stay distinct and each shows its target, not just its slug.
|
|
250
253
|
const parts = [s.teamName];
|
|
254
|
+
// Teammate name (distinct from the team slug) — which member this row is.
|
|
255
|
+
if (s.label && s.label !== s.teamName)
|
|
256
|
+
parts.push(s.label);
|
|
251
257
|
// Lineage: which orchestrator spun up this team. Prefer the resolved label,
|
|
252
258
|
// else the short session id, so "by whom" is answerable at a glance.
|
|
253
259
|
const orch = s.orchestratorLabel || (s.orchestratorSessionId ? s.orchestratorSessionId.slice(0, 8) : '');
|
|
@@ -255,12 +261,12 @@ export function buildSessionDescription(s) {
|
|
|
255
261
|
parts.push(`by ${orch}`);
|
|
256
262
|
if (todo)
|
|
257
263
|
parts.push(todo);
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
parts.push(
|
|
264
|
+
// Target: the live latest turn if working, else the assigned mission (the
|
|
265
|
+
// team's task/target, shown even before the teammate has a transcript), else
|
|
266
|
+
// the transcript topic.
|
|
267
|
+
const target = s.preview || s.assignedTask || s.topic;
|
|
268
|
+
if (target)
|
|
269
|
+
parts.push(target);
|
|
264
270
|
return cleanPreview(parts.filter(Boolean).join(' · '));
|
|
265
271
|
}
|
|
266
272
|
// Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
|
package/dist/lib/activity.d.ts
CHANGED
|
@@ -105,6 +105,14 @@ export interface RecentActivityOptions {
|
|
|
105
105
|
root?: string;
|
|
106
106
|
/** Per-session tail budget in bytes. */
|
|
107
107
|
maxBytesPerSession?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Only include these event names. Applied BEFORE `limit`, so asking for a
|
|
110
|
+
* rare event (a deliberate `status.posted`) returns that many of it instead
|
|
111
|
+
* of however many survive a slice dominated by routine `file.edited` churn.
|
|
112
|
+
*/
|
|
113
|
+
events?: string[];
|
|
114
|
+
/** Only include events in these tiers. Applied BEFORE `limit`, as `events` is. */
|
|
115
|
+
tier?: ActivityTier;
|
|
108
116
|
}
|
|
109
117
|
/**
|
|
110
118
|
* Merge recent events across every session's log, newest first. Reads only the
|
package/dist/lib/activity.js
CHANGED
|
@@ -194,9 +194,14 @@ export function listActivitySessions(root) {
|
|
|
194
194
|
export function readRecentActivity(opts = {}) {
|
|
195
195
|
const dir = opts.root ?? getActivityDir();
|
|
196
196
|
const sinceMs = opts.sinceMs ?? 0;
|
|
197
|
+
const wanted = opts.events && opts.events.length > 0 ? new Set(opts.events) : null;
|
|
197
198
|
const all = [];
|
|
198
199
|
for (const sessionId of listActivitySessions(dir)) {
|
|
199
200
|
for (const ev of readSessionActivity(sessionId, dir, opts.maxBytesPerSession)) {
|
|
201
|
+
if (wanted && !wanted.has(ev.event))
|
|
202
|
+
continue;
|
|
203
|
+
if (opts.tier && ev.tier !== opts.tier)
|
|
204
|
+
continue;
|
|
200
205
|
const t = Date.parse(ev.ts);
|
|
201
206
|
if (Number.isFinite(t) && t >= sinceMs)
|
|
202
207
|
all.push(ev);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** How loudly a post asks to be heard. Ordered — `important` implies milestone. */
|
|
2
|
+
export type FeedPostLevel = 'milestone' | 'important';
|
|
3
|
+
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
|
|
4
|
+
export declare function parseFeedPostLevel(raw: string | undefined): FeedPostLevel;
|
|
5
|
+
export interface FeedSinkConfig {
|
|
6
|
+
/**
|
|
7
|
+
* argv to run, with `{placeholder}` tokens substituted. First element is the
|
|
8
|
+
* program; it is spawned directly (no shell), so quoting is not a concern and
|
|
9
|
+
* post text can never become shell syntax.
|
|
10
|
+
*/
|
|
11
|
+
command: string[];
|
|
12
|
+
/** Lowest post level that reaches this sink. Defaults to `milestone` (all posts). */
|
|
13
|
+
minLevel?: FeedPostLevel;
|
|
14
|
+
}
|
|
15
|
+
/** `feed.broadcast` in agents.yaml — sink name → what to run. */
|
|
16
|
+
export type FeedBroadcastConfig = Record<string, FeedSinkConfig>;
|
|
17
|
+
/** Everything a template may interpolate. Absent values skip templates that need them. */
|
|
18
|
+
export interface FeedBroadcastContext {
|
|
19
|
+
/** The post text, verbatim. */
|
|
20
|
+
text: string;
|
|
21
|
+
level: FeedPostLevel;
|
|
22
|
+
/** Tracker id for the work, e.g. `RUSH-2081`. */
|
|
23
|
+
ticket?: string;
|
|
24
|
+
/** Repo/project the post came from. */
|
|
25
|
+
project?: string;
|
|
26
|
+
agent?: string;
|
|
27
|
+
host?: string;
|
|
28
|
+
session?: string;
|
|
29
|
+
/** URLs attached to the post — the PR, the ticket, a shared plan. */
|
|
30
|
+
links?: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface PlannedSink {
|
|
33
|
+
name: string;
|
|
34
|
+
argv: string[];
|
|
35
|
+
}
|
|
36
|
+
export interface SinkOutcome {
|
|
37
|
+
name: string;
|
|
38
|
+
ok: boolean;
|
|
39
|
+
/** stderr tail when the sink failed, for the warning line. */
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A human-facing one-liner for a messaging sink: what project, what happened,
|
|
44
|
+
* and the link to go read more. Leading with the project is deliberate — a
|
|
45
|
+
* message that opens with an agent name tells the reader nothing about which of
|
|
46
|
+
* their projects just moved.
|
|
47
|
+
*/
|
|
48
|
+
export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
|
|
49
|
+
/**
|
|
50
|
+
* Substitute `{placeholder}` tokens in an argv template. Returns undefined when
|
|
51
|
+
* the template needs a value this post does not have — the sink is then skipped
|
|
52
|
+
* rather than run with an empty argument, which is how a `linear update --comment`
|
|
53
|
+
* would otherwise comment on nothing.
|
|
54
|
+
*/
|
|
55
|
+
export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Which sinks this post reaches, in config order. Pure — the dry-run listing and
|
|
58
|
+
* the real fan-out plan through here, so what `--dry-run` shows is what runs.
|
|
59
|
+
*/
|
|
60
|
+
export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext): PlannedSink[];
|
|
61
|
+
/**
|
|
62
|
+
* Run the planned sinks. Each is a direct spawn with a bounded lifetime; a sink
|
|
63
|
+
* that fails or is not installed is reported, never thrown — the post is already
|
|
64
|
+
* written and must not be undone by a mirror that could not be reached.
|
|
65
|
+
*/
|
|
66
|
+
export declare function runFeedBroadcast(planned: PlannedSink[], timeoutMs?: number): SinkOutcome[];
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan an `agents feed post` out to the systems the operator actually watches.
|
|
3
|
+
*
|
|
4
|
+
* A post is already durable — it lands in the append-only activity log and shows
|
|
5
|
+
* up in `agents feed --filter updates`. But an operator who is away from every
|
|
6
|
+
* terminal never sees it, and the tracker that owns the work (a Linear ticket,
|
|
7
|
+
* a GitHub issue) hears nothing at all. So a post can also be mirrored outward.
|
|
8
|
+
*
|
|
9
|
+
* Sinks are **argv templates from config**, never hardcoded integrations. This
|
|
10
|
+
* CLI ships Apache-2.0 and must not depend on one person's tracker or messaging
|
|
11
|
+
* stack; declaring `[linear, update, "{ticket}", --comment, "{text}"]` in
|
|
12
|
+
* `agents.yaml` keeps the coupling in the operator's config where it belongs,
|
|
13
|
+
* and lets someone else point the same mechanism at `jira`, `gh issue comment`,
|
|
14
|
+
* or a webhook script.
|
|
15
|
+
*
|
|
16
|
+
* Two rules decide whether a sink runs, both derived from the post itself:
|
|
17
|
+
*
|
|
18
|
+
* - **Level.** `minLevel: important` keeps a sink for the posts worth
|
|
19
|
+
* interrupting someone over, so a routine "CI green" does not buzz a phone.
|
|
20
|
+
* - **Placeholders.** A template that references `{ticket}` is skipped when no
|
|
21
|
+
* ticket is known. The template declares what it needs; nothing has to
|
|
22
|
+
* restate it as a flag, and a sink can never fire with a hole in its argv.
|
|
23
|
+
*
|
|
24
|
+
* Delivery is best-effort and reported: a sink that fails prints a warning and
|
|
25
|
+
* the post still stands. Losing a mirror must never cost the operator the post.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from 'child_process';
|
|
28
|
+
const LEVEL_RANK = { milestone: 0, important: 1 };
|
|
29
|
+
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
|
|
30
|
+
export function parseFeedPostLevel(raw) {
|
|
31
|
+
const v = (raw ?? '').trim().toLowerCase();
|
|
32
|
+
if (!v || v === 'milestone')
|
|
33
|
+
return 'milestone';
|
|
34
|
+
if (v === 'important')
|
|
35
|
+
return 'important';
|
|
36
|
+
throw new Error(`Unknown --level '${raw}'. Use milestone or important.`);
|
|
37
|
+
}
|
|
38
|
+
const PLACEHOLDER = /\{([a-z]+)\}/g;
|
|
39
|
+
/**
|
|
40
|
+
* A human-facing one-liner for a messaging sink: what project, what happened,
|
|
41
|
+
* and the link to go read more. Leading with the project is deliberate — a
|
|
42
|
+
* message that opens with an agent name tells the reader nothing about which of
|
|
43
|
+
* their projects just moved.
|
|
44
|
+
*/
|
|
45
|
+
export function composeBroadcastMessage(ctx) {
|
|
46
|
+
const head = ctx.project ? `${ctx.project} · ${ctx.text}` : ctx.text;
|
|
47
|
+
const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
|
|
48
|
+
return link ? `${head}\n${link}` : head;
|
|
49
|
+
}
|
|
50
|
+
/** The values a template may reference, resolved once per post. */
|
|
51
|
+
function templateVars(ctx) {
|
|
52
|
+
return {
|
|
53
|
+
text: ctx.text,
|
|
54
|
+
ticket: ctx.ticket,
|
|
55
|
+
project: ctx.project,
|
|
56
|
+
agent: ctx.agent,
|
|
57
|
+
host: ctx.host,
|
|
58
|
+
session: ctx.session,
|
|
59
|
+
level: ctx.level,
|
|
60
|
+
links: ctx.links?.length ? ctx.links.join(' ') : undefined,
|
|
61
|
+
message: composeBroadcastMessage(ctx),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Substitute `{placeholder}` tokens in an argv template. Returns undefined when
|
|
66
|
+
* the template needs a value this post does not have — the sink is then skipped
|
|
67
|
+
* rather than run with an empty argument, which is how a `linear update --comment`
|
|
68
|
+
* would otherwise comment on nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function renderSinkArgv(template, ctx) {
|
|
71
|
+
const vars = templateVars(ctx);
|
|
72
|
+
const argv = [];
|
|
73
|
+
for (const token of template) {
|
|
74
|
+
let missing = false;
|
|
75
|
+
const rendered = token.replace(PLACEHOLDER, (whole, key) => {
|
|
76
|
+
const value = vars[key];
|
|
77
|
+
if (value === undefined || value === '') {
|
|
78
|
+
missing = true;
|
|
79
|
+
return whole;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
});
|
|
83
|
+
if (missing)
|
|
84
|
+
return undefined;
|
|
85
|
+
argv.push(rendered);
|
|
86
|
+
}
|
|
87
|
+
return argv.length > 0 ? argv : undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Which sinks this post reaches, in config order. Pure — the dry-run listing and
|
|
91
|
+
* the real fan-out plan through here, so what `--dry-run` shows is what runs.
|
|
92
|
+
*/
|
|
93
|
+
export function planFeedBroadcast(config, ctx) {
|
|
94
|
+
if (!config)
|
|
95
|
+
return [];
|
|
96
|
+
const planned = [];
|
|
97
|
+
for (const [name, sink] of Object.entries(config)) {
|
|
98
|
+
if (!Array.isArray(sink?.command) || sink.command.length === 0)
|
|
99
|
+
continue;
|
|
100
|
+
const min = sink.minLevel ?? 'milestone';
|
|
101
|
+
if (LEVEL_RANK[ctx.level] < LEVEL_RANK[min])
|
|
102
|
+
continue;
|
|
103
|
+
const argv = renderSinkArgv(sink.command, ctx);
|
|
104
|
+
if (!argv)
|
|
105
|
+
continue;
|
|
106
|
+
planned.push({ name, argv });
|
|
107
|
+
}
|
|
108
|
+
return planned;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Run the planned sinks. Each is a direct spawn with a bounded lifetime; a sink
|
|
112
|
+
* that fails or is not installed is reported, never thrown — the post is already
|
|
113
|
+
* written and must not be undone by a mirror that could not be reached.
|
|
114
|
+
*/
|
|
115
|
+
export function runFeedBroadcast(planned, timeoutMs = 20_000) {
|
|
116
|
+
return planned.map(({ name, argv }) => {
|
|
117
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
118
|
+
encoding: 'utf-8',
|
|
119
|
+
timeout: timeoutMs,
|
|
120
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
121
|
+
});
|
|
122
|
+
if (result.error) {
|
|
123
|
+
return { name, ok: false, error: result.error.message };
|
|
124
|
+
}
|
|
125
|
+
if (result.status !== 0) {
|
|
126
|
+
const tail = (result.stderr || result.stdout || '').trim().split('\n').slice(-1)[0];
|
|
127
|
+
return { name, ok: false, error: tail || `exited ${result.status}` };
|
|
128
|
+
}
|
|
129
|
+
return { name, ok: true };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -116,6 +116,11 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
116
116
|
tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
|
|
117
117
|
copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
|
|
118
118
|
authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
|
|
119
|
+
// The notification must land on the box the PERSON is at — the one that
|
|
120
|
+
// dispatched — not on a headless worker with no desktop to post to. The local
|
|
121
|
+
// process follows the remote run to completion, so its exit handler fires at
|
|
122
|
+
// the right moment anyway.
|
|
123
|
+
notify: 'local-only',
|
|
119
124
|
// Deprecated alias for --device auto; resolved on the launching box before SSH.
|
|
120
125
|
smart: 'local-only',
|
|
121
126
|
};
|
|
Binary file
|
|
@@ -233,6 +233,27 @@ export declare function convertToKimiFormat(set: PermissionSet): {
|
|
|
233
233
|
* OpenCode uses: { permission: { bash: { "git *": "allow", "rm *": "deny" } } }
|
|
234
234
|
*/
|
|
235
235
|
export declare function convertToOpenCodeFormat(set: PermissionSet): OpenCodePermissions;
|
|
236
|
+
/**
|
|
237
|
+
* Default extra writable roots for Codex's `workspace-write` sandbox: the
|
|
238
|
+
* regenerable package/toolchain caches that build/test/install write OUTSIDE the
|
|
239
|
+
* workspace (cargo registry, npm/bun/pnpm caches, GOPATH/GOCACHE, the OS cache
|
|
240
|
+
* root, …). Without these, a sandboxed `cargo build` / `go build` / `npm install`
|
|
241
|
+
* fails on its cache write, which is what pushes users to `--mode full` (YOLO).
|
|
242
|
+
*
|
|
243
|
+
* Credential dirs (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config`, `~/.netrc`) are
|
|
244
|
+
* deliberately EXCLUDED so the sandbox stays meaningful — this is far from
|
|
245
|
+
* danger-full-access. Resolved per platform + home; each box regenerates its own
|
|
246
|
+
* Codex config on sync, so the paths always match the box Codex runs on.
|
|
247
|
+
*/
|
|
248
|
+
export declare function codexDefaultWritableRoots(home?: string, platform?: NodeJS.Platform): string[];
|
|
249
|
+
/**
|
|
250
|
+
* Merge Codex `[sandbox_workspace_write]` config, UNIONing `writable_roots` so a
|
|
251
|
+
* baseline cache root never clobbers a root the user configured directly (a
|
|
252
|
+
* plain object spread would overwrite the whole array), while merging scalar
|
|
253
|
+
* keys (`network_access`) normally. Shared by the user- and version-scoped Codex
|
|
254
|
+
* config writers so the two can't drift.
|
|
255
|
+
*/
|
|
256
|
+
export declare function mergeCodexSandboxWrite(existing: Record<string, unknown> | undefined, incoming: NonNullable<CodexPermissions['sandbox_workspace_write']>): Record<string, unknown>;
|
|
236
257
|
/**
|
|
237
258
|
* Convert canonical permission set to Codex format.
|
|
238
259
|
* Codex uses coarse-grained modes, so we infer the best fit.
|
package/dist/lib/permissions.js
CHANGED
|
@@ -1007,6 +1007,48 @@ export function convertToOpenCodeFormat(set) {
|
|
|
1007
1007
|
},
|
|
1008
1008
|
};
|
|
1009
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Default extra writable roots for Codex's `workspace-write` sandbox: the
|
|
1012
|
+
* regenerable package/toolchain caches that build/test/install write OUTSIDE the
|
|
1013
|
+
* workspace (cargo registry, npm/bun/pnpm caches, GOPATH/GOCACHE, the OS cache
|
|
1014
|
+
* root, …). Without these, a sandboxed `cargo build` / `go build` / `npm install`
|
|
1015
|
+
* fails on its cache write, which is what pushes users to `--mode full` (YOLO).
|
|
1016
|
+
*
|
|
1017
|
+
* Credential dirs (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config`, `~/.netrc`) are
|
|
1018
|
+
* deliberately EXCLUDED so the sandbox stays meaningful — this is far from
|
|
1019
|
+
* danger-full-access. Resolved per platform + home; each box regenerates its own
|
|
1020
|
+
* Codex config on sync, so the paths always match the box Codex runs on.
|
|
1021
|
+
*/
|
|
1022
|
+
export function codexDefaultWritableRoots(home = HOME, platform = process.platform) {
|
|
1023
|
+
const shared = ['.cargo', '.rustup', '.npm', '.bun', 'go', '.deno', '.gradle', '.m2', '.gem'];
|
|
1024
|
+
const roots = shared.map((d) => path.join(home, d));
|
|
1025
|
+
if (platform === 'darwin') {
|
|
1026
|
+
roots.push(path.join(home, 'Library', 'Caches'), path.join(home, 'Library', 'pnpm'));
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
// Linux/XDG: ~/.cache covers pip, uv, go-build, ms-playwright, etc.
|
|
1030
|
+
roots.push(path.join(home, '.cache'), path.join(home, '.local', 'share'), path.join(home, '.local', 'state'));
|
|
1031
|
+
}
|
|
1032
|
+
return roots;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Merge Codex `[sandbox_workspace_write]` config, UNIONing `writable_roots` so a
|
|
1036
|
+
* baseline cache root never clobbers a root the user configured directly (a
|
|
1037
|
+
* plain object spread would overwrite the whole array), while merging scalar
|
|
1038
|
+
* keys (`network_access`) normally. Shared by the user- and version-scoped Codex
|
|
1039
|
+
* config writers so the two can't drift.
|
|
1040
|
+
*/
|
|
1041
|
+
export function mergeCodexSandboxWrite(existing, incoming) {
|
|
1042
|
+
const existingRoots = Array.isArray(existing?.writable_roots)
|
|
1043
|
+
? existing.writable_roots
|
|
1044
|
+
: [];
|
|
1045
|
+
const unionRoots = [...new Set([...existingRoots, ...(incoming.writable_roots ?? [])])];
|
|
1046
|
+
return {
|
|
1047
|
+
...existing,
|
|
1048
|
+
...incoming,
|
|
1049
|
+
...(unionRoots.length > 0 ? { writable_roots: unionRoots } : {}),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1010
1052
|
/**
|
|
1011
1053
|
* Convert canonical permission set to Codex format.
|
|
1012
1054
|
* Codex uses coarse-grained modes, so we infer the best fit.
|
|
@@ -1042,6 +1084,15 @@ export function convertToCodexFormat(set, cwd) {
|
|
|
1042
1084
|
network_access: true,
|
|
1043
1085
|
};
|
|
1044
1086
|
}
|
|
1087
|
+
// Baseline (unconditional): always grant the regenerable build/test/install
|
|
1088
|
+
// caches as writable roots so `agents run codex` in workspace-write can build,
|
|
1089
|
+
// test, and install without escalating to danger-full-access. Merged with
|
|
1090
|
+
// network_access above when set; applyCodexPermissions unions these with any
|
|
1091
|
+
// writable_roots the user configured directly.
|
|
1092
|
+
result.sandbox_workspace_write = {
|
|
1093
|
+
...result.sandbox_workspace_write,
|
|
1094
|
+
writable_roots: codexDefaultWritableRoots(),
|
|
1095
|
+
};
|
|
1045
1096
|
return result;
|
|
1046
1097
|
}
|
|
1047
1098
|
// ============================================================================
|
|
@@ -1333,8 +1384,10 @@ function applyCodexPermissions(set, scope = 'user', cwd, merge = true) {
|
|
|
1333
1384
|
}
|
|
1334
1385
|
if (newPermissions.sandbox_workspace_write) {
|
|
1335
1386
|
const existing = config.sandbox_workspace_write;
|
|
1387
|
+
// merge=false is a deliberate full replace (drops any user-custom roots);
|
|
1388
|
+
// production sync always passes merge=true, taking the union path.
|
|
1336
1389
|
config.sandbox_workspace_write = merge
|
|
1337
|
-
?
|
|
1390
|
+
? mergeCodexSandboxWrite(existing, newPermissions.sandbox_workspace_write)
|
|
1338
1391
|
: newPermissions.sandbox_workspace_write;
|
|
1339
1392
|
}
|
|
1340
1393
|
fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
|
|
@@ -1450,8 +1503,10 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
|
|
|
1450
1503
|
}
|
|
1451
1504
|
if (newPermissions.sandbox_workspace_write) {
|
|
1452
1505
|
const existing = config.sandbox_workspace_write;
|
|
1506
|
+
// merge=false is a deliberate full replace (drops any user-custom roots);
|
|
1507
|
+
// production sync always passes merge=true, taking the union path.
|
|
1453
1508
|
config.sandbox_workspace_write = merge
|
|
1454
|
-
?
|
|
1509
|
+
? mergeCodexSandboxWrite(existing, newPermissions.sandbox_workspace_write)
|
|
1455
1510
|
: newPermissions.sandbox_workspace_write;
|
|
1456
1511
|
}
|
|
1457
1512
|
fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type DesktopNotification } from './menubar/notify-desktop.js';
|
|
2
|
+
export interface RunNotifyContext {
|
|
3
|
+
/** Agent that ran, e.g. `claude`. */
|
|
4
|
+
agent: string;
|
|
5
|
+
/** `--name` slug when the caller named the run; falls back to the agent. */
|
|
6
|
+
name?: string;
|
|
7
|
+
/** The prompt, used for a one-line reminder of what the run was about. */
|
|
8
|
+
prompt?: string;
|
|
9
|
+
/** Working directory the run was scoped to; its basename names the project. */
|
|
10
|
+
cwd?: string;
|
|
11
|
+
/** Machine the run executed on, when it was dispatched off-box. */
|
|
12
|
+
host?: string;
|
|
13
|
+
/** Clickable target — a PR/ticket URL the caller already knows. */
|
|
14
|
+
url?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The finish notification for one run. Pure — the exit handler and the tests
|
|
18
|
+
* both build through here, so what ships is what is asserted.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildRunFinishNotification(ctx: RunNotifyContext, exitCode: number): DesktopNotification;
|
|
21
|
+
/**
|
|
22
|
+
* Post the finish notification when this process exits. Best-effort by
|
|
23
|
+
* construction: `notifyDesktop` swallows its own failures, and a run killed
|
|
24
|
+
* outright (SIGKILL) never reaches an exit handler — that is the documented
|
|
25
|
+
* limit, not a case to paper over.
|
|
26
|
+
*/
|
|
27
|
+
export declare function armRunFinishNotification(ctx: RunNotifyContext): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop notification when a headless `agents run` finishes (`--notify`).
|
|
3
|
+
*
|
|
4
|
+
* The notifying process is the one that OWNS the run. That is the whole point:
|
|
5
|
+
* the menu bar's quick dispatch used to post its completion notice from the
|
|
6
|
+
* dispatching MenubarHelper's process-termination callback, so a helper that
|
|
7
|
+
* restarted (an upgrade, a crash) took the callback with it — the run kept
|
|
8
|
+
* going, reparented to launchd, and no notification could ever fire. Posting
|
|
9
|
+
* from the run process instead means the notice survives anything that happens
|
|
10
|
+
* to the menu bar, and `notifyDesktop` spawns a FRESH one-shot notifier, so it
|
|
11
|
+
* does not need a helper to have been running at dispatch time either.
|
|
12
|
+
*
|
|
13
|
+
* Armed once via `process.on('exit')` so it covers every way the run command
|
|
14
|
+
* terminates — local spawn, `--host` dispatch, `--lease` box, the error path —
|
|
15
|
+
* rather than being sprinkled over ~50 `process.exit` call sites where the next
|
|
16
|
+
* new exit path would silently miss it.
|
|
17
|
+
*/
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
20
|
+
/** Notification body cap: a banner truncates anyway, and a wall of text is noise. */
|
|
21
|
+
const BODY_MAX = 120;
|
|
22
|
+
function shorten(text, max = BODY_MAX) {
|
|
23
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
24
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The finish notification for one run. Pure — the exit handler and the tests
|
|
28
|
+
* both build through here, so what ships is what is asserted.
|
|
29
|
+
*/
|
|
30
|
+
export function buildRunFinishNotification(ctx, exitCode) {
|
|
31
|
+
const label = ctx.name?.trim() || ctx.agent;
|
|
32
|
+
const project = ctx.cwd ? path.basename(ctx.cwd) : undefined;
|
|
33
|
+
const where = [project, ctx.host].filter(Boolean).join(' · ');
|
|
34
|
+
const n = {
|
|
35
|
+
title: exitCode === 0 ? `${label} finished` : `${label} failed`,
|
|
36
|
+
body: shorten(ctx.prompt?.trim() || `${ctx.agent} run`),
|
|
37
|
+
};
|
|
38
|
+
if (where)
|
|
39
|
+
n.subtitle = where;
|
|
40
|
+
if (ctx.url)
|
|
41
|
+
n.action = `url:${ctx.url}`;
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Post the finish notification when this process exits. Best-effort by
|
|
46
|
+
* construction: `notifyDesktop` swallows its own failures, and a run killed
|
|
47
|
+
* outright (SIGKILL) never reaches an exit handler — that is the documented
|
|
48
|
+
* limit, not a case to paper over.
|
|
49
|
+
*/
|
|
50
|
+
export function armRunFinishNotification(ctx) {
|
|
51
|
+
process.on('exit', (code) => {
|
|
52
|
+
notifyDesktop(buildRunFinishNotification(ctx, code));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -158,6 +158,15 @@ export interface ActiveSession {
|
|
|
158
158
|
/** Display label for the orchestrator (its topic/label), resolved when the
|
|
159
159
|
* orchestrator is itself present in the active set. Display-only. */
|
|
160
160
|
orchestratorLabel?: string;
|
|
161
|
+
/**
|
|
162
|
+
* For a teams teammate: a one-line summary of the mission it was spawned with
|
|
163
|
+
* (the `prompt` stored on the teammate record — the team's task/target), so the
|
|
164
|
+
* listing answers "what is this team working on", not just its name. Survives
|
|
165
|
+
* before the teammate has produced any transcript (a pending/staged teammate
|
|
166
|
+
* still shows its target). Distinct from `topic`, which is derived from the
|
|
167
|
+
* teammate's own transcript once it starts.
|
|
168
|
+
*/
|
|
169
|
+
assignedTask?: string;
|
|
161
170
|
agentId?: string;
|
|
162
171
|
cloudProvider?: string;
|
|
163
172
|
cloudTaskId?: string;
|
|
@@ -338,6 +347,12 @@ interface LiveSignals {
|
|
|
338
347
|
* {@link resolveFallbackStatus} reports the honest live floor (`running`).
|
|
339
348
|
*/
|
|
340
349
|
export declare function computeLiveSignals(kind: string, sessionFile: string | undefined, cwd: string | undefined, pidAlive: boolean): LiveSignals;
|
|
350
|
+
/**
|
|
351
|
+
* One-line summary of a teammate's spawn prompt — the team's task/target. Takes
|
|
352
|
+
* the first non-empty line, strips a leading `MISSION:`/`CONTEXT:`/`TASK:` label,
|
|
353
|
+
* and truncates. Exported for tests.
|
|
354
|
+
*/
|
|
355
|
+
export declare function summarizeMission(prompt: string | null | undefined): string | undefined;
|
|
341
356
|
/** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
|
|
342
357
|
export declare function listTeamsActive(): Promise<ActiveSession[]>;
|
|
343
358
|
/** Live editor-terminal agents across every IDE window. */
|
|
@@ -576,6 +576,22 @@ function quickExtractTopic(sessionFile) {
|
|
|
576
576
|
}
|
|
577
577
|
return undefined;
|
|
578
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* One-line summary of a teammate's spawn prompt — the team's task/target. Takes
|
|
581
|
+
* the first non-empty line, strips a leading `MISSION:`/`CONTEXT:`/`TASK:` label,
|
|
582
|
+
* and truncates. Exported for tests.
|
|
583
|
+
*/
|
|
584
|
+
export function summarizeMission(prompt) {
|
|
585
|
+
if (!prompt)
|
|
586
|
+
return undefined;
|
|
587
|
+
const firstLine = prompt.split('\n').map((l) => l.trim()).find(Boolean);
|
|
588
|
+
if (!firstLine)
|
|
589
|
+
return undefined;
|
|
590
|
+
const cleaned = firstLine.replace(/^(MISSION|CONTEXT|TASK|GOAL|OBJECTIVE)\s*[:\-—]\s*/i, '').trim();
|
|
591
|
+
if (!cleaned)
|
|
592
|
+
return undefined;
|
|
593
|
+
return cleaned.length > 80 ? `${cleaned.slice(0, 79)}…` : cleaned;
|
|
594
|
+
}
|
|
579
595
|
/** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
|
|
580
596
|
export async function listTeamsActive() {
|
|
581
597
|
const mgr = new AgentManager();
|
|
@@ -607,6 +623,7 @@ export async function listTeamsActive() {
|
|
|
607
623
|
startedAtMs: a.startedAt.getTime(),
|
|
608
624
|
lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
|
|
609
625
|
teamName: a.taskName,
|
|
626
|
+
assignedTask: summarizeMission(a.prompt),
|
|
610
627
|
agentId: a.agentId,
|
|
611
628
|
// The frozen actor stamped on the teammate record (RUSH-2028) — who ran
|
|
612
629
|
// this teammate, surfaced as the owner in --active (RUSH-2018); sidecar
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* formats for each supported agent.
|
|
7
7
|
*/
|
|
8
8
|
import type { CloudProviderId } from './cloud/types.js';
|
|
9
|
+
import type { FeedBroadcastConfig } from './feed-broadcast.js';
|
|
9
10
|
/** Unique identifier for a current or legacy AI coding agent. */
|
|
10
11
|
export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes';
|
|
11
12
|
/** How `agents run <agent>` chooses an installed version when none is pinned. */
|
|
@@ -762,6 +763,15 @@ export interface Meta {
|
|
|
762
763
|
};
|
|
763
764
|
/** Spend guardrails (issue #346). User-global caps; project agents.yaml overrides. */
|
|
764
765
|
budget?: BudgetConfig;
|
|
766
|
+
/**
|
|
767
|
+
* `agents feed post` fan-out. `broadcast` maps a sink name to the argv template
|
|
768
|
+
* run for each post, so mirroring to a tracker or a messaging CLI is the
|
|
769
|
+
* operator's config rather than an integration compiled into this CLI. See
|
|
770
|
+
* lib/feed-broadcast.ts and docs/06-observability.md.
|
|
771
|
+
*/
|
|
772
|
+
feed?: {
|
|
773
|
+
broadcast?: FeedBroadcastConfig;
|
|
774
|
+
};
|
|
765
775
|
beta?: {
|
|
766
776
|
enabled?: BetaFeatureName[];
|
|
767
777
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.85",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"start": "node dist/index.js",
|
|
56
56
|
"test": "node ./node_modules/vitest/vitest.mjs run",
|
|
57
57
|
"test:remote": "scripts/sandbox.sh 'bun install && bun run build && bun run test'",
|
|
58
|
+
"verify-docs": "scripts/verify-docs.sh",
|
|
58
59
|
"test:watch": "node ./node_modules/vitest/vitest.mjs"
|
|
59
60
|
},
|
|
60
61
|
"keywords": [
|