@phnx-labs/agents-cli 1.22.68 → 1.22.69

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 (78) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +14 -6
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/exec.js +31 -21
  5. package/dist/commands/feed.js +20 -7
  6. package/dist/commands/monitors.js +3 -0
  7. package/dist/commands/projects.d.ts +26 -6
  8. package/dist/commands/projects.js +55 -22
  9. package/dist/commands/send.js +29 -2
  10. package/dist/commands/sessions-inject.d.ts +58 -0
  11. package/dist/commands/sessions-inject.js +143 -7
  12. package/dist/commands/sessions-picker.js +1 -0
  13. package/dist/commands/share.js +43 -14
  14. package/dist/commands/ssh.js +205 -2
  15. package/dist/lib/accounting/usage.d.ts +7 -2
  16. package/dist/lib/accounting/usage.js +142 -10
  17. package/dist/lib/boot-profile.d.ts +14 -0
  18. package/dist/lib/boot-profile.js +66 -0
  19. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  20. package/dist/lib/channels/providers/desktop.js +5 -4
  21. package/dist/lib/claude-account-token.js +108 -4
  22. package/dist/lib/devices/health.d.ts +38 -2
  23. package/dist/lib/devices/health.js +43 -5
  24. package/dist/lib/devices/worker-pick.d.ts +1 -1
  25. package/dist/lib/devices/worker-pick.js +4 -1
  26. package/dist/lib/exec.js +4 -0
  27. package/dist/lib/feed-broadcast.d.ts +64 -5
  28. package/dist/lib/feed-broadcast.js +124 -22
  29. package/dist/lib/monitors/engine.js +18 -0
  30. package/dist/lib/monitors/sources/command.js +13 -3
  31. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  32. package/dist/lib/monitors/sources/failure.js +52 -0
  33. package/dist/lib/monitors/sources/types.d.ts +9 -0
  34. package/dist/lib/owner-message.d.ts +12 -0
  35. package/dist/lib/owner-message.js +44 -0
  36. package/dist/lib/run-trace-sync.d.ts +15 -0
  37. package/dist/lib/run-trace-sync.js +43 -21
  38. package/dist/lib/secrets/filestore.d.ts +4 -0
  39. package/dist/lib/secrets/filestore.js +164 -3
  40. package/dist/lib/session/active.d.ts +10 -0
  41. package/dist/lib/session/active.js +3 -0
  42. package/dist/lib/session/db.d.ts +12 -1
  43. package/dist/lib/session/db.js +20 -1
  44. package/dist/lib/session/discover.js +81 -1
  45. package/dist/lib/session/linear.d.ts +13 -0
  46. package/dist/lib/session/linear.js +44 -0
  47. package/dist/lib/session/live-metadata.js +1 -0
  48. package/dist/lib/session/parse.js +2 -3
  49. package/dist/lib/session/prompt.d.ts +7 -1
  50. package/dist/lib/session/prompt.js +12 -2
  51. package/dist/lib/session/recovery.d.ts +21 -12
  52. package/dist/lib/session/recovery.js +29 -11
  53. package/dist/lib/session/remote/watch.js +5 -2
  54. package/dist/lib/session/state.js +11 -13
  55. package/dist/lib/share/backend.d.ts +2 -2
  56. package/dist/lib/share/backend.js +20 -9
  57. package/dist/lib/share/delete.d.ts +5 -1
  58. package/dist/lib/share/delete.js +7 -2
  59. package/dist/lib/share/http-error.d.ts +52 -0
  60. package/dist/lib/share/http-error.js +65 -0
  61. package/dist/lib/share/publish.d.ts +13 -3
  62. package/dist/lib/share/publish.js +19 -15
  63. package/dist/lib/share/worker-template.js +5 -1
  64. package/dist/lib/smart-launch.js +27 -4
  65. package/dist/lib/storage/index.d.ts +14 -0
  66. package/dist/lib/storage/index.js +14 -0
  67. package/dist/lib/storage/selection.d.ts +48 -0
  68. package/dist/lib/storage/selection.js +39 -0
  69. package/dist/lib/storage/visibility.d.ts +82 -0
  70. package/dist/lib/storage/visibility.js +99 -0
  71. package/dist/lib/teams/agents.js +3 -1
  72. package/dist/lib/teams/placement-probe.js +1 -0
  73. package/dist/lib/teams/scheduler.d.ts +8 -1
  74. package/dist/lib/teams/scheduler.js +4 -1
  75. package/dist/lib/traces/backend.js +13 -2
  76. package/dist/lib/worktree/held.d.ts +166 -0
  77. package/dist/lib/worktree/held.js +368 -0
  78. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.69
