@phnx-labs/agents-cli 1.22.66 → 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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -10,8 +10,9 @@ 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, stopDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, } from '../lib/daemon/daemon.js';
14
- import { assertSchedulerEnabled, assertDaemonEnabled, isDaemonEnabled } from '../lib/device-config.js';
13
+ import { isDaemonRunning, signalDaemonReload, startDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, } from '../lib/daemon/daemon.js';
14
+ import { isDaemonServiceEnabled, setDaemonServiceEnabled } from '../lib/daemon-services.js';
15
+ import { assertSchedulerEnabled, assertDaemonEnabled, isDaemonEnabled, isSchedulerEnabled, } from '../lib/device-config.js';
15
16
  import { parseAgentVersionSpec, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
16
17
  import { isCustomHarnessName } from '../lib/profiles.js';
17
18
  import { RUN_STRATEGIES, normalizeRunStrategy } from '../lib/accounting/rotate.js';
@@ -313,10 +314,17 @@ function ensureSchedulerRunning(opts = {}) {
313
314
  console.error(chalk.yellow(err.message));
314
315
  return;
315
316
  }
317
+ const wasServiceEnabled = isDaemonServiceEnabled('scheduler');
318
+ if (!wasServiceEnabled)
319
+ setDaemonServiceEnabled('scheduler', true);
316
320
  if (isDaemonRunning()) {
317
- signalDaemonReload();
318
- if (!opts.quiet)
319
- log(chalk.gray('Scheduler reloaded'));
321
+ const reloaded = signalDaemonReload();
322
+ if (!reloaded) {
323
+ console.error(chalk.yellow('Scheduler service enabled, but live reload is unavailable. Restart deliberately with: agents daemon restart'));
324
+ }
325
+ else if (!opts.quiet) {
326
+ log(chalk.gray(wasServiceEnabled ? 'Scheduler reloaded' : 'Scheduler service enabled and reloaded'));
327
+ }
320
328
  return;
321
329
  }
322
330
  const result = startDaemon();
@@ -2162,7 +2170,7 @@ export function registerRoutinesCommands(program) {
2162
2170
  // Scheduler lifecycle — usually auto-managed by `routines add`, exposed here for manual control.
2163
2171
  routinesCmd
2164
2172
  .command('start')
2165
- .description('Start the background scheduler. Usually unnecessary — it auto-starts when you add your first routine.')
2173
+ .description('Enable and reload the scheduler service. Usually unnecessary — it auto-starts when you add your first routine.')
2166
2174
  .action(() => {
2167
2175
  try {
2168
2176
  // A manual start on a disabled device refuses with the same message the
@@ -2176,13 +2184,16 @@ export function registerRoutinesCommands(program) {
2176
2184
  console.error(chalk.red(err.message));
2177
2185
  process.exit(1);
2178
2186
  }
2187
+ setDaemonServiceEnabled('scheduler', true);
2179
2188
  const result = startDaemon();
2180
2189
  if (result.method === 'already-running') {
2181
2190
  // Signal a reload even here: if the daemon booted while this device had
2182
2191
  // scheduler.enabled=false, the reload re-evaluates the gate and boots
2183
2192
  // the scheduler — a manual start heals a scheduler-less daemon.
2184
- signalDaemonReload();
2185
- console.log(chalk.yellow(`Scheduler already running (PID: ${result.pid}) — reloaded`));
2193
+ const reloaded = signalDaemonReload();
2194
+ console.log(reloaded
2195
+ ? chalk.yellow(`Scheduler service enabled (shared daemon PID: ${result.pid}) — reloaded`)
2196
+ : chalk.yellow(`Scheduler service enabled, but live reload is unavailable. Restart deliberately with: agents daemon restart`));
2186
2197
  }
2187
2198
  else if (result.pid) {
2188
2199
  console.log(chalk.green(`Scheduler started (PID: ${result.pid})`));
@@ -2193,25 +2204,31 @@ export function registerRoutinesCommands(program) {
2193
2204
  });
2194
2205
  routinesCmd
2195
2206
  .command('stop')
2196
- .description('Stop the background scheduler. Routines will not fire until you start it again.')
2207
+ .description('Stop only the scheduler service. The shared daemon and its browser, secrets, usage, and monitoring services stay running.')
2197
2208
  .action(() => {
2198
- if (!isDaemonRunning()) {
2199
- console.log(chalk.yellow('Scheduler is not running'));
2209
+ const daemonRunning = isDaemonRunning();
2210
+ setDaemonServiceEnabled('scheduler', false);
2211
+ if (daemonRunning && !signalDaemonReload()) {
2212
+ console.log(chalk.yellow('Scheduler service disabled, but live reload is unavailable. It will stay off after the next daemon start.'));
2200
2213
  return;
2201
2214
  }
2202
- stopDaemon();
2203
- console.log(chalk.green('Scheduler stopped'));
2215
+ console.log(daemonRunning
2216
+ ? chalk.green('Scheduler service stopped; shared daemon is still running')
2217
+ : chalk.yellow('Scheduler service disabled; shared daemon was already stopped'));
2204
2218
  });
2205
2219
  routinesCmd
2206
2220
  .command('status')
2207
- .description('Show scheduler status, enabled routines, and when each one fires next.')
2208
- .option('--json', 'Emit machine-readable scheduler + per-routine status (owner device, last fire, last error, in-flight run)')
2221
+ .description('Show scheduler service state, shared-daemon state, enabled routines, and upcoming runs.')
2222
+ .option('--json', 'Emit machine-readable scheduler service + device gate + daemon + per-routine status (owner device, last fire, last error, in-flight run)')
2209
2223
  .action((options) => {
2210
2224
  try {
2211
2225
  monitorRunningJobs();
2212
2226
  }
2213
2227
  catch { /* best-effort orphan reap */ }
2214
2228
  const status = getDaemonStatus();
2229
+ const schedulerServiceEnabled = isDaemonServiceEnabled('scheduler');
2230
+ const schedulerDeviceEnabled = isSchedulerEnabled();
2231
+ const schedulerState = schedulerServiceEnabled && schedulerDeviceEnabled ? status.state : 'stopped';
2215
2232
  // The daemon-owned status surface (PHNX-3215): scheduler truth plus, per
2216
2233
  // routine, its single owner device, last fire outcome + error, and any
2217
2234
  // in-flight spawn — the fields `list --json` (definition-shaped) does not
@@ -2221,7 +2238,10 @@ export function registerRoutinesCommands(program) {
2221
2238
  writeJson({
2222
2239
  device: machineId(),
2223
2240
  scheduler: {
2224
- state: status.state,
2241
+ state: schedulerState,
2242
+ serviceEnabled: schedulerServiceEnabled,
2243
+ deviceEnabled: schedulerDeviceEnabled,
2244
+ daemonState: status.state,
2225
2245
  pid: status.pid,
2226
2246
  binaryPath: status.binaryPath,
2227
2247
  heartbeat: status.heartbeat
@@ -2238,12 +2258,13 @@ export function registerRoutinesCommands(program) {
2238
2258
  return;
2239
2259
  }
2240
2260
  console.log(chalk.bold('Scheduler\n'));
2241
- const stateLabel = status.state === 'running'
2261
+ const stateLabel = schedulerState === 'running'
2242
2262
  ? chalk.green('running')
2243
- : status.state === 'wedged'
2263
+ : schedulerState === 'wedged'
2244
2264
  ? chalk.red('wedged')
2245
2265
  : chalk.gray('stopped');
2246
2266
  console.log(` Status: ${stateLabel}`);
2267
+ console.log(` Daemon: ${status.running ? chalk.green('running') : chalk.gray('stopped')}`);
2247
2268
  if (status.pid)
2248
2269
  console.log(` PID: ${status.pid}`);
2249
2270
  if (status.binaryPath)
@@ -2256,9 +2277,9 @@ export function registerRoutinesCommands(program) {
2256
2277
  const enabled = jobs.filter((j) => j.enabled);
2257
2278
  console.log(` Routines: ${enabled.length} enabled / ${jobs.length} total`);
2258
2279
  if (status.state === 'wedged') {
2259
- console.log(chalk.red('\n The daemon is wedged (heartbeat stale). Restart with: agents routines stop && agents routines start'));
2280
+ console.log(chalk.red('\n The shared daemon is wedged (heartbeat stale). Restart deliberately with: agents daemon restart'));
2260
2281
  }
2261
- if (status.running && enabled.length > 0) {
2282
+ if (schedulerState === 'running' && enabled.length > 0) {
2262
2283
  const scheduler = new JobScheduler(async () => { });
2263
2284
  scheduler.loadAll();
2264
2285
  const scheduled = scheduler.listScheduled();
@@ -2269,7 +2290,7 @@ export function registerRoutinesCommands(program) {
2269
2290
  }
2270
2291
  scheduler.stopAll();
2271
2292
  }
2272
- else if (!status.running && jobs.length > 0) {
2293
+ else if (schedulerState !== 'running' && jobs.length > 0) {
2273
2294
  console.log(chalk.gray('\n Start the scheduler to begin firing routines: agents routines start'));
2274
2295
  }
2275
2296
  });
@@ -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 {};
@@ -13,14 +13,121 @@
13
13
  * `--pane`/`--pty` target a backend directly when the handle is already known.
14
14
  */
15
15
  import chalk from 'chalk';
16
- import { getActiveSessions } from '../lib/session/active.js';
16
+ import { getActiveSessions, shortIdFromName } from '../lib/session/active.js';
17
17
  import { injectIntoTerminal, resolveInjectTargetForSession } from '../lib/terminal/index.js';
18
+ import { sshExec, shellQuote } from '../lib/ssh-exec.js';
19
+ import { resolveHost } from '../lib/hosts/registry.js';
20
+ import { sshTargetFor } from '../lib/hosts/types.js';
18
21
  import { setHelpSections } from '../lib/help.js';
22
+ /**
23
+ * Whether an active session is the one `sessions inject <token>` means. Matches
24
+ * a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
25
+ * whose full id never resolved (`sessionId` absent) — the `ag-<agent>-<shortid>`
26
+ * tmux name's `shortid` suffix (exact or prefix), the full tmux name, and the
27
+ * pane id. Those are the only selectors an id-less remote tmux row exposes, so
28
+ * without this an operator has no tool-native way to nudge it (PHNX-3688).
29
+ */
30
+ export function matchInjectSelector(session, token) {
31
+ if (!token)
32
+ return false;
33
+ const sid = session.sessionId;
34
+ if (sid && (sid === token || sid.startsWith(token)))
35
+ return true;
36
+ const short = session.tmuxName ? shortIdFromName(session.tmuxName) : undefined;
37
+ if (short && (short === token || short.startsWith(token)))
38
+ return true;
39
+ if (session.tmuxName && session.tmuxName === token)
40
+ return true;
41
+ if (session.paneId && session.paneId === token)
42
+ return true;
43
+ return false;
44
+ }
45
+ /**
46
+ * The `--device` selector, normalized to a single host string. `optsWithGlobals()`
47
+ * merges the parent `sessions` command's variadic `-D, --device <target...>` over
48
+ * this subcommand's scalar `--device`, so a single `--device box` arrives as
49
+ * `['box']` — which flowed straight into `sshExec` and crashed on
50
+ * `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
51
+ * several, since inject delivers to exactly one terminal (a fan-out spelling is a
52
+ * user error, not a first-of-list guess).
53
+ */
54
+ export function normalizeInjectDevice(value) {
55
+ const list = value == null ? [] : Array.isArray(value) ? value : [value];
56
+ const hosts = list.map((v) => String(v).trim()).filter((v) => v.length > 0);
57
+ if (hosts.length === 0)
58
+ return undefined;
59
+ if (hosts.length > 1) {
60
+ throw new Error(`sessions inject targets a single device, but --device named ${hosts.length}: ${hosts.join(', ')}.`);
61
+ }
62
+ return hosts[0];
63
+ }
64
+ /**
65
+ * The `agents sessions inject` argv to re-run ON a device (its tmux panes live
66
+ * there, so resolution must happen there). Every flag rides along EXCEPT
67
+ * `--device`: the command runs on the device, resolving locally. Pure so the
68
+ * forwarded invocation is asserted without an SSH hop (PHNX-3688).
69
+ */
70
+ export function buildRemoteInjectArgv(sessionId, text, options) {
71
+ const argv = ['agents', 'sessions', 'inject', sessionId, text];
72
+ if (options.enter === false)
73
+ argv.push('--no-enter');
74
+ if (options.combined)
75
+ argv.push('--combined');
76
+ if (options.socket)
77
+ argv.push('--socket', options.socket);
78
+ if (options.pty)
79
+ argv.push('--pty', options.pty);
80
+ if (options.pane)
81
+ argv.push('--pane', options.pane);
82
+ if (options.json)
83
+ argv.push('--json');
84
+ return argv;
85
+ }
86
+ /**
87
+ * Resolve `device` (registry alias or `user@host`) to an ssh target and re-run
88
+ * `agents sessions inject` there, so a bare session id + `--device` resolves on
89
+ * the box that actually holds the session's tmux panes. The tool-native form of
90
+ * the `agents ssh <device> "agents sessions inject <id> …"` workaround (PHNX-3688).
91
+ */
92
+ /**
93
+ * Resolve `--device` to an ssh target. A registered device becomes its
94
+ * `user@dnsName`; a bare unknown name (an ad-hoc `user@host` or ssh_config alias)
95
+ * is handed to ssh verbatim (`resolveHost` returns null for it). A registered
96
+ * device we CANNOT dial — password-auth, addressless — throws its typed error and
97
+ * is NOT degraded to the raw name, which could ssh a coincidentally-matching but
98
+ * unrelated `~/.ssh/config` Host (PHNX-3688 review).
99
+ */
100
+ export async function resolveInjectSshTarget(device) {
101
+ const host = await resolveHost(device);
102
+ return host ? sshTargetFor(host) : device;
103
+ }
104
+ async function injectOnDevice(sessionId, text, options, device) {
105
+ let target;
106
+ try {
107
+ target = await resolveInjectSshTarget(device);
108
+ }
109
+ catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ if (options.json)
112
+ console.log(JSON.stringify({ ok: false, error: message }));
113
+ else
114
+ console.error(chalk.red(message));
115
+ process.exit(1);
116
+ }
117
+ const remoteCmd = buildRemoteInjectArgv(sessionId, text, options).map(shellQuote).join(' ');
118
+ const res = sshExec(target, remoteCmd, { multiplex: true });
119
+ if (res.stdout)
120
+ process.stdout.write(res.stdout);
121
+ if (res.stderr)
122
+ process.stderr.write(res.stderr);
123
+ if (res.code !== 0)
124
+ process.exit(res.code ?? 1);
125
+ }
19
126
  /** Resolve a session id (short or full) to an addressable terminal target, via the
20
127
  * same resolver the watchdog uses so both agree on what is reachable. */
21
128
  async function resolveTarget(sessionId) {
22
129
  const sessions = await getActiveSessions();
23
- const match = sessions.find((s) => s.sessionId === sessionId || (s.sessionId != null && s.sessionId.startsWith(sessionId)));
130
+ const match = sessions.find((s) => matchInjectSelector(s, sessionId));
24
131
  if (!match)
25
132
  return { target: null, reason: `No active session matches "${sessionId}".` };
26
133
  const resolution = resolveInjectTargetForSession(match);
@@ -35,8 +142,21 @@ async function resolveTarget(sessionId) {
35
142
  return { target: resolution.target };
36
143
  }
37
144
  async function runInject(sessionId, text, options) {
145
+ let device;
146
+ try {
147
+ device = normalizeInjectDevice(options.device);
148
+ }
149
+ catch (err) {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ if (options.json)
152
+ console.log(JSON.stringify({ ok: false, error: message }));
153
+ else
154
+ console.error(chalk.red(message));
155
+ process.exit(1);
156
+ }
38
157
  // Direct-target shortcuts skip the session lookup — the watchdog often already
39
- // holds the pane id or pty session it wants to type into.
158
+ // holds the pane id or pty session it wants to type into. The pane's exact
159
+ // address is known, so it composes with --device (tmux send-keys over SSH).
40
160
  let target = null;
41
161
  if (options.pty) {
42
162
  target = { backend: 'pty', id: options.pty };
@@ -44,6 +164,12 @@ async function runInject(sessionId, text, options) {
44
164
  else if (options.pane) {
45
165
  target = { backend: 'tmux', pane: options.pane, socket: options.socket };
46
166
  }
167
+ else if (device) {
168
+ // A bare session id + a device: the session's tmux panes live ON that device,
169
+ // so getActiveSessions here can't see them. Resolve + deliver THERE by re-running
170
+ // inject over SSH (the same command, minus --device) — PHNX-3688.
171
+ return injectOnDevice(sessionId, text, options, device);
172
+ }
47
173
  else {
48
174
  const resolved = await resolveTarget(sessionId);
49
175
  if (!resolved.target) {
@@ -60,7 +186,7 @@ async function runInject(sessionId, text, options) {
60
186
  enter: options.enter !== false,
61
187
  combined: options.combined,
62
188
  socket: options.socket,
63
- host: options.device,
189
+ host: device,
64
190
  });
65
191
  if (options.json) {
66
192
  console.log(JSON.stringify(res));
@@ -82,7 +208,7 @@ export function registerSessionsInjectCommand(sessionsCmd) {
82
208
  .option('--pane <id>', 'Target a tmux pane id directly (e.g. %3), skipping session lookup')
83
209
  .option('--pty <id>', 'Target an agents-pty session id directly, skipping session lookup')
84
210
  .option('--socket <path>', 'tmux socket path (defaults to the session/shared socket)')
85
- .option('--device <target>', 'Deliver on a remote device over SSH (tmux/AppleScript backends)')
211
+ .option('--device <target>', 'Deliver on a remote device over SSH. With a bare session id, the session is resolved ON that device; with --pane, the pane is addressed there directly.')
86
212
  .option('--no-enter', 'Send only the text, without a trailing Enter')
87
213
  .option('--combined', 'Fuse text + Enter into ONE write (default: two writes, Ink-TUI safe)')
88
214
  .option('--json', 'Output the InjectResult as JSON');
@@ -96,6 +222,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
96
222
 
97
223
  # Type into an agents-pty session without submitting
98
224
  agents sessions inject _ "ls" --pty $SID --no-enter
225
+
226
+ # Nudge a live session on another box (resolved on the device)
227
+ agents sessions inject 214edaae "continue" --device yosemite-s0
228
+
229
+ # Address a known remote pane directly (skips lookup, sends over SSH)
230
+ agents sessions inject _ "continue" --pane %122 --socket $SOCK --device yosemite-s0
99
231
  `,
100
232
  notes: `
101
233
  - Ink-TUI Enter semantics: by default the text and Enter are two separate
@@ -103,8 +235,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
103
235
  - A session is addressable by id when it resolves to a precise split —
104
236
  tmux, iTerm, a VSCodium/Cursor/VS Code integrated terminal, or a pty
105
237
  (resolveInjectTargetForSession). Use --pane/--pty for direct targeting.
106
- - Built on the Terminal Engine (src/lib/terminal): --device runs the tmux /
107
- AppleScript spec over the same SSH transport the launch engine uses.
238
+ - The id may be the session id (short or full) OR the '<shortid>' suffix of
239
+ a tmux target (ag-<agent>-<shortid>) — the only selector a live tmux
240
+ session whose id column shows '-' exposes.
241
+ - Built on the Terminal Engine (src/lib/terminal): with --pane, --device
242
+ runs the tmux send-keys spec over SSH; with a bare id, --device re-runs
243
+ the lookup on that box (its tmux panes live there, not here).
108
244
  `,
109
245
  });
110
246
  // The parent `sessions` command also defines --json, so it binds there;
@@ -35,7 +35,7 @@ export function registerSessionsOptimizeCommand(sessionsCmd) {
35
35
  notes: `
36
36
  - FTS5 appends a segment on every insert and tombstones every delete; the scanner delete+inserts a session's docs on each rescan and never self-merges, so \`tool_call_text_data\` / \`session_text_data\` bloat with unmerged segments — GBs of index for tens of MB of content, and queries slow down.
37
37
  - This runs FTS5 \`'optimize'\`: it merges every segment into one and purges tombstones. Non-destructive — no searchable content is lost.
38
- - Reclaimed space becomes reusable free pages inside the DB file. To return it to the OS, stop the daemon (\`agents routines stop\`) and run \`VACUUM\` against \`~/.agents/.history/sessions/sessions.db\`.
38
+ - Reclaimed space becomes reusable free pages inside the DB file. To return it to the OS, use the operator lifecycle command \`agents daemon stop\`, run \`VACUUM\` against \`~/.agents/.history/sessions/sessions.db\`, then \`agents daemon start\`. \`agents routines stop\` disables only the scheduler service.
39
39
  `,
40
40
  });
41
41
  }
@@ -104,6 +104,7 @@ function sanitizeMeta(s) {
104
104
  version: clean(s.version),
105
105
  account: clean(s.account),
106
106
  topic: clean(s.topic),
107
+ firstUserMessage: clean(s.firstUserMessage),
107
108
  label: clean(s.label),
108
109
  ticketId: clean(s.ticketId),
109
110
  prUrl: clean(s.prUrl),
@@ -53,7 +53,7 @@ import { itemPicker } from '../lib/picker.js';
53
53
  import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
54
54
  import { listInstalledVersions, resolveVersionAliasLoose } from '../lib/installations/versions.js';
55
55
  import { getAgentsInvocation } from '../lib/daemon/daemon.js';
56
- import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
56
+ import { sessionAgentSupportsResume, sessionRecoveryRunArgs } from '../lib/session/recovery.js';
57
57
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
58
58
  import { sessionPicker, buildPreview, loadSessionPreviewDigest, transcriptOnPeerOf, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
59
59
  import { setHelpSections } from '../lib/help.js';
@@ -4062,6 +4062,8 @@ function versionedAliasIfPresent(agent, version) {
4062
4062
  return null;
4063
4063
  }
4064
4064
  export function buildResumeCommand(session) {
4065
+ if (!sessionAgentSupportsResume(session.agent))
4066
+ return null;
4065
4067
  switch (session.agent) {
4066
4068
  // opencode sessions are shared across versions, so resume is deliberately NOT
4067
4069
  // version-pinned — it always goes through the plain launcher.
@@ -4079,16 +4081,7 @@ export function buildResumeCommand(session) {
4079
4081
  }
4080
4082
  return resumeArgv(session.agent, session.id, cli);
4081
4083
  }
4082
- case 'gemini':
4083
- case 'antigravity':
4084
- case 'openclaw':
4085
- case 'rush':
4086
- case 'hermes':
4087
- case 'grok':
4088
- case 'kimi':
4089
- case 'droid':
4090
- case 'cursor':
4091
- // Grok (and some others) sessions are captured artifacts, not resumable the same way.
4084
+ default:
4092
4085
  return null;
4093
4086
  }
4094
4087
  }
@@ -80,7 +80,9 @@ export declare function runShareEdit(target: string, opts: {
80
80
  removeMeta?: string[];
81
81
  /** Change the page's visibility in place (public | unlisted | me | org).
82
82
  * A metadata-only rewrite like label/meta — the body is untouched, so no
83
- * revision is created. me/org require a Phoenix session. */
83
+ * revision is created. me/org require a Phoenix session. `private` is
84
+ * rejected here — token-gating needs a fresh viewer key only the publish
85
+ * path mints (PHNX-3654). */
84
86
  visibility?: ShareVisibility;
85
87
  force?: boolean;
86
88
  fetchEdit?: typeof fetch;
@@ -116,7 +118,7 @@ export declare function runShareList(opts?: {
116
118
  /** Visibility filter. `public` (default) lists only public gallery pages;
117
119
  * `unlisted`/`me`/`org` include that owner's hidden pages; `all` includes
118
120
  * every active page. Hidden scopes send the owner's bearer and `scope=mine`. */
119
- scope?: 'public' | 'unlisted' | 'me' | 'org' | 'all';
121
+ scope?: 'public' | 'unlisted' | 'private' | 'me' | 'org' | 'all';
120
122
  }): Promise<ShareListResult>;
121
123
  /** Client-side filtering over an already-fetched listing (RUSH-2683) — the
122
124
  * Worker has no query surface for this, so it narrows the fetched set instead
@@ -127,7 +129,7 @@ export declare function applyShareListFilters(result: ShareListResult, filters:
127
129
  session?: string;
128
130
  label?: string;
129
131
  meta?: Record<string, string>;
130
- scope?: 'public' | 'unlisted' | 'me' | 'org' | 'all';
132
+ scope?: 'public' | 'unlisted' | 'private' | 'me' | 'org' | 'all';
131
133
  }): ShareListResult;
132
134
  export declare function formatShareList(result: ShareListResult, json?: boolean): string;
133
135
  /** One retained prior version of a slug, as reported by the Worker's