@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
@@ -90,15 +90,29 @@ export function formatEmptyAutoPoolError() {
90
90
  'mark one with `agents devices role <name> worker`, or widen the pool with `agents config set auto.pool all`.');
91
91
  }
92
92
  export function formatNoHealthyDeviceError(pool, signals, agent) {
93
+ let timedOutCount = 0;
93
94
  const excluded = pool.map((key) => {
94
95
  const signal = signals.get(key);
95
- const reason = signal?.reachable !== true
96
- ? 'unreachable'
97
- : signal.headroom === 'loaded'
96
+ let reason;
97
+ if (signal?.reachable === true) {
98
+ reason = signal.headroom === 'loaded'
98
99
  ? 'overloaded'
99
100
  : signal.installed !== true || signal.signedIn !== true
100
101
  ? 'no ready harness account'
101
102
  : 'ineligible';
103
+ }
104
+ else if (signal?.timedOut) {
105
+ // A slow link is not an offline box. Saying "unreachable" here sent
106
+ // operators hunting a fleet outage that did not exist (PHNX-3682).
107
+ timedOutCount++;
108
+ reason = 'probe timed out';
109
+ }
110
+ else if (signal === undefined) {
111
+ reason = 'no probe signal';
112
+ }
113
+ else {
114
+ reason = 'unreachable';
115
+ }
102
116
  return `${key} (${reason})`;
103
117
  }).join(', ');
104
118
  const target = agent ? `can run ${agent}` : "for 'run auto'";
@@ -108,7 +122,16 @@ export function formatNoHealthyDeviceError(pool, signals, agent) {
108
122
  // in this error's own candidate set, not just the ones with a doc.
109
123
  const marked = describeAutoPool({ roster: pool });
110
124
  const poolNote = marked ? ` [pool: ${marked}]` : '';
111
- return `agents: no healthy device ${target}${poolNote} — excluded: ${excluded}; earliest window resets unknown`;
125
+ // Only talk about usage windows when a device was actually turned away for
126
+ // one. A pool that timed out needs a link/latency hint, not a reset time.
127
+ const scope = timedOutCount === pool.length
128
+ ? `every probe (${pool.length}) exceeded`
129
+ : `${timedOutCount} of ${pool.length} probes exceeded`;
130
+ const hint = timedOutCount > 0
131
+ ? `; ${scope} the probe budget — those devices are likely up but slow to answer`
132
+ + ' (relayed Tailscale paths). Retry, or check `tailscale status` for a direct path.'
133
+ : '; earliest window resets unknown';
134
+ return `agents: no healthy device ${target}${poolNote} — excluded: ${excluded}${hint}`;
112
135
  }
113
136
  /**
114
137
  * Pick the least-loaded healthy device that can run `agent` when the harness is
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `lib/storage` — the surface-agnostic core the managed-storage surfaces share:
3
+ *
4
+ * - {@link selection} the ONE managed-vs-BYO selection policy.
5
+ * - {@link visibility} the ONE visibility model + product default (`me`).
6
+ *
7
+ * Consume the SELECTION + VISIBILITY POLICY from here; keep each surface's typed,
8
+ * `kind`-tagged backend adapter (endpoint, namespace, covers) in that surface's
9
+ * own module. `agents artifacts share` (`lib/share/`) and `agents traces`
10
+ * (`lib/traces/`) are the two current adapters; the `sessions` sync adapter is
11
+ * the next consumer.
12
+ */
13
+ export { type StorageBackendKind, type StorageSelectionOpts, selectStorageBackendKind, isManagedSelection, } from './selection.js';
14
+ export { type ShareVisibility, type VisibilityFlags, PUBLISH_VISIBILITY_LEVELS, EDITABLE_VISIBILITY_LEVELS, MANAGED_DEFAULT_VISIBILITY, BYO_DEFAULT_VISIBILITY, defaultVisibilityForBackend, explicitVisibility, resolveVisibility, publishVisibility, } from './visibility.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `lib/storage` — the surface-agnostic core the managed-storage surfaces share:
3
+ *
4
+ * - {@link selection} the ONE managed-vs-BYO selection policy.
5
+ * - {@link visibility} the ONE visibility model + product default (`me`).
6
+ *
7
+ * Consume the SELECTION + VISIBILITY POLICY from here; keep each surface's typed,
8
+ * `kind`-tagged backend adapter (endpoint, namespace, covers) in that surface's
9
+ * own module. `agents artifacts share` (`lib/share/`) and `agents traces`
10
+ * (`lib/traces/`) are the two current adapters; the `sessions` sync adapter is
11
+ * the next consumer.
12
+ */
13
+ export { selectStorageBackendKind, isManagedSelection, } from './selection.js';
14
+ export { PUBLISH_VISIBILITY_LEVELS, EDITABLE_VISIBILITY_LEVELS, MANAGED_DEFAULT_VISIBILITY, BYO_DEFAULT_VISIBILITY, defaultVisibilityForBackend, explicitVisibility, resolveVisibility, publishVisibility, } from './visibility.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The ONE managed-vs-BYO storage-backend selection policy.
3
+ *
4
+ * Every surface that persists to Phoenix-managed storage (`agents artifacts
5
+ * share` today, `agents sessions` sync next) makes the SAME choice: run on OUR
6
+ * managed infrastructure when the caller is signed in to Phoenix, and only fall
7
+ * back to a bring-your-own bucket when the caller explicitly asked for it. This
8
+ * module owns that decision so it is not re-derived — and re-drifted — per
9
+ * surface.
10
+ *
11
+ * What lives here is ONLY the identity + selection policy. It deliberately does
12
+ * NOT know a surface's endpoint, namespace shape, public-read semantics, or OG
13
+ * covers — those differ (share uses an email-handle namespace with public reads
14
+ * and covers; a traces/sessions adapter uses the userId and differs again). Each
15
+ * surface keeps its own DISCRIMINATED, typed adapter that reads this decision and
16
+ * returns its own `kind`-tagged backend. See `lib/share/backend.ts` and
17
+ * `lib/traces/backend.ts` for the two adapters.
18
+ */
19
+ import { type PhoenixSession } from '../identity/client.js';
20
+ /** The two legitimate principals any managed-capable surface can resolve to. */
21
+ export type StorageBackendKind = 'managed' | 'byo';
22
+ export interface StorageSelectionOpts {
23
+ /**
24
+ * True when the SURFACE detected an explicit bring-your-own override — a
25
+ * `--byo` flag, a caller-supplied static write token, a `…_BACKEND=byo` env,
26
+ * or a full BYO endpoint config. Detecting WHICH signals count is the
27
+ * surface's job (they differ per surface); this policy only honors the boolean.
28
+ */
29
+ byoOverride?: boolean;
30
+ /**
31
+ * DI seam for the Phoenix session. `undefined` reads the real persisted
32
+ * session (`readSession()`); `null` means "explicitly signed out".
33
+ */
34
+ session?: PhoenixSession | null;
35
+ }
36
+ /**
37
+ * Pick the storage principal. Managed when signed in (`readSession() != null`)
38
+ * AND the surface reported no explicit BYO override; otherwise BYO.
39
+ *
40
+ * This is not a fallback chain — it is a single decision. A surface that cannot
41
+ * authenticate EITHER principal (signed out AND no BYO config) still reads `byo`
42
+ * here and fails loud in its own adapter, where the actionable "run auth login
43
+ * or set up your bucket" message belongs.
44
+ */
45
+ export declare function selectStorageBackendKind(opts?: StorageSelectionOpts): StorageBackendKind;
46
+ /** True when the shared policy resolves to the managed principal. Thin sugar
47
+ * over {@link selectStorageBackendKind} for the common boolean check. */
48
+ export declare function isManagedSelection(opts?: StorageSelectionOpts): boolean;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The ONE managed-vs-BYO storage-backend selection policy.
3
+ *
4
+ * Every surface that persists to Phoenix-managed storage (`agents artifacts
5
+ * share` today, `agents sessions` sync next) makes the SAME choice: run on OUR
6
+ * managed infrastructure when the caller is signed in to Phoenix, and only fall
7
+ * back to a bring-your-own bucket when the caller explicitly asked for it. This
8
+ * module owns that decision so it is not re-derived — and re-drifted — per
9
+ * surface.
10
+ *
11
+ * What lives here is ONLY the identity + selection policy. It deliberately does
12
+ * NOT know a surface's endpoint, namespace shape, public-read semantics, or OG
13
+ * covers — those differ (share uses an email-handle namespace with public reads
14
+ * and covers; a traces/sessions adapter uses the userId and differs again). Each
15
+ * surface keeps its own DISCRIMINATED, typed adapter that reads this decision and
16
+ * returns its own `kind`-tagged backend. See `lib/share/backend.ts` and
17
+ * `lib/traces/backend.ts` for the two adapters.
18
+ */
19
+ import { readSession } from '../identity/client.js';
20
+ /**
21
+ * Pick the storage principal. Managed when signed in (`readSession() != null`)
22
+ * AND the surface reported no explicit BYO override; otherwise BYO.
23
+ *
24
+ * This is not a fallback chain — it is a single decision. A surface that cannot
25
+ * authenticate EITHER principal (signed out AND no BYO config) still reads `byo`
26
+ * here and fails loud in its own adapter, where the actionable "run auth login
27
+ * or set up your bucket" message belongs.
28
+ */
29
+ export function selectStorageBackendKind(opts = {}) {
30
+ if (opts.byoOverride === true)
31
+ return 'byo';
32
+ const session = opts.session === undefined ? readSession() : opts.session;
33
+ return session != null ? 'managed' : 'byo';
34
+ }
35
+ /** True when the shared policy resolves to the managed principal. Thin sugar
36
+ * over {@link selectStorageBackendKind} for the common boolean check. */
37
+ export function isManagedSelection(opts = {}) {
38
+ return selectStorageBackendKind(opts) === 'managed';
39
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The ONE visibility model, identical on every surface that publishes to managed
3
+ * storage. Three levels the operator ever chooses between:
4
+ *
5
+ * - PRIVATE = `me` — owner-only, Phoenix-gated. THE DEFAULT for a new
6
+ * managed share (see {@link MANAGED_DEFAULT_VISIBILITY}).
7
+ * - TEAM = `org` — everyone at the sharer's signed-in email DOMAIN,
8
+ * Phoenix-gated. Automatic from the domain — there is
9
+ * no organization to create and no member to add.
10
+ * - PUBLIC = `public` — explicit opt-in; listed in the gallery, gets an OG
11
+ * card.
12
+ *
13
+ * Two capability-URL levels remain for the power path (unguessable link, not a
14
+ * login gate): `unlisted` (obscurity — noindex, gallery-hidden, still
15
+ * world-readable) and `private`-token-gated (`--protected`: a `?k=` key the
16
+ * Worker checks, 404 without it). `me`/`org` require a Phoenix session; the
17
+ * Worker refuses them for a bare WRITE_TOKEN (BYO) publish, which is why the BYO
18
+ * default stays `public`.
19
+ *
20
+ * The names match the Worker's own metadata vocabulary (`lib/share/worker-template.ts`),
21
+ * so this module is the single client-side source of truth for the level set and
22
+ * the DEFAULT, reusable by any surface.
23
+ */
24
+ /** Every visibility level the Worker understands. `me`/`org` are the
25
+ * Phoenix-gated (login-required) levels; `public`/`unlisted`/`private` are the
26
+ * link-reachable levels. */
27
+ export type ShareVisibility = 'public' | 'unlisted' | 'private' | 'me' | 'org';
28
+ /** The levels a publish (`share <file> --visibility`) may select. */
29
+ export declare const PUBLISH_VISIBILITY_LEVELS: readonly ShareVisibility[];
30
+ /** The levels an ALREADY-published page may be re-scoped to in place
31
+ * (`share visibility <target> <level>`). Excludes `private`: re-scoping to
32
+ * token-gated needs a fresh viewer token, which only the publish path mints. */
33
+ export declare const EDITABLE_VISIBILITY_LEVELS: readonly ShareVisibility[];
34
+ /**
35
+ * The product default for a MANAGED (signed-in) share: PRIVATE = owner-only,
36
+ * Phoenix-gated. A publish with no visibility flag lands here.
37
+ */
38
+ export declare const MANAGED_DEFAULT_VISIBILITY: ShareVisibility;
39
+ /**
40
+ * The default for a BYO (bring-your-own bucket) publish. `me`/`org` are refused
41
+ * server-side for a WRITE_TOKEN publish — there is no Phoenix owner to gate on —
42
+ * so a BYO share with no flag stays `public`, matching the pre-managed behavior.
43
+ */
44
+ export declare const BYO_DEFAULT_VISIBILITY: ShareVisibility;
45
+ import type { StorageBackendKind } from './selection.js';
46
+ /** The no-flags default for a given storage backend: `me` on managed,
47
+ * `public` on BYO. */
48
+ export declare function defaultVisibilityForBackend(kind: StorageBackendKind): ShareVisibility;
49
+ export interface VisibilityFlags {
50
+ /** An explicit `--visibility <level>`. */
51
+ visibility?: ShareVisibility;
52
+ /** `--unlisted` / `--private` (obscurity, not read-auth). */
53
+ unlisted?: boolean;
54
+ /** `--protected` — token-gated read auth (maps to `private`). */
55
+ protected?: boolean;
56
+ }
57
+ /**
58
+ * The visibility the caller EXPLICITLY asked for, or `undefined` when they
59
+ * passed no visibility signal at all. `--protected` wins over `--unlisted`
60
+ * (stronger control), which wins over an explicit `--visibility`.
61
+ *
62
+ * The distinction between "explicit" and "no preference" is what lets a caller
63
+ * apply the product default only when nothing was asked — a caller that passes
64
+ * `--visibility public` gets `public`, never the `me` default.
65
+ */
66
+ export declare function explicitVisibility(opts?: VisibilityFlags): ShareVisibility | undefined;
67
+ /**
68
+ * Resolve visibility from the caller's flags, falling back to `fallback` when no
69
+ * explicit signal was given. The library fallback stays `public` so a caller
70
+ * that hasn't opted into the managed private default (e.g. an existing lib
71
+ * consumer) is never silently flipped; the surface applies the product default
72
+ * itself via {@link defaultVisibilityForBackend}.
73
+ */
74
+ export declare function resolveVisibility(opts?: VisibilityFlags, fallback?: ShareVisibility): ShareVisibility;
75
+ /**
76
+ * The visibility a PUBLISH should stamp: the caller's explicit flag if any, else
77
+ * the product default for the resolved backend (`me` on managed, `public` on
78
+ * BYO). This is the one call a publishing surface makes — `agents artifacts
79
+ * share` today, `agents sessions` sync next — so "private by default when signed
80
+ * in" is decided in exactly one place.
81
+ */
82
+ export declare function publishVisibility(opts: VisibilityFlags, kind: StorageBackendKind): ShareVisibility;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The ONE visibility model, identical on every surface that publishes to managed
3
+ * storage. Three levels the operator ever chooses between:
4
+ *
5
+ * - PRIVATE = `me` — owner-only, Phoenix-gated. THE DEFAULT for a new
6
+ * managed share (see {@link MANAGED_DEFAULT_VISIBILITY}).
7
+ * - TEAM = `org` — everyone at the sharer's signed-in email DOMAIN,
8
+ * Phoenix-gated. Automatic from the domain — there is
9
+ * no organization to create and no member to add.
10
+ * - PUBLIC = `public` — explicit opt-in; listed in the gallery, gets an OG
11
+ * card.
12
+ *
13
+ * Two capability-URL levels remain for the power path (unguessable link, not a
14
+ * login gate): `unlisted` (obscurity — noindex, gallery-hidden, still
15
+ * world-readable) and `private`-token-gated (`--protected`: a `?k=` key the
16
+ * Worker checks, 404 without it). `me`/`org` require a Phoenix session; the
17
+ * Worker refuses them for a bare WRITE_TOKEN (BYO) publish, which is why the BYO
18
+ * default stays `public`.
19
+ *
20
+ * The names match the Worker's own metadata vocabulary (`lib/share/worker-template.ts`),
21
+ * so this module is the single client-side source of truth for the level set and
22
+ * the DEFAULT, reusable by any surface.
23
+ */
24
+ /** The levels a publish (`share <file> --visibility`) may select. */
25
+ export const PUBLISH_VISIBILITY_LEVELS = [
26
+ 'public',
27
+ 'unlisted',
28
+ 'private',
29
+ 'me',
30
+ 'org',
31
+ ];
32
+ /** The levels an ALREADY-published page may be re-scoped to in place
33
+ * (`share visibility <target> <level>`). Excludes `private`: re-scoping to
34
+ * token-gated needs a fresh viewer token, which only the publish path mints. */
35
+ export const EDITABLE_VISIBILITY_LEVELS = [
36
+ 'public',
37
+ 'unlisted',
38
+ 'me',
39
+ 'org',
40
+ ];
41
+ /**
42
+ * The product default for a MANAGED (signed-in) share: PRIVATE = owner-only,
43
+ * Phoenix-gated. A publish with no visibility flag lands here.
44
+ */
45
+ export const MANAGED_DEFAULT_VISIBILITY = 'me';
46
+ /**
47
+ * The default for a BYO (bring-your-own bucket) publish. `me`/`org` are refused
48
+ * server-side for a WRITE_TOKEN publish — there is no Phoenix owner to gate on —
49
+ * so a BYO share with no flag stays `public`, matching the pre-managed behavior.
50
+ */
51
+ export const BYO_DEFAULT_VISIBILITY = 'public';
52
+ /** The no-flags default for a given storage backend: `me` on managed,
53
+ * `public` on BYO. */
54
+ export function defaultVisibilityForBackend(kind) {
55
+ return kind === 'managed' ? MANAGED_DEFAULT_VISIBILITY : BYO_DEFAULT_VISIBILITY;
56
+ }
57
+ /**
58
+ * The visibility the caller EXPLICITLY asked for, or `undefined` when they
59
+ * passed no visibility signal at all. `--protected` wins over `--unlisted`
60
+ * (stronger control), which wins over an explicit `--visibility`.
61
+ *
62
+ * The distinction between "explicit" and "no preference" is what lets a caller
63
+ * apply the product default only when nothing was asked — a caller that passes
64
+ * `--visibility public` gets `public`, never the `me` default.
65
+ */
66
+ export function explicitVisibility(opts = {}) {
67
+ if (opts.protected === true)
68
+ return 'private';
69
+ if (opts.unlisted === true)
70
+ return 'unlisted';
71
+ if (opts.visibility === 'public' ||
72
+ opts.visibility === 'unlisted' ||
73
+ opts.visibility === 'private' ||
74
+ opts.visibility === 'me' ||
75
+ opts.visibility === 'org') {
76
+ return opts.visibility;
77
+ }
78
+ return undefined;
79
+ }
80
+ /**
81
+ * Resolve visibility from the caller's flags, falling back to `fallback` when no
82
+ * explicit signal was given. The library fallback stays `public` so a caller
83
+ * that hasn't opted into the managed private default (e.g. an existing lib
84
+ * consumer) is never silently flipped; the surface applies the product default
85
+ * itself via {@link defaultVisibilityForBackend}.
86
+ */
87
+ export function resolveVisibility(opts = {}, fallback = 'public') {
88
+ return explicitVisibility(opts) ?? fallback;
89
+ }
90
+ /**
91
+ * The visibility a PUBLISH should stamp: the caller's explicit flag if any, else
92
+ * the product default for the resolved backend (`me` on managed, `public` on
93
+ * BYO). This is the one call a publishing surface makes — `agents artifacts
94
+ * share` today, `agents sessions` sync next — so "private by default when signed
95
+ * in" is decided in exactly one place.
96
+ */
97
+ export function publishVisibility(opts, kind) {
98
+ return explicitVisibility(opts) ?? defaultVisibilityForBackend(kind);
99
+ }
@@ -2361,7 +2361,9 @@ export class AgentManager {
2361
2361
  ? `at its agents.max-concurrent cap (${e.detail} running)`
2362
2362
  : e.reason === 'not-installed'
2363
2363
  ? `does not have ${this.placementAgentLabel(agent)} installed`
2364
- : e.reason;
2364
+ : e.reason === 'probe-timed-out'
2365
+ ? 'did not answer the probe in time (likely up but on a slow/relayed link)'
2366
+ : e.reason;
2365
2367
  console.error(chalk.dim(`[placement] '${e.device}' excluded from auto-pick — ${why}`));
2366
2368
  }
2367
2369
  }
@@ -137,6 +137,7 @@ export async function probePoolSignals(pool, agent, opts = {}) {
137
137
  continue; // fully unknown device — leave it out of the map
138
138
  signals.set(name, {
139
139
  reachable: s?.reachable,
140
+ timedOut: s?.timedOut,
140
141
  headroom: s ? headroom(s) : undefined,
141
142
  loadPercent: s?.loadPercent,
142
143
  memPercent: s?.memPercent,
@@ -10,6 +10,13 @@ import type { Headroom } from '../devices/health.js';
10
10
  export interface DevicePlacementSignal {
11
11
  /** SSH probe answered. `false` → excluded (unreachable). undefined → unknown. */
12
12
  reachable?: boolean;
13
+ /**
14
+ * The probe was killed for exceeding its budget rather than failing to
15
+ * connect. Ranking treats it exactly like `reachable:false` — an
16
+ * unresponsive box is still not placeable — but the operator-facing
17
+ * exclusion reason says "probe timed out", not "unreachable" (PHNX-3682).
18
+ */
19
+ timedOut?: boolean;
13
20
  /** Headroom bucket from load+memory. `'loaded'` → excluded (overloaded). */
14
21
  headroom?: Headroom;
15
22
  /** Normalized CPU load percent (finer rank tiebreak within a headroom tier). */
@@ -60,7 +67,7 @@ export interface PlacementOptions {
60
67
  preferred?: ReadonlySet<string>;
61
68
  }
62
69
  /** Why a device was excluded from the viable set, for the fail-loud message. */
63
- export type ExclusionReason = 'unreachable' | 'overloaded' | 'capped' | 'not-installed';
70
+ export type ExclusionReason = 'unreachable' | 'probe-timed-out' | 'overloaded' | 'capped' | 'not-installed';
64
71
  /** A pool device dropped from the auto-pick, with the reason + live detail. */
65
72
  export interface ExcludedDevice {
66
73
  device: string;
@@ -199,7 +199,10 @@ export function classifyExclusions(devices, roster, opts) {
199
199
  for (const d of devices) {
200
200
  const s = signals?.get(d);
201
201
  if (s?.reachable === false) {
202
- excluded.push({ device: d, reason: 'unreachable' });
202
+ // A probe killed for exceeding its budget is a slow link, not a box that
203
+ // is down. Same exclusion either way — an unresponsive device is still not
204
+ // placeable — but the operator sees which one it was (PHNX-3682).
205
+ excluded.push({ device: d, reason: s.timedOut ? 'probe-timed-out' : 'unreachable' });
203
206
  continue;
204
207
  }
205
208
  if (s?.installed === false) {
@@ -10,6 +10,7 @@
10
10
  * `agents artifacts setup`.
11
11
  */
12
12
  import { readSession } from '../identity/client.js';
13
+ import { selectStorageBackendKind } from '../storage/selection.js';
13
14
  export const DEFAULT_TRACES_DOMAIN = 'traces.agents-cli.sh';
14
15
  /**
15
16
  * Resolve the backend for the current machine. Throws when not signed in.
@@ -21,11 +22,21 @@ export const DEFAULT_TRACES_DOMAIN = 'traces.agents-cli.sh';
21
22
  export function resolveTracesBackend() {
22
23
  const envBase = (process.env['AGENTS_TRACES_BASE_URL'] ?? '').replace(/\/+$/, '').trim();
23
24
  const envToken = (process.env['AGENTS_TRACES_WRITE_TOKEN'] ?? '').trim();
24
- if (envBase && envToken) {
25
- return { baseUrl: envBase, token: envToken, userId: 'byo' };
25
+ // The traces surface's only BYO signal is the full BASE_URL + WRITE_TOKEN env
26
+ // pair; the managed-vs-BYO decision itself is the shared selection policy.
27
+ const byoOverride = Boolean(envBase && envToken);
28
+ if (selectStorageBackendKind({ byoOverride }) === 'byo') {
29
+ if (byoOverride) {
30
+ return { baseUrl: envBase, token: envToken, userId: 'byo' };
31
+ }
32
+ // Not signed in and no BYO env pair — the managed principal is the only one
33
+ // this surface exposes, so fail loud with the login hint.
34
+ throw new Error("Not signed in. Run 'agents auth login' to sync traces to your Phoenix account.");
26
35
  }
27
36
  const session = readSession();
28
37
  if (!session) {
38
+ // selectStorageBackendKind read the same session and returned 'managed', so a
39
+ // null here means it was cleared between the two reads — treat as signed out.
29
40
  throw new Error("Not signed in. Run 'agents auth login' to sync traces to your Phoenix account.");
30
41
  }
31
42
  if (!session.access_token) {
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The coarse bucket a held worktree falls into. These are the three the ticket
3
+ * names, and they demand very different follow-ups:
4
+ *
5
+ * - `unmerged-commits` — the one that matters. The branch carries commits with
6
+ * no patch-equivalent upstream: real work visible to nobody. Candidate for
7
+ * "push the branch or open a PR", never deletion.
8
+ * - `uncommitted-changes` — a dirty tree. Could be live work, could be build
9
+ * output nobody will miss. Needs a human eye, not an automatic action.
10
+ * - `undeterminable` — a broken or locked checkout whose merge state or status
11
+ * could not be read. Fails closed by design; must be re-examined, never swept.
12
+ */
13
+ export type HeldBucket = 'unmerged-commits' | 'uncommitted-changes' | 'undeterminable';
14
+ /**
15
+ * The specific reason inside a bucket. `undeterminable` splits into the two the
16
+ * sweep distinguishes so an operator can tell a locked index (`status-unreadable`)
17
+ * from a repo with no resolvable default ref (`merge-state-unknown`).
18
+ */
19
+ export type HeldReason = 'unmerged-commits' | 'uncommitted-changes' | 'status-unreadable' | 'merge-state-unknown';
20
+ /** One classified held worktree — the structured record the sweep threw away. */
21
+ export interface HeldWorktree {
22
+ /** Repo root that owns this worktree (the dir holding `.agents/worktrees`). */
23
+ repo: string;
24
+ /** basename(repo), for compact tables. */
25
+ repoName: string;
26
+ /** Worktree slug (the dir under `.agents/worktrees`). */
27
+ name: string;
28
+ /** Absolute path to the worktree. */
29
+ path: string;
30
+ /** Checked-out branch, or null for a detached HEAD. */
31
+ branch: string | null;
32
+ bucket: HeldBucket;
33
+ reason: HeldReason;
34
+ /**
35
+ * Commits on this worktree with no patch-equivalent upstream (`git cherry`).
36
+ * -1 means the answer could not be established.
37
+ */
38
+ unmergedCommits: number;
39
+ /** `git status --porcelain` line count; -1 means it could not be read. */
40
+ dirtyFiles: number;
41
+ /** True when `<branch>` already exists on `origin` (so the work is visible). */
42
+ hasRemoteBranch: boolean;
43
+ /** Whole days since the worktree dir was last modified. */
44
+ ageDays: number;
45
+ /** Disk footprint of the worktree dir in bytes, or -1 if it could not be read. */
46
+ sizeBytes: number;
47
+ }
48
+ /**
49
+ * Resolve the default branch ref to compare against: `origin/HEAD` when set,
50
+ * else whichever of `origin/main` / `origin/master` exists. Returns null when
51
+ * none resolve, which makes every worktree `merge-state-unknown` rather than
52
+ * silently comparing against nothing.
53
+ */
54
+ export declare function resolveDefaultRef(repoRoot: string): Promise<string | null>;
55
+ /**
56
+ * Count commits with no patch-equivalent upstream. Returns -1 when the answer
57
+ * cannot be established, which the classifier treats as `merge-state-unknown`.
58
+ *
59
+ * Patch-id (`git cherry`), NOT ancestry, is load-bearing: this fleet
60
+ * rebase-merges, so a landed branch's SHAs are rewritten and
61
+ * `merge-base --is-ancestor` reports *not merged* for work that fully landed.
62
+ * It also subsumes the unpushed check — an unpushed commit has no upstream
63
+ * equivalent, so it surfaces as unmerged rather than needing a separate probe.
64
+ */
65
+ export declare function countUnmergedCommits(worktreePath: string, defaultRef: string | null): Promise<number>;
66
+ /** One `git worktree list --porcelain` record. */
67
+ interface PorcelainEntry {
68
+ path: string;
69
+ branch: string | null;
70
+ detached: boolean;
71
+ }
72
+ export declare function parseWorktreePorcelain(out: string): PorcelainEntry[];
73
+ /**
74
+ * True when `child` is `parent` or sits beneath it, compared on path
75
+ * boundaries. A raw `startsWith` makes `.../fix-1103` look like it is inside
76
+ * `.../fix-110`, so the primary checkout would be mistaken for a neighbour.
77
+ */
78
+ export declare function isInside(child: string, parent: string): boolean;
79
+ /** Everything the bucket decision depends on, gathered once. */
80
+ export interface HeldFacts {
81
+ /** null for detached HEAD. */
82
+ branch: string | null;
83
+ /** `git status --porcelain` line count; -1 = could not read. */
84
+ dirtyFiles: number;
85
+ /** `git cherry` count; -1 = could not determine. */
86
+ unmergedCommits: number;
87
+ }
88
+ /**
89
+ * Decide which bucket a worktree falls into, or null when it is neither stranded
90
+ * nor dirty nor broken (clean and fully upstream — nothing to surface). Pure.
91
+ *
92
+ * Precedence is chosen for SURFACING, not for the sweep's deletion decision:
93
+ * the work-loss signal wins. A determinable `unmerged-commits` is reported as
94
+ * exactly that even when the tree is also dirty, because the recoverable work
95
+ * is the priority — an operator seeing `uncommitted-changes` would push nothing.
96
+ * Only when the merge state itself is unreadable do we fall to `undeterminable`,
97
+ * because then we genuinely cannot tell whether work is stranded.
98
+ */
99
+ export declare function classifyHeld(facts: HeldFacts): {
100
+ bucket: HeldBucket;
101
+ reason: HeldReason;
102
+ } | null;
103
+ /**
104
+ * Classify every worktree registered under one repo and return only the held
105
+ * ones. Read-only — no `git worktree remove`, no `branch -d`, no push. The
106
+ * primary checkout (the first porcelain record, always the repo root) is never
107
+ * a `.agents/worktrees` slug, so it is skipped implicitly.
108
+ */
109
+ export declare function collectHeldWorktrees(repoRoot: string): Promise<HeldWorktree[]>;
110
+ /**
111
+ * Discover repo roots that own a `.agents/worktrees` container beneath
112
+ * `searchHome`. Mirrors the sweep's discovery: match directory SHAPE, prune the
113
+ * heavy dirs so a large home stays fast. Read-only.
114
+ */
115
+ export declare function discoverWorktreeRepos(searchHome: string, maxDepth?: number): Promise<string[]>;
116
+ /** Discover + classify every held worktree beneath `searchHome`. Read-only. */
117
+ export declare function collectHeldWorktreesUnder(searchHome: string): Promise<HeldWorktree[]>;
118
+ /** A held set grouped by bucket, with the itemised entries kept. */
119
+ export interface HeldSummary {
120
+ total: number;
121
+ buckets: Record<HeldBucket, HeldWorktree[]>;
122
+ }
123
+ /** Group a flat held list into its three buckets. Pure. */
124
+ export declare function summarizeHeld(held: HeldWorktree[]): HeldSummary;
125
+ /** One device's contribution to a fleet-wide roll-up. */
126
+ export interface DeviceHeld {
127
+ device: string;
128
+ held: HeldWorktree[];
129
+ }
130
+ /** A fleet-wide roll-up: every device's held worktrees, still bucketed. Pure. */
131
+ export interface FleetHeldSummary extends HeldSummary {
132
+ devices: {
133
+ device: string;
134
+ total: number;
135
+ }[];
136
+ }
137
+ /**
138
+ * Merge several devices' held sets into one fleet roll-up, stamping each entry
139
+ * with its source device so the `unmerged-commits` bucket names where the
140
+ * stranded work lives. Pure — the SSH fan-out that produces the input lives in
141
+ * the command layer.
142
+ */
143
+ export declare function aggregateHeld(perDevice: DeviceHeld[]): FleetHeldSummary;
144
+ /** Outcome of the safe recovery action on one stranded worktree. */
145
+ export interface PushResult {
146
+ name: string;
147
+ branch: string | null;
148
+ pushed: boolean;
149
+ reason: string;
150
+ }
151
+ /**
152
+ * The safe automatic action for the `unmerged-commits` bucket (ticket step 3):
153
+ * PUBLISH the branch so the work becomes visible, never remove anything.
154
+ *
155
+ * Gated hard, fails closed:
156
+ * - only a worktree whose live classification is still `unmerged-commits`;
157
+ * - only when the branch does NOT already exist on `origin` — a branch that is
158
+ * already pushed (including one behind an open PR) needs nothing, and this
159
+ * also means we never force-update a remote ref;
160
+ * - a slug-shaped worktree name only, never shelled otherwise.
161
+ *
162
+ * `git push` sets no `--force`: it fast-forwards a new ref or fails loud. A
163
+ * failure is returned, never swallowed.
164
+ */
165
+ export declare function pushStrandedBranch(repoRoot: string, wt: HeldWorktree): Promise<PushResult>;
166
+ export {};