4
+
5
+ - **`agents monitors` no longer fires an action on a poll FAILURE (PHNX-3510).** An `[on-change]` monitor treated a failing poll — a `gh pr list … | jq` that intermittently printed `GraphQL: API rate limit already exceeded …` — as a value change: the empty→error→empty flap read as two changes and dispatched a full agent run each time on a premise that was false (~10 sessions burned re-verifying two already-merged PRs). A command/poll source now flags a snapshot that exited non-zero, or whose output matches a transport/auth/rate-limit error shape (caught even on exit 0, since a pipe to jq swallows the failing half's exit code), as an *observation failure*: the engine skips it, leaves watched-state untouched, does not fire, and records it as a failed check so a sustained streak escalates to the owner as a drought. `agents monitors test` labels a failed poll and reports `Would fire: no`. Note: a non-zero exit is now an observation failure rather than a watchable value — a monitor that wants to watch a command's success/failure should emit a stable token (`… && echo UP || echo DOWN`, always exit 0) so the state it cares about diffs as a value; sustained failures still reach the owner via the drought escalation. Source: `cli/src/lib/monitors/sources/failure.ts`, `cli/src/lib/monitors/sources/command.ts`, `cli/src/lib/monitors/engine.ts`, `cli/docs/automation.md`.
6
+
7
+ - **`agents fleet worktrees` surfaces the worktree-sweep's held set as buckets, not a silent count (PHNX-3520).** The nightly `worktree-sweep` (PHNX-3503, in `phnx-labs/.agents`) reclaims merged agent worktrees and correctly HOLDS anything dirty, unmerged, or undeterminable — but it only ever emitted a `held=<n>` count, so the held set became a permanent, growing residue nothing resolved. Measured 2026-08-30 it is consistently LARGER than the reclaimed set (~800 worktrees fleet-wide), and one bucket inside it is real stranded work: a worktree whose branch carries commits on no remote (the PHNX-2951 / PHNX-2732 class — an agent finished, its commits never reached a remote, nothing surfaced them). The new read-only command classifies every held worktree into its three buckets — **`unmerged-commits`** (the one that matters), **`uncommitted-changes`**, and **`undeterminable`** (`status-unreadable` / `merge-state-unknown`, failing closed exactly as the sweep does) — and reports each with device/repo/worktree/branch/reason/age/size. `--json` is the structured form for machine callers; `--fleet` fans out over every online device and aggregates; `--bucket <name>` filters. The safe recovery action for the stranded bucket is `--push`: it PUBLISHES each `unmerged-commits` branch that is on no remote (re-reading the merge state immediately before pushing and refusing anything already on origin), making the work visible — it never removes a worktree or a branch. The destructive reclaim stays in the sweep; this is the surfacing half the sweep can consume. Source: `cli/src/lib/worktree/held.ts`, `cli/src/commands/ssh.ts`, `cli/src/lib/worktree/held.test.ts`.
8
+
9
+ - **`agents run` starts ~0.6s faster on a box with many secrets bundles (PHNX-3585).** The AGI EXT "New Claude" boot runs `agents run claude --interactive`, and the whole wrapper cost is spent BEFORE the harness prints anything. Profiling that pre-exec window (new `AGENTS_PROFILE_BOOT=1` per-stage tracer, `cli/src/lib/boot-profile.ts`) showed the dominant stage was `--strategy balanced` account rotation: `readAccountRegistry` → `listBundles` decrypted EVERY file-store bundle's metadata to find the account bundles, and each decrypt runs a fresh scrypt KDF (~12ms) because every `.enc` file carries its own salt — ~690ms of pure KDF on a box with ~40 bundles, on every launch. Bundle metadata is non-secret by contract (it holds `keychain:`/`env:`/literal refs, never the secret bytes, and is already stored no-ACL so `secrets list` enumerates without Touch ID), so the file store now caches the decrypted metadata JSON keyed by each `.enc` file's `(mtime,size)` plus a passphrase fingerprint. Any bundle write (new mtime/size) or passphrase change (new fingerprint; rotation re-writes every file) misses and re-derives — no staleness, self-healing on any miss — and secret VALUE items never enter the cache. Measured on a worker box: `agents run claude` pre-exec `resolve-version` dropped from ~690ms to ~31ms (warm), total pre-exec ~1040ms → ~370ms. A committed `cli/scripts/bench-boot.sh` keeps it measured. Source: `cli/src/lib/secrets/filestore.ts`.
10
+
11
+ - **Grok's cheap scan now fills `firstUserMessage` from a bounded `chat_history.jsonl` prefix read (PHNX-3621).** The summary-only path never parsed the log. The prefix reader skips Grok `<user_info>` / `synthetic_reason` scaffolding, prefers the first `prompt_index` user turn, and unwraps `<user_query>`. Existing Grok rows backfill through `CONTENT_INDEX_VERSION` v4. Source: `cli/src/lib/session/discover.ts`.
12
+
13
+ - **Worker homes provisioned before seed-on-attach self-heal their account identity at resolve time (PHNX-3660).** `resolveClaudeSetupToken` required the version home's `.claude.json` to carry `oauthAccount.emailAddress`; homes whose `.oauth_token` was written by a pre-fix `accounts attach` had none, so exec-env injection and per-account usage reads were silently inert for them (they authenticated only via the shim's `.oauth_token` fallback). The resolver now recovers the email by matching the home's `.oauth_token` value against the `auth` bundle (re-encode-guarded slug decode, so a lossy or unmatched token can never invent a mapping), writes it back with `seedClaudeWorkerHomeIdentity` so the home converges after one read, and still fails closed — writing nothing — when the token matches no bundle key. Ambient no-home resolves never rewrite the operator's real `~/.claude.json`. Source: `cli/src/lib/claude-account-token.ts`.
14
+
15
+ - **Resume `/continue` no longer launches the exhausted origin login when the pick is a provider account (PHNX-3674).** After native-first rotation (PHNX-3626), a usage-limited origin whose transcript is not in the origin home (trash/backup/reinstall, or local fallback from an unreachable peer) fell through to `/continue` on the provider-inclusive pool without carrying `RecoveryAccount`, so exec authenticated as the rate-limited native login. That continue target now injects the healthy provider the same way native rotation does. Source: `cli/src/lib/session/recovery.ts`, `cli/src/commands/exec.ts`.
16
+
17
+ - **Private share OG covers are no longer cacheable by shared proxies (PHNX-3676).** Token-gated `visibility=private` pages already served HTML as `Cache-Control: private, no-store`, but the generated `/user/slug.png` cover used `public, max-age=31536000, immutable` after the token gate. RFC 9111 lets a shared cache reuse a `public` Authorization response for later unauthenticated requests keyed on the unauthenticated URL, so a Bearer fetch of the cover could leak the preview. `managedCoverHeaders` now treats `private` like `me`/`org` (`private, no-store` + `X-Robots-Tag: noindex`). Source: `cli/src/lib/share/worker-template.ts`.
18
+
19
+ - **`agents sessions inject` can nudge a live remote tmux session that has no session id, and no longer crashes on `--pane --device` (PHNX-3688).** Two bugs are fixed. (1) A live session whose `agents sessions --active` id column shows `-` — only a tmux label like `ag-claude-214edaae:0.0` is known — was untargetable: `inject <shortid>` failed `No active session matches`. The lookup now matches the `ag-<agent>-<shortid>` tmux name's suffix (and the full name / pane id), the only selector such a row exposes, and the discovered row carries its `tmuxName` so the match has something to key on. (2) `inject _ "text" --pane %122 --device <box>` crashed with `TypeError: host.startsWith is not a function`: under `optsWithGlobals()` the parent `sessions` command's variadic `-D, --device <target...>` shadows this subcommand's scalar `--device`, so a single `--device box` arrived as `['box']` and the array flowed straight into `sshExec`. It is now coerced to one host (and fails loud on more than one, since inject delivers to exactly one terminal). A bare session id combined with `--device` now resolves ON that device — its tmux panes live there, not on the box you typed the command from — by re-running the inject over SSH, the tool-native form of the old `agents ssh <box> "agents sessions inject <id> …"` workaround. Source: `cli/src/commands/sessions-inject.ts`, `cli/src/lib/session/active.ts`.
20
+
21
+ - **`agents` releases no longer wedge waiting for a human to mint an attestation (PHNX-3696).** RUSH-2666 made an exact-tree attestation for the *release commit* mandatory but shipped no producer for it, so every `release.sh --apply` since 2026-08-15 stopped at `missing exact attestation key` and required an operator to hand-run `release-attestation-produce.sh` and copy the tarball into the store. `attest-main.yml` already attests every push to `main`; the release commit differs from that base only by the version bump, the folded changelog, and the regenerated command index — exactly the set `release-attestation.sh derive` allowlists — so `release.sh` now derives the record itself (`derive_release_attestation`, inheriting the green base's suite via `--inherit-suite-from`, no suite re-run). Soundness is unchanged: `derive` still fails closed if the tree diff touches anything outside that allowlist, so a code change can never inherit a stale pass, and the derive is best-effort — any failure falls through to the previous poll-then-`require`, which still fails loud (the call site guards it with `|| true`, which is load-bearing under `set -euo pipefail`: a bare call returning non-zero would abort the release before that fallback ran). The regression survived review because `release.test.ts` asserted against `release.sh` as *text*; the new coverage executes the real `derive_release_attestation` body against a real git repo and store. Source: `cli/scripts/release.sh`, `cli/scripts/release.test.ts`, `cli/AGENTS.md`.
22
+
23
+ - **Owner phone pings are short and tappable — but only where a sink can render a link (PHNX-3698).** Every owner-bound message goes through one composer (`composeBroadcastMessage`), and the shared `{message}` now surfaces its links per-sink instead of dumping naked URLs after the footer. A **Slack `channel:` sink** gets mrkdwn labeled links `<url|label>`: the `Sent from claude/6fc1db18 on zion` crumb becomes `<https://prix.dev/console/sessions/<full-id>|claude/6fc1db18>` (an 8-char crumb is first upgraded to the full indexed id, since a truncated id would 404), and every `TEAM-N` key the title or body *names* — not just the session's own `ticketId`, so a ping that only mentions `PHNX-3689` in prose is still tappable — becomes `<https://linear.app/<workspace>/issue/<KEY>|<KEY>>` in place. **iMessage, the owner-scoped rush message, `command:` sinks, and every other channel stay plain** — they cannot render a labeled link and a dumped naked URL reads as noise, so the message is the human sentence with no URLs (keys and crumb as text); there is never a trailing URL line on any sink. `agents notify` and `agents send --to owner` route through that same composer on the owner-scoped (plain) path instead of shipping the raw body dump; a non-owner `agents send` is still delivered verbatim. An important/`--blocked` post (and an owner send) fires a best-effort, opt-in background `agents traces sync` so the linked console page exists when a Slack crumb is tapped (gated exactly like the run-exit arm; `AGENTS_NO_TRACE_SYNC=1` opts out). The Linear-key detector is canonical in one place shared with transcript ticket detection (`session/linear.ts`). Source: `cli/src/lib/feed-broadcast.ts`, `cli/src/lib/owner-message.ts`, `cli/src/lib/session/linear.ts`, `cli/src/lib/session/db.ts`, `cli/src/lib/run-trace-sync.ts`, `cli/src/commands/send.ts`, `cli/src/commands/feed.ts`.
24
+
25
+ - **An ordinary CLI release no longer signs or notarizes anything on macOS, and the macOS attestation producer works again (PHNX-3699, PHNX-3631).** `release-attestation-produce.sh` gated its sign+notarize block on `uname == Darwin` alone, with no `--with-helpers` condition — so a CLI-only attestation produced on a Mac codesigned the CLI binary and rebuilt + signed both helper `.app`s, none of which ship in the tarball (RUSH-3026 removed the binary, RUSH-3100 the bundles). That violates the R3 requirement ("no signing, no notarization on the ordinary path") and, because `agents secrets exec apple.com` cannot unlock a Touch-ID-gated bundle headlessly, it *killed* the release outright — hit live cutting 1.22.69 right after PHNX-3696 made `release.sh` produce this attestation itself. The block is now gated on `--with-helpers`, so cutting a helper release still signs and an ordinary CLI release does not. Shipped alongside the **PHNX-3631** fix in the same path: `ATTEST_TMP` used `mktemp ".../agents-cli-attest.XXXXXX.json"`, but BSD/macOS `mktemp` only substitutes trailing X's and treats a template with a suffix after them as a literal filename — so the first call created that exact file and every later call died with `mkstemp failed … File exists`. Now `agents-cli-attest.json.XXXXXX`, which GNU accepts identically; the same pattern in `remote-sign-mac.sh` is fixed too. Together these take `release-attestation-produce.test.ts` on macOS from 22-of-29 failing to 31/31 passing. Source: `cli/scripts/release-attestation-produce.sh`, `cli/scripts/remote-sign-mac.sh`.
26
+
27
+ - **`agents projects view <path>` auto-detects the cwd's project; `projects for-cwd` is gone (PHNX-3704).** `agents projects view [nameOrPath]` now accepts a DIRECTORY as well as a project name. A path-shaped argument — `.`, `..`, a `~`-prefixed value, a `/`-containing value, or an explicit `--path [dir]` (bare `--path` = cwd) — auto-detects the project that contains that directory via the existing path-based `projectNameForCwd` (def-root containment, longest wins); a bare token stays a project name. `view <path> --json` prints `{name, linear:{name,projectId}, root}` in ONE call, so a consumer gets the project name AND its Linear binding without a second `projects list` round-trip. Fail-open: an all-null shape when nothing contains the path, exit 0, never throws. The `projects for-cwd` subcommand and its `formatForCwdOutput` helper are deleted — `view <path> --json` subsumes them. Source: `cli/src/commands/projects.ts`.
28
+
29
+ - **`agents artifacts share` is private by default when signed in, and storage + visibility now live in one shared module.** A publish with no `--visibility` flag is now `me` (owner-only, Phoenix-gated) instead of `public` — signed in, your share is private unless you opt into `--visibility org` (everyone at your email domain, no org to create) or `--visibility public` (the gallery). A BYO (signed-out) publish still defaults to `public`, since the Worker refuses `me`/`org` without a Phoenix owner. The managed-vs-BYO selection policy and the visibility model are now the single `cli/src/lib/storage/` module (`selectStorageBackendKind` + `publishVisibility`/`defaultVisibilityForBackend`), consumed by both `agents artifacts share` (`lib/share/backend.ts`) and `agents traces sync` (`lib/traces/backend.ts`) instead of each re-deriving it — the seam the `sessions` backup surface reuses next. Failed share calls (publish/list/revisions/delete) now surface the Worker's own error — a 400/413/429 shows its `{"error":"…"}` reason and a 429's `Retry-After` — via one bounded extractor (`lib/share/http-error.ts`) that never dumps an arbitrary body. Source: `cli/src/lib/storage/`, `cli/src/lib/share/{backend,publish,delete,http-error}.ts`, `cli/src/commands/share.ts`.
30
+
31
+ - **`agents view grok` shows the last-known usage % again instead of a numberless "run grok once to refresh usage".** Two bugs compounded. (1) `getGrokUsageInfo` read `unified.jsonl` from the home it was given, but `agents view` fetches usage per INSTALLED VERSION, passing each version's isolated home (`~/.agents/.history/versions/grok/<ver>`) — whose `.grok/logs/unified.jsonl` never exists, because Grok writes its billing log only to the user's shared real home `~/.grok`. So every version fell back to the refresh hint while the real reading (e.g. week 42%) sat unread in the shared home. Grok usage now resolves the requested home's log first and falls back to the shared `~/.grok` log where Grok actually writes (a per-version log, if one ever appears, still wins). The shared last line is attributed only to the identity that owns `~/.grok` (matching `auth.json`, or the real home itself) — other version-scoped Grok accounts stay as no-recent-usage rather than inheriting that meter. (2) The usage cache serializer persisted only fresh `windows`, dropping the `staleWindows` Grok's collector pre-partitions an ended-period reading onto — so the daemon-refreshed cache the plain `agents view grok` reads rendered the plan alone (no bar), even right after a `--refresh` had shown "W: 42% · stale". The serializer now persists the union of fresh and stale windows; `deserializeClaudeUsageSnapshot` re-runs the freshness gate on read, so the round-trip is preserved for every collector (Claude, which returns raw windows, is unchanged). Source: `cli/src/lib/accounting/usage.ts`.
32
+
3
33
  ## 1.22.68
4
34
 
5
35
  Fix OpenCode session history to index and backfill the genuine first user request.
package/README.md CHANGED
@@ -416,11 +416,11 @@ agents sessions resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 # original harness/
416
416
  agents run auto --resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 # adapt if its account is unavailable
417
417
  ```
418
418
 
419
- `agents sessions resume` reopens several sessions in whatever terminal you're in -- auto-detected across iTerm, Ghostty, tmux, and the VSCodium agent-terminal, or forced with `--iterm` / `--ghostty` / `--tmux` / `--vscodium`. `agents sessions resume <id>` resumes one session without requiring you to name its harness: exact IDs take a local SQLite fast path, then resolve fleet-wide and recover on the source device. If the origin version is installed, signed in, healthy, and still owns the indexed transcript, its isolated home performs native resume. Claude launches that native resume from the original project directory recorded before the first turn, so its `projects/<cwd-key>` lookup reaches the conversation even when the session later changed directories. Otherwise a healthy version of the **same harness** starts with `/continue <id>`, which reads the indexed transcript even when the old version home is retained under version trash or the same version number was reinstalled into a new home. It never native-resumes from a different isolated home. Back them with **tmux** and the runs turn durable: detach, close your editor, reboot the GUI -- the session is still alive to `agents tmux attach`. The whole `agents tmux` subsystem (persistent multiplexer sessions that survive editor restarts and can be shared with other tools) sits underneath.
419
+ `agents sessions resume` reopens several sessions in whatever terminal you're in -- auto-detected across iTerm, Ghostty, tmux, and the VSCodium agent-terminal, or forced with `--iterm` / `--ghostty` / `--tmux` / `--vscodium`. `agents sessions resume <id>` resumes one session without requiring you to name its harness: exact IDs take a local SQLite fast path, then resolve fleet-wide and recover on the source device. If the origin version is installed, signed in, healthy, and still owns the indexed transcript, its isolated home performs native resume. Claude launches that native resume from the original project directory recorded before the first turn, so its `projects/<cwd-key>` lookup reaches the conversation even when the session later changed directories. When that origin login is usage-limited but the home still owns the transcript, resume stays native and rotates to a healthy injectable provider account of the **same harness**. `/continue <id>` is the last-resort fallback — a signed-out, revoked, trashed, backup-only, or same-number-reinstalled origin, or a limited origin whose transcript is no longer in that home — and still authenticates as a healthy same-harness account (injecting a provider credential when that is the pick, so it never launches the exhausted native login). It never native-resumes from a different isolated home. Back them with **tmux** and the runs turn durable: detach, close your editor, reboot the GUI -- the session is still alive to `agents tmux attach`. The whole `agents tmux` subsystem (persistent multiplexer sessions that survive editor restarts and can be shared with other tools) sits underneath.
420
420
 
421
421
  ### Send an agent to the background — and bring it back
422
422
 
423
- Running 30 agents and drowning in terminal tabs? `agents sessions detach <id>` stops a session's interactive process and keeps it working **headless** in the background -- it drives its task to done unattended, no tab, lower cost. `agents sessions resume <id>` brings it back through the same origin-device recovery decision: native resume in the exact healthy origin home, or same-harness `/continue` when that home is unavailable, with the full indexed history (including whatever it did while backgrounded).
423
+ Running 30 agents and drowning in terminal tabs? `agents sessions detach <id>` stops a session's interactive process and keeps it working **headless** in the background -- it drives its task to done unattended, no tab, lower cost. `agents sessions resume <id>` brings it back through the same origin-device recovery decision: native resume in the exact origin home (healthy login, or a rotated injectable account on a usage limit), or same-harness `/continue` as last resort when that home is unavailable, with the full indexed history (including whatever it did while backgrounded).
424
424
 
425
425
  ```
426
426
  agents sessions detach a1b2c3d4 # go headless in the background, keep working
@@ -658,6 +658,9 @@ agents fleet status # online/offline rollup + NEEDS ATTENTIO
658
658
  agents fleet status --verbose # full per-device auth/CLI/sync/version grid
659
659
  agents fleet status --live # force a live resource probe (alias of --refresh)
660
660
  agents fleet status --json --strict # scriptable fleet health gate
661
+ agents fleet worktrees # held agent worktrees by bucket: stranded (unmerged) · dirty · undeterminable
662
+ agents fleet worktrees --fleet # aggregate the held set across every online device
663
+ agents fleet worktrees --push # publish stranded on-no-remote branches (recovers work; never deletes)
661
664
  agents devices harnesses # per device: agent@version · account · signed · quota · ready
662
665
  agents devices accounts # same, one row per account (which harnesses share it)
663
666
  agents devices harnesses --agents claude,codex --json # scoped, machine-readable
@@ -1322,6 +1325,7 @@ Sources: a command's stdout (`--watch` / `--poll`), an HTTP endpoint (`--poll-ht
1322
1325
  # Signed in? Just publish — no Cloudflare setup.
1323
1326
  agents auth login
1324
1327
  agents artifacts share plan.html --visibility unlisted # → https://share.agents-cli.sh/<handle>/<slug>-<id>
1328
+ agents artifacts share secret.html --protected # → https://share.agents-cli.sh/<handle>/<slug>-<id>?k=<token>
1325
1329
 
1326
1330
  # Or provision your own Cloudflare R2 (~$0).
1327
1331
  agents artifacts setup # once: provision bucket + Worker on your CF
@@ -1331,7 +1335,7 @@ agents artifacts share plan.html --label "Q3 fleet plan" --meta kind=plan # hu
1331
1335
  agents artifacts share plan.html --json # URL object for plan-render hooks
1332
1336
  agents artifacts share list --agent claude # public gallery, filterable
1333
1337
  agents artifacts share list --meta kind=plan # exact, repeatable metadata filters
1334
- agents artifacts share list --all # include hidden unlisted/me/org pages
1338
+ agents artifacts share list --all # include hidden unlisted/private/me/org pages
1335
1339
  agents artifacts share list --scope me # just the owner-only pages
1336
1340
  agents artifacts share edit fleet --label "Final fleet plan" --meta status=final
1337
1341
  agents artifacts share revisions fleet # prior versions kept under a slug
@@ -1353,7 +1357,10 @@ Worker lazily renders and caches the branded 1200×630 Open Graph card at `<slug
1353
1357
  so publishing from Linux does not require a local Chromium; BYO endpoints keep the
1354
1358
  local screenshot fallback. `--visibility unlisted` (hidden aliases
1355
1359
  `--unlisted` / `--private`) is a capability URL: GET still works, the gallery hides it,
1356
- and the Worker sends `X-Robots-Tag: noindex`. `--visibility me` is visible only to
1360
+ and the Worker sends `X-Robots-Tag: noindex`. `--protected` (`--visibility private`)
1361
+ is token-gated: the published URL carries a secret `?k=` key (`Authorization: Bearer`
1362
+ is accepted too) and GET returns 404 without it — treat the whole link as a secret.
1363
+ `--visibility me` is visible only to
1357
1364
  you (the signed-in owner); `--visibility org` is visible to anyone at your email
1358
1365
  **domain** — derived from your own address, so it needs a **workspace** Google
1359
1366
  account and is refused on a public-inbox domain (`gmail.com`, `outlook.com`,
@@ -1373,8 +1380,9 @@ single **Phoenix ID** (Google-only device-code OAuth, `agents auth login`) — s
1373
1380
  a Cloudflare API token from your `cloudflare.com` secrets bundle (or `--token`), creates
1374
1381
  an R2 bucket, uploads a tiny Worker, and enables the free `*.workers.dev` subdomain (or
1375
1382
  maps `--domain share.example.com` when the token owns the zone). Writes are bearer-gated
1376
- **through** the Worker (Phoenix bearer or static `WRITE_TOKEN`); reads are **public**, so
1377
- a link outlives the agent. R2 has zero egress + a 10 GB free tier, so BYO is still
1383
+ **through** the Worker (Phoenix bearer or static `WRITE_TOKEN`); public and unlisted
1384
+ reads are **public** so a link outlives the agent, while `--protected` token-gates
1385
+ reads on BYO too. R2 has zero egress + a 10 GB free tier, so BYO is still
1378
1386
  effectively free.
1379
1387
 
1380
1388
  **Fleet mode:** provision one endpoint, then every fleet / cloud / ephemeral agent
package/dist/bootstrap.js CHANGED
@@ -19,6 +19,7 @@ import * as path from 'path';
19
19
  import { fileURLToPath } from 'url';
20
20
  import { detectDevBuild } from './lib/startup/dev-build.js';
21
21
  import { configureRootCommand } from './lib/startup/root-command.js';
22
+ import { bootMark } from './lib/boot-profile.js';
22
23
  // `ora`, `@inquirer/prompts`, `./commands/utils.js`, and the agents/versions/shims
23
24
  // modules are imported dynamically at their use sites: they are needed only on
24
25
  // interactive / update / shim-repair paths, never for fast commands like
@@ -1029,6 +1030,7 @@ if (helpAllRequested) {
1029
1030
  // immediately: skip the update check (PATH scan + cache read) and the detached
1030
1031
  // background sync (spawns a child process) that every other invocation runs.
1031
1032
  if (!isDocumentationRequest) {
1033
+ bootMark('bootstrap:evaluated');
1032
1034
  // Run update check before parsing so the upgrade notice/prompt precedes output.
1033
1035
  await checkForUpdates();
1034
1036
  // Fire-and-forget the background sync. System repo gets a real fast-forward
@@ -1157,6 +1159,7 @@ if (passedArgs.length === 0) {
1157
1159
  }
1158
1160
  try {
1159
1161
  await maybeBootstrapShimIntegration(requestedCommand, isDocumentationRequest, verboseStartup);
1162
+ bootMark('bootstrap:pre-parse');
1160
1163
  await program.parseAsync();
1161
1164
  }
1162
1165
  catch (err) {
@@ -27,6 +27,7 @@ import { randomUUID } from 'crypto';
27
27
  import { isSessionTrackedAgent } from '../lib/session/types.js';
28
28
  import { applyActiveRulesPresetAtRun } from '../lib/rules/run-sync.js';
29
29
  import { handleBroadcast } from './run-broadcast.js';
30
+ import { bootMark } from '../lib/boot-profile.js';
30
31
  /** Distinguish a terminal account-picker marker from an explicit @version pin. */
31
32
  export function parseRunAccountPickerRequest(agentSpec) {
32
33
  const requested = agentSpec.endsWith('@');
@@ -690,6 +691,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
690
691
  `,
691
692
  });
