@phnx-labs/agents-cli 1.22.107 → 1.22.110

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 (95) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/bootstrap.js +3 -2
  3. package/dist/commands/browser-sessions-picker.js +2 -1
  4. package/dist/commands/computer-sessions-picker.js +2 -1
  5. package/dist/commands/cost.js +2 -1
  6. package/dist/commands/daemon.js +45 -40
  7. package/dist/commands/doctor.js +7 -0
  8. package/dist/commands/exec.js +4 -2
  9. package/dist/commands/fork.js +6 -4
  10. package/dist/commands/logs.js +2 -1
  11. package/dist/commands/monitors.js +15 -2
  12. package/dist/commands/routines.js +46 -6
  13. package/dist/commands/sessions-backfill.d.ts +30 -0
  14. package/dist/commands/sessions-backfill.js +72 -0
  15. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  16. package/dist/commands/sessions-backup-setup.js +65 -0
  17. package/dist/commands/sessions-inject.d.ts +3 -3
  18. package/dist/commands/sessions-inject.js +5 -11
  19. package/dist/commands/sessions-picker.js +10 -7
  20. package/dist/commands/sessions-resume.d.ts +17 -2
  21. package/dist/commands/sessions-resume.js +88 -12
  22. package/dist/commands/sessions.js +46 -24
  23. package/dist/commands/share.js +15 -2
  24. package/dist/commands/sync.js +30 -1
  25. package/dist/commands/utils.d.ts +9 -0
  26. package/dist/commands/utils.js +18 -0
  27. package/dist/commands/watchdog.js +4 -1
  28. package/dist/lib/accounting/rotate.d.ts +9 -0
  29. package/dist/lib/accounting/rotate.js +17 -1
  30. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  31. package/dist/lib/accounting/usage-sync.js +70 -6
  32. package/dist/lib/accounting/usage.d.ts +32 -0
  33. package/dist/lib/accounting/usage.js +37 -2
  34. package/dist/lib/accounts/slots.js +7 -0
  35. package/dist/lib/agent-spec/agents.d.ts +60 -8
  36. package/dist/lib/agent-spec/agents.js +118 -45
  37. package/dist/lib/auth-health.js +19 -0
  38. package/dist/lib/claude-statusline.js +5 -0
  39. package/dist/lib/computer/sessions-list.js +2 -1
  40. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  41. package/dist/lib/daemon/auth-sync-service.js +2 -2
  42. package/dist/lib/daemon/daemon.d.ts +34 -1
  43. package/dist/lib/daemon/daemon.js +70 -9
  44. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  45. package/dist/lib/daemon/leaked-daemons.js +180 -0
  46. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  47. package/dist/lib/daemon/session-title-service.js +76 -0
  48. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  49. package/dist/lib/daemon/usage-sync-service.js +10 -2
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +5 -0
  52. package/dist/lib/daemon-ticks.js +6 -0
  53. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  54. package/dist/lib/devices/doctor-findings.js +32 -1
  55. package/dist/lib/fleet-shared-state.d.ts +2 -0
  56. package/dist/lib/hooks/install.js +78 -64
  57. package/dist/lib/mailbox-target.js +2 -1
  58. package/dist/lib/models.js +76 -5
  59. package/dist/lib/session/active.d.ts +93 -16
  60. package/dist/lib/session/active.js +80 -14
  61. package/dist/lib/session/cloud.js +3 -1
  62. package/dist/lib/session/db.d.ts +44 -1
  63. package/dist/lib/session/db.js +122 -17
  64. package/dist/lib/session/fork.d.ts +10 -2
  65. package/dist/lib/session/fork.js +11 -2
  66. package/dist/lib/session/live-metadata.js +6 -0
  67. package/dist/lib/session/mirror.d.ts +3 -2
  68. package/dist/lib/session/mirror.js +5 -2
  69. package/dist/lib/session/parse.d.ts +1 -0
  70. package/dist/lib/session/parse.js +136 -2
  71. package/dist/lib/session/recovery.d.ts +9 -1
  72. package/dist/lib/session/recovery.js +14 -4
  73. package/dist/lib/session/remote/watch.js +18 -8
  74. package/dist/lib/session/title.d.ts +256 -0
  75. package/dist/lib/session/title.js +312 -0
  76. package/dist/lib/session/tool-calls.d.ts +1 -1
  77. package/dist/lib/session/tool-calls.js +40 -5
  78. package/dist/lib/session/tool-index.d.ts +5 -0
  79. package/dist/lib/session/tool-index.js +1 -0
  80. package/dist/lib/session/types.d.ts +11 -0
  81. package/dist/lib/share/backend.d.ts +23 -0
  82. package/dist/lib/share/backend.js +24 -0
  83. package/dist/lib/share/provision.d.ts +12 -0
  84. package/dist/lib/share/provision.js +30 -0
  85. package/dist/lib/share/worker-template.js +273 -0
  86. package/dist/lib/startup/root-command.d.ts +2 -0
  87. package/dist/lib/startup/root-command.js +22 -0
  88. package/dist/lib/terminal/engine.js +13 -1
  89. package/dist/lib/traces/sync.js +1 -0
  90. package/dist/lib/usage-refresh.d.ts +58 -7
  91. package/dist/lib/usage-refresh.js +145 -23
  92. package/dist/lib/watchdog/runner.d.ts +3 -0
  93. package/dist/lib/watchdog/runner.js +1 -0
  94. package/dist/session-tracker/dist/install-hook.js +4 -4
  95. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,101 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.110