692
693
  runCmd.action(async (agentSpec, prompt, options, command) => {
694
+ bootMark('run-action:enter');
693
695
  // Capture everything after -- as passthrough args forwarded verbatim to the
694
696
  // underlying CLI. Commander strips the literal `--` and folds what follows
695
697
  // into the positional operands (so `agents run codex -- --yolo` would parse
@@ -1806,6 +1808,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1806
1808
  import('../lib/capabilities.js'),
1807
1809
  import('../lib/share/config.js'),
1808
1810
  ]);
1811
+ bootMark('run-deps:imported');
1809
1812
  const isValidAgent = (agent) => ALL_AGENT_IDS.includes(agent);
1810
1813
  // Parse agent@version#label. The label selects native auth without pinning
1811
1814
  // the binary version; --account remains the equivalent flag form.
@@ -2345,6 +2348,25 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2345
2348
  process.exit(1);
2346
2349
  }
2347
2350
  version = resolvedRecoveryTarget.version;
2351
+ // Account rotation (PHNX-3626 / PHNX-3674): recovery picked a healthy
2352
+ // provider of the SAME harness. Inject that credential through the
2353
+ // `--account` path so spawn does not authenticate as the version home's
2354
+ // native login (the exhausted origin when the continue pick is a
2355
+ // provider). An explicit --account (configuredAccount) always wins.
2356
+ const rotatedAccount = resolvedRecoveryTarget.account;
2357
+ if (rotatedAccount && !configuredAccount) {
2358
+ try {
2359
+ const picked = resolveSpawnAccount(rotatedAccount.providerAccount, agent, version, readMeta(), { useDefault: false });
2360
+ if (picked?.kind === 'provider')
2361
+ accountEnv = picked.env;
2362
+ }
2363
+ catch {
2364
+ // Account-registry errors can carry provider credential material;
2365
+ // never relay them onto the terminal from this automatic path.
2366
+ console.error(chalk.red('Could not prepare the rotated provider account for session recovery.'));
2367
+ process.exit(1);
2368
+ }
2369
+ }
2348
2370
  if (resolvedRecoveryTarget.mode === 'native') {
2349
2371
  version = session.version;
2350
2372
  resumeNative = true;
@@ -2355,27 +2377,8 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2355
2377
  // differ from the earlier cwd that selected projects/<cwd-key>.
2356
2378
  if (!options.cwd && resolvedRecoveryTarget.cwd)
2357
2379
  options.cwd = resolvedRecoveryTarget.cwd;
2358
- // Account rotation on a limit (PHNX-3626): recovery kept resume NATIVE
2359
- // in the origin home but rotated to a healthy provider account of the
2360
- // SAME harness. Inject that credential through the same `--account`
2361
- // path an explicit selection uses, so exec authenticates as the
2362
- // rotated account while reading the origin transcript. An explicit
2363
- // --account (configuredAccount) always wins — never rotate over it.
2364
- const rotatedAccount = resolvedRecoveryTarget.account;
2365
- if (rotatedAccount && !configuredAccount) {
2366
- try {
2367
- const picked = resolveSpawnAccount(rotatedAccount.providerAccount, agent, version, readMeta(), { useDefault: false });
2368
- if (picked?.kind === 'provider')
2369
- accountEnv = picked.env;
2370
- }
2371
- catch {
2372
- // Account-registry errors can carry provider credential material;
2373
- // never relay them onto the terminal from this automatic path.
2374
- console.error(chalk.red('Could not prepare the rotated provider account for native resume.'));
2375
- process.exit(1);
2376
- }
2377
- if (!options.quiet)
2378
- process.stderr.write(chalk.gray(`[agents] origin ${agent} account limited → rotated to ${rotatedAccount.label} (native resume)\n`));
2380
+ if (rotatedAccount && !configuredAccount && !options.quiet) {
2381
+ process.stderr.write(chalk.gray(`[agents] origin ${agent} account limited → rotated to ${rotatedAccount.label} (native resume)\n`));
2379
2382
  }
2380
2383
  if (!options.quiet)
2381
2384
  process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''} in ${options.cwd ?? cwd}\n`));
@@ -2386,6 +2389,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2386
2389
  prompt = buildContinuePrompt(session.id, prompt);
2387
2390
  if (prompt.trim() === `/continue ${session.id}`)
2388
2391
  forceInteractive = true;
2392
+ if (rotatedAccount && !configuredAccount && !options.quiet) {
2393
+ process.stderr.write(chalk.gray(`[agents] origin ${agent} account limited → rotated to ${rotatedAccount.label} (/continue)\n`));
2394
+ }
2389
2395
  if (!options.quiet)
2390
2396
  process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)${version ? ` @${version}` : ''}\n`));
2391
2397
  }
@@ -2443,7 +2449,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2443
2449
  // ones sitting in a version home (RUSH-3182). Run-path only — the
2444
2450
  // picker's other callers keep the native-only collector.
2445
2451
  const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2452
+ bootMark('resolve-version:start');
2446
2453
  const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun);
2454
+ bootMark('resolve-version:done');
2447
2455
  if (resolved.exhausted) {
2448
2456
  // Zero healthy accounts splits two ways, and conflating them is what
2449
2457
  // stranded a logged-out harness with no way in at all (RUSH-2334):
@@ -2610,6 +2618,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2610
2618
  version = healed;
2611
2619
  }
2612
2620
  }
2621
+ bootMark('ensure-runnable:done');
2613
2622
  // The harness may simply not be on this machine. The self-heal above only
2614
2623
  // runs when a managed version resolved, so with nothing installed we used
2615
2624
  // to fall through and spawn the bare `cliCommand`, which dies as
@@ -2646,6 +2655,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2646
2655
  if (defaultVersion) {
2647
2656
  applyActiveRulesPresetAtRun(agent, defaultVersion, getVersionHomePath(agent, defaultVersion));
2648
2657
  }
2658
+ bootMark('rules-sync:done');
2649
2659
  // Login preflight (advisory, warn + continue). On a local INTERACTIVE