4
+
5
+ - **Session rows are named by what YOU asked for, not by the agent's latest
6
+ message (PHNX-3797).** Every headline in `agents sessions`, the picker, and the
7
+ AGI EXT Sessions/Fleet list used to be the agent's last transcript line
8
+ ("Both seams verified on the real shipped artifacts…") — verbose, rolling, and
9
+ unrecognizable. The headline ladder is now `/rename` label → a
10
+ daemon-generated title → your own first message; the agent's live line stays in
11
+ the separate preview slot where a rolling status belongs. The new
12
+ `session-title` daemon service generates a short, descriptive **action + object
13
+ headline** ("Triage the AGI board", not just "Triage") with a cheap model
14
+ through a swappable `SessionTitleProvider` (cloud default; a local ollama-style
15
+ backend can be dropped in later without touching the tick), read-only plan mode,
16
+ **once** per session and
17
+ persists it in the session index against a hash of the message it came from, so
18
+ a titled session costs no further model calls and only re-titles when that first
19
+ message changes. Nothing generates per client or per tick: the value rides the
20
+ existing `sessions watch --json` stream and the fleet session mirror, so a
21
+ remote box's rows show the same title with no per-row SSH. Until the titler
22
+ reaches a session it shows your own words — never an agent line.
23
+ `agents sessions backfill titles [--session <id>] [--refresh]` runs it on
24
+ demand; `agents daemon services` can turn the service off.
25
+ - **Session rows carry a ranked secondary line (PHNX-3797).** Beside the bold
26
+ headline, every live row now folds on `importantMessage` — the single most
27
+ important recent agent message, ranked so a pending **question** or a
28
+ **needs-you** block (plan review, permission, input-required) beats generic
29
+ current activity. It rides the same `sessions watch --json` / mirror feed as the
30
+ title (via the row spread), so AGI EXT renders a bold title over a dim secondary
31
+ line with no extra query; `agents sessions --active` prints the question /
32
+ needs-you line dim beneath the row.
33
+ Source: `src/lib/session/title.ts`, `src/lib/daemon/session-title-service.ts`,
34
+ `src/lib/session/active.ts`, `src/lib/session/db.ts`, `src/lib/session/mirror.ts`,
35
+ `src/commands/sessions.ts`, `src/commands/sessions-backfill.ts`.
36
+
37
+ - Codex account sessions record their originating account after hook sync, including macOS homes relocated for Unix socket limits. Registration refreshes trust for the runtime home and removes the obsolete builtin tracker that could overwrite account metadata; unrelated hooks and explicit disabled states are preserved.
38
+
39
+ - **Session resume preserves device and picker filters (PHNX-3940).** `sessions resume --device <host> <id>` and the equivalent device-after-ID form now open on the selected origin device. Agent, account, directory, time, and limit filters reach the shared picker; an unspecified limit remains 200. Repeated different devices produce an error.
40
+
41
+ - **Usage snapshots stay fresh enough for balanced to auto-pick (PHNX-3940 W3).** A headed box polls only the accounts it holds native logins for; a setup-token-only worker never hits the usage endpoint. A changed snapshot is published immediately (statusline ingest or poll) instead of waiting for the 15-minute tick. `isUsageVerified` trusts a row that arrived via sync from the account's poller for that 15-minute cadence, and keeps the 5-minute bar for a local capture. `auth-sync` and `usage-sync` take the shared-repo lock on different offsets so they no longer starve each other. Source: `cli/src/lib/usage-refresh.ts`, `cli/src/lib/accounting/rotate.ts`, `cli/src/lib/accounting/usage-sync.ts`.
42
+
43
+ ## 1.22.109
44
+
45
+ - **`agents sessions backup-setup` provisions the managed session-backup endpoint (PHNX-3726).** The operator command that deploys `sessions.agents-cli.sh` — the Cloudflare Worker + R2 bucket a signed-in user's `agents sessions export --to-r2` backs up to with no `r2.backups` bucket of their own. Mirrors `agents traces setup`: reads Cloudflare credentials from the `cloudflare` secrets bundle (`agents secrets exec cloudflare -- agents sessions backup-setup`), idempotent (re-running redeploys the current Worker template in place). It is NOT a per-user step — signing in with `agents auth login` is all a user does; this is how the first-party endpoint itself is deployed. The Worker it deploys is Phoenix-only with no static token (PHNX-3726). Source: `cli/src/commands/sessions-backup-setup.ts`, `cli/src/lib/session/sync/provision.ts` (+ `provision.test.ts`), `cli/docs/sessions.md`.
46
+
47
+ - **`agents doctor` flags a leaked daemon no owner record names (W4, PHNX-3736).**
48
+ A headless e2e session launched `agents __daemon-run` under
49
+ `HOME=/tmp/pin-e2e-<pid>` and never stopped it; the daemon ran 4+ days,
50
+ invisible to the pid-file takeover because it keeps its own pid file under the
51
+ temp home. `agents doctor` now reports any same-uid `__daemon-run` whose pid is
52
+ neither the service manager's unit main PID nor the recorded `daemon.pid` —
53
+ checked against both the caller's HOME and the real account home, so a
54
+ test-harness caller never accuses the production daemon — as a `leaked-daemon`
55
+ warning carrying the process's HOME and start time, with a `kill <pid>`
56
+ remediation. Source: `cli/src/lib/daemon/leaked-daemons.ts`,
57
+ `cli/src/lib/devices/doctor-findings.ts`.
58
+
59
+ - **`agents daemon start` refuses under a redirected HOME unless
60
+ `AGENTS_ALLOW_TEST_DAEMON=1` (W4, PHNX-3736).** RUSH-3021 gated auto-start from
61
+ a sandbox/test HOME but left the explicit `startDaemon()` open — the path the
62
+ e2e harness took to leak the `/tmp/pin-e2e-<pid>` daemon. The launch now throws
63
+ `RedirectedHomeDaemonError` (printed without a stack, exit 1); a deliberate
64
+ test/e2e launch opts in with `AGENTS_ALLOW_TEST_DAEMON=1` and owns stopping
65
+ what it starts. Reporting an already-running daemon — and therefore
66
+ `agents daemon stop` of a leaked one — is not gated. Auto-start side effects
67
+ (`routines add`, webhook fires, `monitors add`) state the refusal and leave
68
+ the foreground command green, the same tier split as the auto-start circuit
69
+ breaker; only the explicit start commands fail loud. Source:
70
+ `cli/src/lib/daemon/daemon.ts` (`startDaemon`).
71
+
72
+ - **The managed share Worker now proxies same-origin human collaboration to Prix (PHNX-3835).** The Cloudflare Worker that fronts a managed share gains a `/__collab/*` transport so authorized humans can review a hosted artifact together (comments now; agent mentions/wake-up later). It stays the trust boundary exactly as the page GET does: every `/__collab` request loads the R2 object FIRST (a missing/just-deleted share 404s, so existence is never enumerable), re-derives the share id (`SHA-256(ownerId + NUL + normalized path)`), current revision (etag), owner, visibility, org domain, and producer provenance SERVER-SIDE from R2 metadata — the browser-supplied `share` is a lookup key only, never trusted as identity — then reuses the EXACT page read gate (me/org identity gate + private viewer-token gate) with every denial normalized to 404. Public/unlisted readers may read anonymously; every write additionally requires a verified signed-in Phoenix human; private still requires its share token. It proxies to the Prix `/v1/artifact-collaboration` routes (`context`, `threads` list/create, thread replies, comment/thread PATCH, SSE `events`, and a best-effort authenticated `purge` on share delete) with the service token in `Authorization` (never surfaced to the browser) plus trusted `X-Artifact-*` / `X-Phoenix-Actor-*` headers, preserves the client `Idempotency-Key` and `Last-Event-ID`, streams SSE through unbuffered (cancel/disconnect propagated), and marks every collaboration response `no-store`. A visibility downgrade is immediate (re-gated per request) and a deleted share drops its comments the instant the object is gone. The whole surface fails closed (404) when `PRIX_ARTIFACT_COLLAB_BASE` / `ARTIFACT_COLLAB_SERVICE_TOKEN` are absent — so BYO endpoints (no Phoenix identity) never expose it and the artifact page GET is unaffected — and stays dormant until an operator sets both secrets, which `agents artifacts share update`/`setup` now apply on a managed deploy (both or neither). Because the Worker template changed, already-deployed managed endpoints read `outdated` until re-run through `agents artifacts share update`. Source: `cli/src/lib/share/worker-template.ts`, `cli/src/lib/share/provision.ts`, `cli/src/lib/share/backend.ts`, `cli/src/commands/share.ts`, `cli/docs/share.md`.
73
+
74
+ - **`agents sync --dry-run` no longer mutates every native home (PHNX-3923).** The umbrella verb (bare `agents sync`, including `--local`/`--yes`/`--json`) ignored `--dry-run` entirely: it ran the full reconcile, evicted central browser profiles, verified, and repaired — mutating every installed version home despite the preview flag. The umbrella composes only mutating stages (repo `git pull`, `refresh()` reconcile, device sync, `repairAfterSync`) with no non-mutating preview, so it now refuses `--dry-run` LOUD and up front — before any change — and points at the scoped path (`agents sync <agent> --dry-run`), which honors it non-destructively. `--json` reports `{ ok: false, mode: 'umbrella', dryRun: true, error, hint, installedAgents }`. Source: `cli/src/commands/sync.ts`.
75
+
76
+ - Terminal session resumes preserve follow-up prompts, including spaces and shell characters. Remote terminals use the origin directory or explicit `--cwd`; run pickers retain `--remote-cwd` precedence, and home-relative paths expand on the target. An invalid remote directory or failed terminal launch reports failure.
77
+
78
+ - Publishes the session-resume fixes from the unshipped 1.22.108 candidate: account homes refresh their session tracker, Codex hook trust follows registration, empty local picks are skipped, and remote picks are validated on their origin device. The 1.22.108 tag is preserved; npm publication resumes with this patch after correcting its release notes.
79
+
80
+ - **`agents view` shows OpenCode's real model, account, plan, and last-active (PHNX-3982).** The OpenCode row was the only one in the table with nothing usable in it — `1.18.15 (default) default id:meta+openai+opencode-go` — while every other harness showed a model, an email, and a plan. Three separate reads were pointed at the wrong place. (1) The model came from `<home>/.opencode/settings.json`, which is agents-cli's own plugin-enablement file; OpenCode reads `~/.config/opencode/opencode.{jsonc,json}`, and ships no catalog default, so the row fell through to the literal placeholder `default`. Both spellings are now read comment-tolerantly, and when nothing is configured the row shows the model OpenCode itself will start with, from the selection it persists to `$XDG_STATE_HOME/opencode/model.json`. (2) The account showed only the provider join because the code asserted OpenCode's `auth.json` carries no identity — it does, for `type: 'oauth'` providers, in the same namespaced JWT claims `case 'codex'` already decodes, so the email and plan now render (secrets are never read out, only claims). (3) `lastActive` was blank because the per-file session walk finds nothing for a harness that keeps every session in one sqlite file; it now dates from `opencode.db`. Live usage *windows* remain unavailable for OpenCode — it records no rate-limit state locally and each provider would need its own reader. Source: `cli/src/lib/agent-spec/agents.ts`, `cli/src/lib/models.ts`.
81
+
82
+ - **Codex trace error rates now reflect failed tool calls (PHNX-3761).** The tool-call index recognizes Codex's script completion and failure markers, preserves text across input blocks, and re-indexes older rows instead of reporting failed calls as unknown.
83
+
84
+ - **`agents sessions --cloud <id>` and `agents cloud view` open OpenCode cloud transcripts (PHNX-3845).** Cloud discovery and fetch accept OpenCode sessions, and normalized cloud transcripts are parsed separately from local SQLite sessions. Both `bash` and `shell` tool names appear as command events.
85
+
86
+ ## 1.22.108
87
+
88
+ - Account sync now refreshes the native session-tracking hook in every account home, replacing stale registrations from previous CLI installations so new sessions retain their account identity. Codex tracker installation also refreshes hook trust so headless runs can execute the registered hook.
89
+
90
+ - Resume selection validates peer-owned transcripts on their origin device instead of treating a missing local copy as a missing session. Local empty picks are still skipped; explicit local replay still requires readable local content.
91
+
92
+ - **The resume picker skips a local session with no transcript before opening a
93
+ tab (PHNX-4080).** Empty local selections are reported as skipped, and a batch
94
+ with nothing left to resume exits 1. Peer-owned sessions and mirror entries are
95
+ validated on their origin device. Explicit local replay still requires readable
96
+ local content; the executing device refuses recovery when no transcript exists.
97
+ Source: `src/commands/sessions-resume.ts`, `src/lib/session/recovery.ts`.
98
+
3
99
  ## 1.22.107