2650
2660
  // launch, probe whether this agent's account has a credential and print a
2651
2661
  // one-line warning if it looks logged out — so you find out BEFORE the TUI
@@ -5,7 +5,8 @@ import { projectKeyFromCwd } from '../lib/project-key.js';
5
5
  import { postFeedStatus } from '../lib/feed-post.js';
6
6
  import { linearIssueUrl } from '../lib/session/linear.js';
7
7
  import { parseFeedPostLevel, planFeedBroadcast, runFeedBroadcast, effectiveBroadcastConfig, withDesktopNotify, blockBroadcastContext, blockDeliveryFailure, } from '../lib/feed-broadcast.js';
8
- import { getSessionById } from '../lib/session/db.js';
8
+ import { getSessionById, resolveFullSessionId } from '../lib/session/db.js';
9
+ import { fireTraceSyncInBackground } from '../lib/run-trace-sync.js';
9
10
  import { readMeta } from '../lib/state.js';
10
11
  import { enrichBlocksFromSessions, groupBlocksByOutcome, isUnambiguousOutcomeAnswer, openBlocksForOutcome, stampBlockOutcomes, } from '../lib/feed-outcome.js';
11
12
  import { classifyBlock, filterBlocksForFeed, suppressionDigest, } from '../lib/ask-classifier.js';
@@ -692,7 +693,10 @@ async function broadcastPostedEvent(event, level, meta, notify = false) {
692
693
  const config = withDesktopNotify(effectiveBroadcastConfig(meta.feed?.broadcast, level, meta), notify);
693
694
  if (!config)
694
695
  return [];
695
- const ticket = getSessionById(event.sessionId)?.ticketId;
696
+ // Upgrade an 8-char footer crumb to the full indexed id so the console session
697
+ // URL resolves instead of 404ing, and so the ticket join hits the right row.
698
+ const session = resolveFullSessionId(event.sessionId) ?? event.sessionId;
699
+ const ticket = getSessionById(session)?.ticketId;
696
700
  const planned = planFeedBroadcast(config, {
697
701
  title: event.title,
698
702
  text: event.detail ?? '',
@@ -702,11 +706,16 @@ async function broadcastPostedEvent(event, level, meta, notify = false) {
702
706
  project: event.project,
703
707
  agent: event.agent,
704
708
  host: event.host,
705
- session: event.sessionId,
709
+ session,
706
710
  links: (event.attachments ?? [])
707
711
  .map((a) => a.href)
708
712
  .filter((href) => /^https?:\/\//i.test(href)),
709
- });
713
+ }, meta);
714
+ // An important post links the session's console page; fire the trace sync now
715
+ // so that page exists when the owner taps it (trace sync otherwise waits for
716
+ // run exit — PHNX-3628/PHNX-3698). Gated + best-effort, never blocks the post.
717
+ if (level === 'important')
718
+ fireTraceSyncInBackground();
710
719
  return runFeedBroadcast(planned, meta);
711
720
  }
712
721
  /**
@@ -722,9 +731,13 @@ async function broadcastBlock(block, extras, meta, notify = false) {
722
731
  const config = withDesktopNotify(effectiveBroadcastConfig(meta.feed?.broadcast, 'important', meta), notify);
723
732
  if (!config)
724
733
  return [];
725
- const ticket = getSessionById(block.sessionId)?.ticketId;
726
- const ctx = blockBroadcastContext({ ...block, ticket: block.ticket ?? ticket }, extras);
727
- return runFeedBroadcast(planFeedBroadcast(config, ctx), meta);
734
+ const sessionId = resolveFullSessionId(block.sessionId) ?? block.sessionId;
735
+ const ticket = getSessionById(sessionId)?.ticketId;
736
+ const ctx = blockBroadcastContext({ ...block, sessionId, ticket: block.ticket ?? ticket }, extras);
737
+ // A block is always important and links the session's console page — fire the
738
+ // trace sync so the page exists when tapped (best-effort, gated, non-blocking).
739
+ fireTraceSyncInBackground();
740
+ return runFeedBroadcast(planFeedBroadcast(config, ctx, meta), meta);
728
741
  }
729
742
  /** One line per sink that ran. Silent when nothing is configured. */