4
100
 
5
101
  - **A bare interactive run places itself like `--device auto` (PHNX-4083).**
package/dist/bootstrap.js CHANGED
@@ -19,7 +19,7 @@ import * as fs from 'fs';
19
19
  import * as path from 'path';
20
20
  import { fileURLToPath } from 'url';
21
21
  import { detectDevBuild } from './lib/startup/dev-build.js';
22
- import { configureRootCommand } from './lib/startup/root-command.js';
22
+ import { configureRootCommand, normalizeResumeDeviceArgs } from './lib/startup/root-command.js';
23
23
  import { bootMark } from './lib/boot-profile.js';
24
24
  // `ora`, `@inquirer/prompts`, `./commands/utils.js`, and the agents/versions/shims
25
25
  // modules are imported dynamically at their use sites: they are needed only on
@@ -877,7 +877,8 @@ program.on('command:*', (operands) => {
877
877
  // Parse the invocation shape up front: the first non-flag token is the command,
878
878
  // and the doc flags (--version/--help/-h) drive both the registration strategy
879
879
  // and whether the update check + background sync run at all.
880
- const passedArgs = process.argv.slice(2);
880
+ const passedArgs = normalizeResumeDeviceArgs(process.argv.slice(2));
881
+ process.argv.splice(2, process.argv.length - 2, ...passedArgs);
881
882
  // Commander owns `--version` on the root command and otherwise intercepts it
882
883
  // even after `sessions`, before the subcommand can parse its version filter.
883
884
  // Rewrite only that value-taking nested form; bare `agents --version` and every
@@ -18,6 +18,7 @@ import { buildPreview } from './sessions-picker.js';
18
18
  import { createSessionsPickerCommand } from './sessions-picker-factory.js';
19
19
  import { runBrowserSessions, buildBrowserSessionRows, matchesBrowserSessionRow, formatBytes, } from '../lib/browser/sessions-list.js';
20
20
  import { formatRelativeTime } from '../lib/session/relative-time.js';
21
+ import { sessionHeadline } from '../lib/session/title.js';
21
22
  const KIND_LABEL = {
22
23
  screenshot: 'shots',
23
24
  pdf: 'pdf',
@@ -37,7 +38,7 @@ function rowLinkSummary(row) {
37
38
  return '';
38
39
  if (row.linkStatus === 'linked' && row.linkedSession) {
39
40
  const s = row.linkedSession;
40
- return chalk.cyan(s.agent) + ' — ' + (s.label || s.topic || s.shortId);
41
+ return chalk.cyan(s.agent) + ' — ' + (sessionHeadline(s) || s.shortId);
41
42
  }
42
43
  if (row.linkStatus === 'unresolved') {
43
44
  return chalk.yellow(`owner ${row.owner ?? 'unknown'} (session not indexed here)`);
@@ -21,10 +21,11 @@ import { buildPreview } from './sessions-picker.js';
21
21
  import { createSessionsPickerCommand } from './sessions-picker-factory.js';
22
22
  import { runComputerSessions, printComputerSessionRows, buildComputerSessionRows, matchesComputerSessionRow, formatRowActions, } from '../lib/computer/sessions-list.js';
23
23
  import { formatRelativeTime } from '../lib/session/relative-time.js';
24
+ import { sessionHeadline } from '../lib/session/title.js';
24
25
  function rowLinkSummary(row) {
25
26
  if (row.linkStatus === 'linked' && row.linkedSession) {
26
27
  const s = row.linkedSession;
27
- return chalk.cyan(s.agent) + ' — ' + (s.label || s.topic || s.shortId);
28
+ return chalk.cyan(s.agent) + ' — ' + (sessionHeadline(s) || s.shortId);
28
29
  }
29
30
  if (row.linkStatus === 'unresolved') {
30
31
  return chalk.yellow(`owner ${row.agent ?? 'unknown'} (session not indexed here)`);
@@ -5,6 +5,7 @@ import { queryUsageRollup, topSessionsByCost, } from '../lib/session/db.js';
5
5
  import { formatUsd, PRICING_VERSION } from '../lib/pricing/index.js';
6
6
  import { formatDuration } from '../lib/session/render.js';
7
7
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
8
+ import { sessionHeadline } from '../lib/session/title.js';
8
9
  /** Register `agents insights cost` under the insights parent. */
9
10
  export function registerCostCommand(insightsCmd) {
10
11
  addHostOption(insightsCmd.command('cost'))
@@ -99,7 +100,7 @@ async function costAction(options) {
99
100
  for (const t of top) {
100
101
  const cost = formatUsd(t.costUsd).padStart(costW);
101
102
  const dur = t.durationMs > 0 ? formatDuration(t.durationMs) : '—';
102
- const label = (t.meta.label || t.meta.topic || '(untitled)').replace(/\s+/g, ' ').trim();
103
+ const label = (sessionHeadline(t.meta) || '(untitled)').replace(/\s+/g, ' ').trim();
103
104
  const proj = showProject && t.meta.project ? chalk.gray(` ${t.meta.project}`) : '';
104
105
  const prefix = ` ${chalk.green(cost)} ${chalk.gray(t.meta.shortId)} ${chalk.cyan(t.meta.agent.padEnd(7))} `;
105
106
  const suffix = proj + chalk.gray(` ${dur}`);
@@ -21,7 +21,8 @@ import { execFileSync } from 'child_process';
21
21
  import * as fs from 'fs';
22
22
  import * as path from 'path';
23
23
  import { setHelpSections } from '../lib/help.js';
24
- import { getDaemonStatus, isDaemonRunning, isDaemonWedged, readDaemonLog, startDaemon, stopDaemon, signalDaemonReload, findSurvivingStateDirDaemons, getDaemonLogPath, isDaemonAutostartCircuitOpen, } from '../lib/daemon/daemon.js';
24
+ import { getDaemonStatus, isDaemonRunning, isDaemonWedged, readDaemonLog, startDaemon, stopDaemon, signalDaemonReload, findSurvivingStateDirDaemons, getDaemonLogPath, isDaemonAutostartCircuitOpen, RedirectedHomeDaemonError, } from '../lib/daemon/daemon.js';
25
+ import { listDaemonRunProcesses } from '../lib/daemon/leaked-daemons.js';
25
26
  import { getConfigValue, setConfigValue, isDaemonEnabled } from '../lib/device-config.js';
26
27
  import { readSubsystemHealth, readAllSubsystemHealth, SUBSYSTEM_BROWSER_IPC, SUBSYSTEM_DAEMON_START, } from '../lib/daemon-health.js';
27
28
  import { DAEMON_SERVICE_IDS, listDaemonServiceStates, setDaemonServiceEnabled, getDaemonServicesConfigPath, queueDaemonServiceRestart, } from '../lib/daemon-services.js';
@@ -32,6 +33,27 @@ import { parseDuration } from '../lib/hooks/cache.js';
32
33
  import { registerFunnelCommand } from './funnel.js';
33
34
  import { DEFAULT_WEBHOOK_PORT, DEFAULT_WEBHOOK_RATE_LIMIT, addHostedReceiver, getDaemonWebhooksConfigPath, hostedReceiverPort, readDaemonWebhooksConfig, removeHostedReceiver, } from '../lib/daemon-webhooks.js';
34
35
  import { parseFunnelPort } from '../lib/funnel.js';
36
+ // ─── Process scanning — which install owns the pid, and every duplicate ──────
37
+ /**
38
+ * startDaemon for the explicit start/restart commands: the redirected-HOME
39
+ * refusal (W4, PHNX-3736) is user-actionable, not an engineering bug, so it
40
+ * prints without a stack and exits 1. Anything else (a genuinely unspawnable
41
+ * binary) still throws. Kept local to this command rather than in bootstrap's
42
+ * catch list — a bootstrap.ts edit selects the 54s non-interactive suite into
43
+ * the required impact gate, and this change's budget cannot carry it.
44
+ */
45
+ function startDaemonClean() {
46
+ try {
47
+ return startDaemon();
48
+ }
49
+ catch (err) {
50
+ if (err instanceof RedirectedHomeDaemonError) {
51
+ console.error(chalk.red(err.message));
52
+ process.exit(1);
53
+ }
54
+ throw err;
55
+ }
56
+ }
35
57
  /**
36
58
  * Provably absent, or null when unknowable.
37
59
  *
@@ -188,19 +210,14 @@ function entryFromTokens(tokens) {
188
210
  return tokens.length >= 2 ? tokens[tokens.length - 2] : null;
189
211
  }
190
212
  /**
191
- * Every live `__daemon-run` process on this box, regardless of which install
192
- * launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
193
- * Windows.
194
- *
195
- * `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
196
- * after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
197
- * A substring/regex test anywhere in the full command line is not enough: an
198
- * `agents run claude "<prompt>"` invocation whose prompt happens to quote the
199
- * literal text `__daemon-run` (this ticket's own brief does) matches that test
200
- * too, and was observed producing false "duplicate daemon" rows. Requiring it
201
- * to be the LAST whitespace-delimited token is the actual invariant.
213
+ * Every live `__daemon-run` process on this box, enriched with display
214
+ * metadata (entry/version/entryMissing). The raw `ps` scan and its
215
+ * last-token `__daemon-run` invariant live in
216
+ * `lib/daemon/leaked-daemons.ts` (`listDaemonRunProcesses`) — shared with the
217
+ * doctor's leaked-daemon detection so the two can never disagree on what a
218
+ * daemon process is.
202
219
  *
203
- * This raw box-wide scan is deliberately NOT the duplicate-detection scope
220
+ * This box-wide scan is deliberately NOT the duplicate-detection scope
204
221
  * (RUSH-2368): a `__daemon-run` under a different HOME serves a different
205
222
  * `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
206
223
  * `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
@@ -209,35 +226,14 @@ function entryFromTokens(tokens) {
209
226
  * `findSurvivingStateDirDaemons` has already confirmed as real duplicates.
210
227
  */
211
228
  function scanDaemonProcesses() {
212
- if (process.platform === 'win32')
213
- return [];
214
- let out;
215
- try {
216
- out = execFileSync('ps', ['-eo', 'pid=,uid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
217
- }
218
- catch {
219
- return [];
220
- }
221
229
  const found = [];
222
- for (const line of out.split('\n')) {
223
- const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
224
- if (!m)
225
- continue;
226
- const args = m[3].trim();
227
- const tokens = args.split(/\s+/);
228
- if (tokens.length === 0 || tokens[tokens.length - 1] !== '__daemon-run')
229
- continue;
230
- const pid = parseInt(m[1], 10);
231
- if (isNaN(pid))
232
- continue;
233
- const uidParsed = parseInt(m[2], 10);
234
- const uid = isNaN(uidParsed) ? null : uidParsed;
235
- const entry = entryFromTokens(tokens);
236
- const version = entry ? resolveVersionNear(entry, pid) : null;
230
+ for (const p of listDaemonRunProcesses()) {
231
+ const entry = entryFromTokens(p.tokens);
232
+ const version = entry ? resolveVersionNear(entry, p.pid) : null;
237
233
  // Asks whether the code is on disk for the NEXT launch -- what a restart and
238
234
  // every other reader will see -- not what this pid currently has mapped.
239
235
  const entryMissing = entry ? entryAbsent(entry) : false;
240
- found.push({ pid, entry, version, entryMissing, uid });
236
+ found.push({ pid: p.pid, entry, version, entryMissing, uid: p.uid });
241
237
  }
242
238
  return found;
243
239
  }
@@ -905,6 +901,15 @@ export function registerDaemonCommand(program) {
905
901
  'agents daemon start' still starts it explicitly, same as
906
902
  'systemctl start' on a disabled unit.
907
903
 
904
+ Under a redirected HOME (a test/e2e harness), 'agents daemon start'
905
+ REFUSES to launch: a daemon started there keeps its own pid file under
906
+ the temp home and leaks, invisible to the real install (W4, PHNX-3736).
907
+ 'daemon restart' inherits the same guard — it stops first, so run it
908
+ with the same opt-in. A deliberate test launch sets
909
+ AGENTS_ALLOW_TEST_DAEMON=1 — and the harness owns stopping what it
910
+ started. 'agents doctor' flags any leaked daemon that already exists,
911
+ with its HOME and start time.
912
+
908
913
  'agents daemon services enable|disable|restart <id>' applies live (no
909
914
  daemon restart) for supervisor-managed services that were registered at
910
915
  boot. browser-ipc is also registered while disabled, specifically so a
@@ -924,7 +929,7 @@ export function registerDaemonCommand(program) {
924
929
  cmd.command('start')
925
930
  .description('Start the daemon. Bypasses daemon.enabled — this is the deliberate override.')
926
931
  .action(() => {
927
- const result = startDaemon();
932
+ const result = startDaemonClean();
928
933
  if (result.method === 'already-running') {
929
934
  console.log(chalk.yellow(`Daemon already running (PID: ${result.pid})`));
930
935
  }
@@ -977,7 +982,7 @@ export function registerDaemonCommand(program) {
977
982
  for (const s of stop.surviving)
978
983
  console.log(chalk.red(` surviving: ${s}`));
979
984
  }
980
- const result = startDaemon();
985
+ const result = startDaemonClean();
981
986
  if (result.pid)
982
987
  console.log(chalk.green(`Daemon started (PID: ${result.pid}, ${result.method})`));
983
988
  else
@@ -16,6 +16,7 @@ import { hostIdentityArgs, sshTargetFor } from '../lib/hosts/types.js';
16
16
  import { deviceIdentityArgs } from '../lib/devices/connect.js';
17
17
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
18
18
  import { findAmbiguousDevicePins } from '../lib/scheduling/routines.js';
19
+ import { findLeakedDaemons } from '../lib/daemon/leaked-daemons.js';
19
20
  import chalk from 'chalk';
20
21
  import { checkAllClis, collectTeamsDoctorData } from '../lib/teams/agents.js';
21
22
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError, getAccountInfo } from '../lib/agents.js';
@@ -478,6 +479,10 @@ async function runDevicesDoctor(opts) {
478
479
  // remote boxes self-report it in their own `agents doctor --json`.
479
480
  ownerSink: await probeOwnerSink(readMeta()),
480
481
  binaryShadows: detectAgentsBinaryShadows(),
482
+ // A `__daemon-run` no unit main PID or daemon.pid owns (W4, PHNX-3736) —
483
+ // the /tmp/pin-e2e leak class the registry-scoped duplicate check cannot
484
+ // see. Local only: a remote box self-reports in its own doctor.
485
+ leakedDaemons: findLeakedDaemons(),
481
486
  }));
482
487
  accounts[localName] = r.inventory?.signIn ?? {};
483
488
  continue;
@@ -1478,6 +1483,8 @@ export function registerDoctorCommand(program) {
1478
1483
  // Can the owner-delivery lane (feed/notify) escalate a block from this
1479
1484
  // box? A factory that cannot escalate is not healthy (RUSH-2262).
1480
1485
  ownerSink: await probeOwnerSink(readMeta()),
1486
+ // A `__daemon-run` no unit main PID or daemon.pid owns (W4, PHNX-3736).
1487
+ leakedDaemons: findLeakedDaemons(),
1481
1488
  });
1482
1489
  if (opts.json) {
1483
1490
  const overviewPayload = {
@@ -986,6 +986,8 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
986
986
  throw new Error('--resume cannot be combined with --session-id, --loop, --fallback, --resume-checkpoint, or --lease.');
987
987
  }
988
988
  const { sessionsResumeAction } = await import('./sessions-resume.js');
989
+ const { toRemotePortable } = await import('../lib/project-root.js');
990
+ const device = options.host || options.device || options.on || options.computer;
989
991
  await sessionsResumeAction(undefined, prompt, {
990
992
  agent: normalizedAgentSpec.split('#')[0] === RUN_AUTO_KEYWORD ? undefined : normalizedAgentSpec.split('#')[0],
991
993
  account: options.account,
@@ -993,9 +995,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
993
995
  mode: command.getOptionValueSource('mode') === 'default' ? undefined : options.mode,
994
996
  interactive: options.interactive,
995
997
  headless: options.headless,
996
- cwd: options.cwd,
998
+ cwd: device ? options.remoteCwd ?? (options.cwd ? toRemotePortable(options.cwd) : undefined) : options.cwd,
997
999
  quiet: options.quiet,
998
- device: options.host || options.device || options.on || options.computer,
1000
+ device,
999
1001
  runArgs: rawArgs.slice(2),
1000
1002
  });
1001
1003
  return;
@@ -97,10 +97,12 @@ export async function runFork(sessionArg, options, deps = defaultDeps()) {
97
97
  return;
98
98
  }
99
99
  const digest = data?.preview ?? undefined;
100
- // Most sessions have no explicit --name label; fall back to the auto-derived
101
- // topic the rest of the CLI shows, not the raw short id (forkLabelFor is the
102
- // shared 3-tier resolver, and preview's --json now carries `topic`).
103
- const label = forkLabelFor({ label: source.label, topic: source.topic, shortId: source.shortId });
100
+ // Most sessions have no explicit --name label; fall back to the shared headline
101
+ // ladder the rest of the CLI shows, not the raw short id. Pass `source` whole
102
+ // rather than re-listing fields: an object literal that omits `generatedTitle`
103
+ // still type-checks (the key is optional) and would silently drop the
104
+ // daemon-generated title rung — the PHNX-3797 bug shape.
105
+ const label = forkLabelFor(source);
104
106
  const recap = buildForkRecap({
105
107
  agent: source.agent,
106
108
  label,
@@ -29,6 +29,7 @@ import { listTasks } from '../lib/hosts/tasks.js';
29
29
  import { itemPicker } from '../lib/picker.js';
30
30
  import { stats, getLogsPath, rotate, } from '../lib/feed/events.js';
31
31
  import { addEventsReadOptions, runEventsCommand } from './events.js';
32
+ import { sessionHeadline } from '../lib/session/title.js';
32
33
  /** Compact one-line label used by both the picker and the non-TTY list. */
33
34
  function candidateLabel(c) {
34
35
  if (c.kind === 'task') {
@@ -40,7 +41,7 @@ function candidateLabel(c) {
40
41
  }
41
42
  const s = c.session;
42
43
  const ver = s.version ? chalk.gray(`@${s.version}`) : '';
43
- const title = s.label || s.topic || '';
44
+ const title = sessionHeadline(s) || '';
44
45
  return `${chalk.gray('sess')} ${s.shortId.padEnd(9)} ${(s.agent + ver).padEnd(14)} ${chalk.gray(s.timestamp.slice(0, 16))} ${title.slice(0, 40)}`;
45
46
  }
46
47
  /** Emit a host-dispatch task's log as JSON: `{ kind, task, log }`. */
@@ -12,7 +12,7 @@ import { withAliases } from '../lib/verbs.js';
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as yaml from 'yaml';
15
- import { isDaemonRunning, signalDaemonReload, startDaemon, } from '../lib/daemon/daemon.js';
15
+ import { isDaemonRunning, signalDaemonReload, startDaemon, RedirectedHomeDaemonError, } from '../lib/daemon/daemon.js';
16
16
  import { findDuplicateMonitor, monitorFingerprint } from '../lib/monitors/fingerprint.js';
17
17
  import { gatherFleetMonitors, NO_MONITOR_FANOUT_ENV } from '../lib/monitors/remote.js';
18
18
  import { listMonitors, readMonitor, writeMonitor, deleteMonitor, setMonitorEnabled, getMonitorPath, validateMonitor, monitorRunsOnThisDevice, requiresSingleOwner, monitorSharedInputOwner, parseInterval, } from '../lib/monitors/config.js';
@@ -246,7 +246,20 @@ function ensureDaemonRunning() {
246
246
  stderrLine(chalk.gray('Daemon reloaded'));
247
247
  return true;
248
248
  }
249
- const result = startDaemon();
249
+ let result;
250
+ try {
251
+ result = startDaemon();
252
+ }
253
+ catch (err) {
254
+ // The redirected-HOME refusal (W4) is an auto-start policy, not a failure
255
+ // of the monitor just added — state it and leave the foreground command
256
+ // green. Anything else (a genuinely unspawnable binary) still fails loud.
257
+ if (err instanceof RedirectedHomeDaemonError) {
258
+ stderrLine(chalk.yellow(err.message));
259
+ return true;
260
+ }
261
+ throw err;
262
+ }
250
263
  if (result.pid) {
251
264
  console.log(chalk.green(`Daemon started (PID: ${result.pid}). It will watch monitors in the background.`));
252
265
  console.log(chalk.gray('Disable only monitor polling with: agents daemon services disable monitors'));
@@ -10,7 +10,7 @@ import ora from 'ora';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
- import { isDaemonRunning, signalDaemonReload, startDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, } from '../lib/daemon/daemon.js';
13
+ import { isDaemonRunning, signalDaemonReload, startDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, RedirectedHomeDaemonError, } from '../lib/daemon/daemon.js';
14
14
  import { isDaemonServiceEnabled, setDaemonServiceEnabled } from '../lib/daemon-services.js';
15
15
  import { assertSchedulerEnabled, assertDaemonEnabled, isDaemonEnabled, isSchedulerEnabled, } from '../lib/device-config.js';
16
16
  import { parseAgentVersionSpec, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
@@ -327,7 +327,22 @@ function ensureSchedulerRunning(opts = {}) {
327
327
  }
328
328
  return;
329
329
  }
330
- const result = startDaemon();
330
+ let result;
331
+ try {
332
+ result = startDaemon();
333
+ }
334
+ catch (err) {
335
+ // The redirected-HOME refusal (W4) is an auto-start policy, not a failure
336
+ // of the routine just added — state it and leave the foreground command
337
+ // green, the same tier split as the auto-start circuit breaker. Anything
338
+ // else (a genuinely unspawnable binary) still fails loud.
339
+ if (err instanceof RedirectedHomeDaemonError) {
340
+ if (!opts.quiet)
341
+ log(chalk.yellow(err.message));
342
+ return;
343
+ }
344
+ throw err;
345
+ }
331
346
  if (opts.quiet)
332
347
  return;
333
348
  if (result.pid) {
@@ -1851,9 +1866,22 @@ export function registerRoutinesCommands(program) {
1851
1866
  console.log(chalk.yellow(`Daemon is disabled (daemon.enabled=false) — run(s) below fire but are not monitored. Re-enable with: agents daemon enable`));
1852
1867
  }
1853
1868
  else {
1854
- const started = startDaemon();
1855
- if (started.pid) {
1856
- console.log(chalk.gray(`Started scheduler (PID: ${started.pid}) so webhook runs are monitored.`));
1869
+ try {
1870
+ const started = startDaemon();
1871
+ if (started.pid) {
1872
+ console.log(chalk.gray(`Started scheduler (PID: ${started.pid}) so webhook runs are monitored.`));
1873
+ }
1874
+ }
1875
+ catch (err) {
1876
+ // Same contract as the daemon.enabled=false branch above: a refused
1877
+ // auto-start (W4's redirected-HOME guard) never cancels the fire —
1878
+ // the runs below fire but are not monitored. Anything else rethrows.
1879
+ if (err instanceof RedirectedHomeDaemonError) {
1880
+ console.log(chalk.yellow(err.message));
1881
+ }
1882
+ else {
1883
+ throw err;
1884
+ }
1857
1885
  }
1858
1886
  }
1859
1887
  }
@@ -2185,7 +2213,19 @@ export function registerRoutinesCommands(program) {
2185
2213
  process.exit(1);
2186
2214
  }
2187
2215
  setDaemonServiceEnabled('scheduler', true);
2188
- const result = startDaemon();
2216
+ let result;
2217
+ try {
2218
+ result = startDaemon();
2219
+ }
2220
+ catch (err) {
2221
+ // The redirected-HOME refusal (W4) is user-actionable — print it
2222
+ // without a stack, same as the asserts above.
2223
+ if (err instanceof RedirectedHomeDaemonError) {
2224
+ console.error(chalk.red(err.message));
2225
+ process.exit(1);
2226
+ }
2227
+ throw err;
2228
+ }
2189
2229
  if (result.method === 'already-running') {
2190
2230
  // Signal a reload even here: if the daemon booted while this device had
2191
2231
  // scheduler.enabled=false, the reload re-evaluates the gate and boots
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import { type ToolIndexCoverage } from '../lib/session/tool-index.js';
3
+ import { type SessionTitleRunner } from '../lib/session/title.js';
3
4
  export interface ToolBackfillMachineResult {
4
5
  machine: string;
5
6
  indexedFiles: number;
@@ -61,5 +62,34 @@ export interface ResourceBackfillEnvelope {
61
62
  * resource_scan_ledger so reruns skip completed transcripts.
62
63
  */
63
64
  export declare function runResourceBackfill(options: ResourceBackfillOptions): Promise<ResourceBackfillEnvelope>;
65
+ interface TitleBackfillOptions {
66
+ session?: string;
67
+ limit?: string;
68
+ refresh?: boolean;
69
+ json?: boolean;
70
+ /** The injectable model call (`SessionTitleRunner`); not a CLI flag — tests pass it. */
71
+ run?: SessionTitleRunner;
72
+ }
73
+ export interface TitleBackfillEnvelope {
74
+ schemaVersion: 1;
75
+ kind: 'titles-backfill';
76
+ generatedAt: string;
77
+ machine: string;
78
+ scanned: number;
79
+ cached: number;
80
+ generated: number;
81
+ failed: number;
82
+ titles: Array<{
83
+ id: string;
84
+ title: string;
85
+ }>;
86
+ }
87
+ /**
88
+ * Generate session headlines NOW instead of waiting for the daemon's sweep — the
89
+ * explicit-refresh half of PHNX-3797. Same code path the `session-title` service
90
+ * ticks, so there is one generator: this only changes when it runs, how many it
91
+ * does, and (with `--refresh`) whether an already-current title is regenerated.
92
+ */
93
+ export declare function runTitlesBackfill(options: TitleBackfillOptions): Promise<TitleBackfillEnvelope>;
64
94
  export declare function registerSessionsBackfillCommand(sessionsCmd: Command): void;
65
95
  export {};