730
743
  function reportBroadcast(outcomes) {
@@ -947,6 +947,9 @@ export function registerMonitorsCommands(program) {
947
947
  console.log(observation.raw.split('\n').slice(0, 20).map((l) => ` ${l}`).join('\n'));
948
948
  if (observation.meta)
949
949
  console.log(chalk.gray(` meta: ${JSON.stringify(observation.meta)}`));
950
+ if (observation.failed) {
951
+ console.log(chalk.yellow(` poll failed (${observation.failureReason ?? 'observation failure'}) — not a value change; skipped`));
952
+ }
950
953
  console.log('');
951
954
  console.log(`Would fire: ${wouldFire ? chalk.green('yes') : chalk.gray('no')}`);
952
955
  if (decision?.event) {
@@ -13,13 +13,33 @@ import { type LinearMilestone } from '../lib/linear-project-counts.js';
13
13
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
14
14
  export declare const PROJECTS_NO_FANOUT_ENV = "AGENTS_PROJECTS_LOCAL";
15
15
  /**
16
- * Render `agents projects for-cwd`'s output for a resolved (or absent)
17
- * project name — `--json` always prints `{"name": ...}` even on no match, so
18
- * a scripted caller can distinguish "ran and found nothing" from a crash;
19
- * the plain-text form prints nothing on no match. Returns '' when nothing
20
- * should be printed.
16
+ * A path-shaped `agents projects view` argument — `.`, `..`, a `~`-prefixed
17
+ * value, or anything containing a path separator — means "auto-detect the
18
+ * project for this DIRECTORY", not "a project named literally this". A bare
19
+ * token (no separators) stays a project name, exactly as before.
21
20
  */
22
- export declare function formatForCwdOutput(name: string | undefined, json: boolean): string;
21
+ export declare function looksLikePath(token: string): boolean;
22
+ /** Machine-readable shape of `agents projects view <path> --json`. */
23
+ export interface ViewDetection {
24
+ /** Detected project name, or null when no definition contains the path. */
25
+ name: string | null;
26
+ /** The detected project's Linear binding; fields are null when unbound. */
27
+ linear: {
28
+ name: string | null;
29
+ projectId: string | null;
30
+ };
31
+ /** The detected project's repo/monorepo root (home-relative), or null. */
32
+ root: string | null;
33
+ }
34
+ /**
35
+ * Best-effort cwd->project detection for `agents projects view <path>`. Delegates
36
+ * to the shared {@link projectNameForCwd} (def-root containment, longest wins),
37
+ * then reads the matched definition's Linear binding and root so ONE call yields
38
+ * the name AND the Linear projectId. Fail-open: an all-null shape when nothing
39
+ * contains the path — never throws, so a scripted caller stays unscoped rather
40
+ * than crashing. This subsumes the removed `agents projects for-cwd`.
41
+ */
42
+ export declare function detectProjectForPath(cwd: string, defs: ProjectDef[]): ViewDetection;
23
43
  /**
24
44
  * One compact trailing note for peers that didn't answer the `--fleet`
25
45
  * fan-out — unreachable, running an agents-cli too old to carry `projects
@@ -35,16 +35,33 @@ export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
35
35
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
36
36
  const SKIPPED_NAME_LIMIT = 4;
37
37
  /**
38
- * Render `agents projects for-cwd`'s output for a resolved (or absent)
39
- * project name — `--json` always prints `{"name": ...}` even on no match, so
40
- * a scripted caller can distinguish "ran and found nothing" from a crash;
41
- * the plain-text form prints nothing on no match. Returns '' when nothing
42
- * should be printed.
38
+ * A path-shaped `agents projects view` argument — `.`, `..`, a `~`-prefixed
39
+ * value, or anything containing a path separator — means "auto-detect the
40
+ * project for this DIRECTORY", not "a project named literally this". A bare
41
+ * token (no separators) stays a project name, exactly as before.
43
42
  */
44
- export function formatForCwdOutput(name, json) {
45
- if (json)
46
- return JSON.stringify({ name: name ?? null });
47
- return name ?? '';
43
+ export function looksLikePath(token) {
44
+ return token === '.' || token === '..' || token.startsWith('~') || token.includes('/');
45
+ }
46
+ /**
47
+ * Best-effort cwd->project detection for `agents projects view <path>`. Delegates
48
+ * to the shared {@link projectNameForCwd} (def-root containment, longest wins),
49
+ * then reads the matched definition's Linear binding and root so ONE call yields
50
+ * the name AND the Linear projectId. Fail-open: an all-null shape when nothing
51
+ * contains the path — never throws, so a scripted caller stays unscoped rather
52
+ * than crashing. This subsumes the removed `agents projects for-cwd`.
53
+ */
54
+ export function detectProjectForPath(cwd, defs) {
55
+ const name = projectNameForCwd(cwd, defs);
56
+ const def = name ? defs.find((d) => d.name === name) : undefined;
57
+ return {
58
+ name: name ?? null,
59
+ linear: {
60
+ name: def?.linear?.name ?? null,
61
+ projectId: def?.linear?.projectId ?? null,
62
+ },
63
+ root: def?.root ?? null,
64
+ };
48
65
  }
49
66
  /**
50
67
  * One compact trailing note for peers that didn't answer the `--fleet`
@@ -570,17 +587,6 @@ export function registerProjectsCommands(program) {
570
587
  console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))}${agentsSuffix}`);
571
588
  }
572
589
  });
573
- // ---- for-cwd ----
574
- projects
575
- .command('for-cwd [cwd]')
576
- .description('Resolve a directory to its defined project name (root or a repos[].path/subpath match). Defaults to the current directory.')
577
- .option('--json', 'Machine-readable output: {"name": string | null}')
578
- .action((cwdArg, opts) => {
579
- const name = projectNameForCwd(cwdArg ?? process.cwd(), listProjectDefs());
580
- const out = formatForCwdOutput(name, !!opts.json);
581
- if (out)
582
- console.log(out);
583
- });
584
590
  // ---- add ----
585
591
  projects
586
592
  .command('add <name>')
@@ -763,15 +769,42 @@ export function registerProjectsCommands(program) {
763
769
  process.stdout.write(formatFleetSkippedNote(fleetSkipped));
764
770
  }
765
771
  projects
766
- .command('status [name]')
772
+ .command('status [nameOrPath]')
767
773
  .alias('view')
768
- .description('Progress card for every project across the whole fleet, or one named project (alias: view). Named form also prints every milestone and the stored definition.')
774
+ .description('Progress card for every project across the whole fleet, or one named project (alias: view). Named form also prints every milestone and the stored definition. A path argument (., .., a ~-prefixed value, a /-containing value, or --path) auto-detects the project that CONTAINS that directory.')
769
775
  .option('--json', 'Machine-readable output')
776
+ .option('--path [dir]', 'Treat the argument as a DIRECTORY and auto-detect the project that contains it (bare --path uses the cwd). With --json prints {name, linear:{name,projectId}, root}, all-null when nothing matches (fail-open, exit 0).')
770
777
  .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
771
778
  .option('--no-remote', 'Skip the GitHub and Linear lookups; faster, offline')
772
779
  .option('--device <name...>', 'Scope fleet status to one or more devices (repeatable)')
773
780
  .option('--devices <names>', 'Scope fleet status to a comma-separated list of devices')
774
781
  .action(async (name, rawOpts) => {
782
+ // Path mode: an explicit --path, or a path-shaped positional (., .., a
783
+ // ~-prefixed value, or a /-containing value) means "auto-detect the
784
+ // project for this DIRECTORY" rather than "a project named literally
785
+ // this". projectNameForCwd resolves the dir (expandLocalHome +
786
+ // path.resolve), so `.`, `~/…`, and relative paths all normalize. This
787
+ // subsumes the removed `agents projects for-cwd`.
788
+ let detectDir;
789
+ if (typeof rawOpts.path === 'string')
790
+ detectDir = rawOpts.path;
791
+ else if (rawOpts.path === true)
792
+ detectDir = process.cwd();
793
+ else if (name !== undefined && looksLikePath(name))
794
+ detectDir = name;
795
+ if (detectDir !== undefined) {
796
+ const detection = detectProjectForPath(detectDir, listProjectDefs());
797
+ if (rawOpts.json) {
798
+ console.log(JSON.stringify(detection));
799
+ return;
800
+ }
801
+ if (!detection.name) {
802
+ console.log(chalk.gray(`No defined project contains ${detectDir}`));
803
+ return;
804
+ }
805
+ // Non-JSON: fall through and render the full card for the detected project.
806
+ name = detection.name;
807
+ }
775
808
  // Named invocation = `view` depth (all milestones + definition). Unnamed
776
809
  // stays the scannable multi-project rollup. `view` is a commander alias
777
810
  // of this same command, so there is only one implementation.
@@ -2,7 +2,9 @@ import chalk from 'chalk';
2
2
  import { die } from '../lib/format.js';
3
3
  import { setHelpSections } from '../lib/help.js';
4
4
  import { readMeta } from '../lib/state.js';
5
- import { sendMessage } from '../lib/channels/send.js';
5
+ import { sendMessage, isOwnerAlias } from '../lib/channels/send.js';
6
+ import { composeOwnerMessage } from '../lib/owner-message.js';
7
+ import { fireTraceSyncInBackground } from '../lib/run-trace-sync.js';
6
8
  function mergeAttachments(opts) {
7
9
  const list = [...(opts.attach ?? []), ...(opts.attachment ?? [])];
8
10
  return list.length ? list : undefined;
@@ -23,7 +25,28 @@ function toInput(positionalText, opts, ownerMode) {
23
25
  }
24
26
  async function runSend(positionalText, opts, ownerMode) {
25
27
  const meta = readMeta();
26
- const out = await sendMessage(toInput(positionalText, opts, ownerMode), meta);
28
+ let input = toInput(positionalText, opts, ownerMode);
29
+ // An owner-bound ping (`agents notify`, `agents send --to owner`) goes through
30
+ // the SAME composer as an important `feed post` (PHNX-3698): short-shaped body,
31
+ // TEAM-N keys linkified, session crumb as a tappable console URL — instead of a
32
+ // raw dump. A non-owner send (explicit --channel/--to) is delivered verbatim.
33
+ if (ownerMode || isOwnerAlias(opts.to)) {
34
+ const flagged = opts.text?.trim() ?? '';
35
+ const positional = (positionalText ?? '').trim();
36
+ const raw = flagged || positional;
37
+ // When both forms are given and disagree, leave it to sendMessage to fail
38
+ // loud with the "pass the message once" error rather than composing a guess.
39
+ const bothDiffer = flagged !== '' && positional !== '' && flagged !== positional;
40
+ if (raw && !bothDiffer) {
41
+ input = { ...input, text: composeOwnerMessage(raw), positionalText: undefined };
42
+ // The console URL in the composed body only resolves once this session's
43
+ // trace shard is uploaded; fire that now so the tapped link isn't a 404.
44
+ // A --dry-run resolves + composes but MUST NOT act (its documented contract),
45
+ // so it never spawns the sync — it just shows what would be sent.
46
+ fireTraceSyncInBackground({ disabled: Boolean(opts.dryRun) });
47
+ }
48
+ }
49
+ const out = await sendMessage(input, meta);
27
50
  if ('error' in out) {
28
51
  die(out.error);
29
52
  }
@@ -127,6 +150,10 @@ export function registerSendCommand(program) {
127
150
  Set owner.channels + owner.policy.normal in humans.yaml once per fleet.
128
151
  Every channel listed in the normal policy receives an owner-addressed send.
129
152
 
153
+ Owner sends go through the same composer as "feed post": the body is
154
+ short-shaped, any TEAM-N key becomes a Linear URL, and the session crumb
155
+ becomes a tappable https://prix.dev/console/sessions/<id> link.
156
+
130
157
  ${SHARED_NOTES}
131
158
  `,
132
159
  });
@@ -13,5 +13,63 @@
13
13
  * `--pane`/`--pty` target a backend directly when the handle is already known.
14
14
  */
15
15
  import type { Command } from 'commander';
16
+ import { type ActiveSession } from '../lib/session/active.js';
17
+ interface InjectOptions {
18
+ pane?: string;
19
+ socket?: string;
20
+ pty?: string;
21
+ /**
22
+ * The remote device. A single `--device box` arrives here as `['box']` because
23
+ * the parent `sessions` command's variadic `-D, --device <target...>` shadows
24
+ * this subcommand's scalar option under `optsWithGlobals()` — normalize it with
25
+ * {@link normalizeInjectDevice} before use (PHNX-3688).
26
+ */
27
+ device?: string | string[];
28
+ enter?: boolean;
29
+ combined?: boolean;
30
+ json?: boolean;
31
+ }
32
+ /**
33
+ * Whether an active session is the one `sessions inject <token>` means. Matches
34
+ * a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
35
+ * whose full id never resolved (`sessionId` absent) — the `ag-<agent>-<shortid>`
36
+ * tmux name's `shortid` suffix (exact or prefix), the full tmux name, and the
37
+ * pane id. Those are the only selectors an id-less remote tmux row exposes, so
38
+ * without this an operator has no tool-native way to nudge it (PHNX-3688).
39
+ */
40
+ export declare function matchInjectSelector(session: ActiveSession, token: string): boolean;
41
+ /**
42
+ * The `--device` selector, normalized to a single host string. `optsWithGlobals()`
43
+ * merges the parent `sessions` command's variadic `-D, --device <target...>` over
44
+ * this subcommand's scalar `--device`, so a single `--device box` arrives as
45
+ * `['box']` — which flowed straight into `sshExec` and crashed on
46
+ * `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
47
+ * several, since inject delivers to exactly one terminal (a fan-out spelling is a
48
+ * user error, not a first-of-list guess).
49
+ */
50
+ export declare function normalizeInjectDevice(value: string | string[] | undefined): string | undefined;
51
+ /**
52
+ * The `agents sessions inject` argv to re-run ON a device (its tmux panes live
53
+ * there, so resolution must happen there). Every flag rides along EXCEPT
54
+ * `--device`: the command runs on the device, resolving locally. Pure so the
55
+ * forwarded invocation is asserted without an SSH hop (PHNX-3688).
56
+ */
57
+ export declare function buildRemoteInjectArgv(sessionId: string, text: string, options: InjectOptions): string[];
58
+ /**
59
+ * Resolve `device` (registry alias or `user@host`) to an ssh target and re-run
60
+ * `agents sessions inject` there, so a bare session id + `--device` resolves on
61
+ * the box that actually holds the session's tmux panes. The tool-native form of
62
+ * the `agents ssh <device> "agents sessions inject <id> …"` workaround (PHNX-3688).
63
+ */
64
+ /**
65
+ * Resolve `--device` to an ssh target. A registered device becomes its
66
+ * `user@dnsName`; a bare unknown name (an ad-hoc `user@host` or ssh_config alias)
67
+ * is handed to ssh verbatim (`resolveHost` returns null for it). A registered
68
+ * device we CANNOT dial — password-auth, addressless — throws its typed error and
69
+ * is NOT degraded to the raw name, which could ssh a coincidentally-matching but
70
+ * unrelated `~/.ssh/config` Host (PHNX-3688 review).
71
+ */
72
+ export declare function resolveInjectSshTarget(device: string): Promise<string>;
16
73
  /** Attach the `inject` subcommand to an existing `sessions` command. */
17
74
  export declare function registerSessionsInjectCommand(sessionsCmd: Command): void;
75
+ export {};