@phnx-labs/agents-cli 1.20.89 → 1.20.91

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 (114) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/feed.js +77 -4
  6. package/dist/commands/harness.d.ts +27 -0
  7. package/dist/commands/harness.js +120 -13
  8. package/dist/commands/hooks.js +22 -6
  9. package/dist/commands/perf.d.ts +14 -0
  10. package/dist/commands/perf.js +221 -0
  11. package/dist/commands/profiles.d.ts +3 -0
  12. package/dist/commands/profiles.js +1 -1
  13. package/dist/commands/routines.d.ts +19 -0
  14. package/dist/commands/routines.js +58 -30
  15. package/dist/commands/secrets.d.ts +52 -4
  16. package/dist/commands/secrets.js +234 -37
  17. package/dist/commands/send.d.ts +5 -1
  18. package/dist/commands/send.js +1 -1
  19. package/dist/commands/sessions-browser.d.ts +4 -0
  20. package/dist/commands/sessions-browser.js +51 -9
  21. package/dist/commands/sessions-favorite.d.ts +20 -0
  22. package/dist/commands/sessions-favorite.js +120 -0
  23. package/dist/commands/sessions-picker.js +70 -1
  24. package/dist/commands/sessions.d.ts +103 -20
  25. package/dist/commands/sessions.js +356 -62
  26. package/dist/commands/setup-secrets.d.ts +7 -0
  27. package/dist/commands/setup-secrets.js +12 -9
  28. package/dist/commands/versions.js +12 -4
  29. package/dist/commands/view.d.ts +14 -1
  30. package/dist/commands/view.js +103 -128
  31. package/dist/index.js +18 -3
  32. package/dist/lib/activity.d.ts +11 -1
  33. package/dist/lib/activity.js +1 -0
  34. package/dist/lib/agents.d.ts +4 -2
  35. package/dist/lib/agents.js +21 -6
  36. package/dist/lib/catchup.d.ts +105 -0
  37. package/dist/lib/catchup.js +160 -0
  38. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  39. package/dist/lib/channels/providers/desktop.js +132 -0
  40. package/dist/lib/channels/providers/index.js +2 -0
  41. package/dist/lib/daemon.js +74 -13
  42. package/dist/lib/events.d.ts +12 -0
  43. package/dist/lib/events.js +122 -9
  44. package/dist/lib/exec.js +10 -0
  45. package/dist/lib/feed-broadcast.d.ts +47 -0
  46. package/dist/lib/feed-broadcast.js +65 -1
  47. package/dist/lib/feed-post.d.ts +10 -0
  48. package/dist/lib/feed-post.js +1 -1
  49. package/dist/lib/feed.d.ts +47 -1
  50. package/dist/lib/feed.js +38 -0
  51. package/dist/lib/hooks/cache.d.ts +2 -0
  52. package/dist/lib/hooks/cache.js +24 -4
  53. package/dist/lib/hosts/dispatch.js +19 -1
  54. package/dist/lib/hq/floor.js +12 -0
  55. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  56. package/dist/lib/overdue.d.ts +14 -0
  57. package/dist/lib/overdue.js +37 -1
  58. package/dist/lib/perf/db.d.ts +25 -0
  59. package/dist/lib/perf/db.js +290 -0
  60. package/dist/lib/perf/spool.d.ts +18 -0
  61. package/dist/lib/perf/spool.js +79 -0
  62. package/dist/lib/perf/types.d.ts +45 -0
  63. package/dist/lib/perf/types.js +2 -0
  64. package/dist/lib/picker.d.ts +27 -2
  65. package/dist/lib/picker.js +71 -7
  66. package/dist/lib/profiles.d.ts +48 -0
  67. package/dist/lib/profiles.js +67 -0
  68. package/dist/lib/rotate.d.ts +24 -2
  69. package/dist/lib/rotate.js +63 -6
  70. package/dist/lib/routines-project.js +6 -0
  71. package/dist/lib/routines.d.ts +30 -1
  72. package/dist/lib/routines.js +11 -0
  73. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  74. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  75. package/dist/lib/secrets/list-filter.d.ts +94 -0
  76. package/dist/lib/secrets/list-filter.js +245 -0
  77. package/dist/lib/session/active.d.ts +109 -3
  78. package/dist/lib/session/active.js +269 -13
  79. package/dist/lib/session/db.d.ts +14 -0
  80. package/dist/lib/session/db.js +35 -0
  81. package/dist/lib/session/digest.d.ts +7 -0
  82. package/dist/lib/session/digest.js +29 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/favorites.d.ts +39 -0
  86. package/dist/lib/session/favorites.js +101 -0
  87. package/dist/lib/session/highlights.d.ts +82 -0
  88. package/dist/lib/session/highlights.js +251 -0
  89. package/dist/lib/session/host-link.d.ts +68 -0
  90. package/dist/lib/session/host-link.js +64 -0
  91. package/dist/lib/session/parse.js +23 -1
  92. package/dist/lib/session/presence.d.ts +85 -0
  93. package/dist/lib/session/presence.js +150 -0
  94. package/dist/lib/session/relative-time.d.ts +14 -0
  95. package/dist/lib/session/relative-time.js +36 -0
  96. package/dist/lib/session/remote-list.d.ts +10 -0
  97. package/dist/lib/session/remote-list.js +47 -9
  98. package/dist/lib/session/render.d.ts +7 -0
  99. package/dist/lib/session/render.js +87 -17
  100. package/dist/lib/session/types.d.ts +4 -1
  101. package/dist/lib/startup/command-registry.d.ts +1 -0
  102. package/dist/lib/startup/command-registry.js +2 -0
  103. package/dist/lib/state.d.ts +9 -0
  104. package/dist/lib/state.js +11 -0
  105. package/dist/lib/tmux/binary.d.ts +7 -0
  106. package/dist/lib/tmux/binary.js +11 -1
  107. package/dist/lib/types.d.ts +4 -3
  108. package/dist/lib/usage-backoff.d.ts +29 -0
  109. package/dist/lib/usage-backoff.js +165 -0
  110. package/dist/lib/usage.d.ts +112 -5
  111. package/dist/lib/usage.js +464 -46
  112. package/dist/lib/watchdog/runner.d.ts +13 -0
  113. package/dist/lib/watchdog/runner.js +16 -1
  114. package/package.json +3 -1
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Filters for `agents secrets list`.
3
+ *
4
+ * The listing had no filtering at all: `--host`/`--device` pick a machine and
5
+ * `--json` picks a format, but nothing selected over the bundles themselves. On
6
+ * a fleet with fifty-odd bundles the answerable questions — which ones read with
7
+ * no Touch ID at all, which hold a raw literal, which have already expired, what
8
+ * has not been touched in three months — meant piping the table through grep, or
9
+ * were simply unanswerable.
10
+ *
11
+ * Everything here is pure: parsing produces a `SecretsListFilter`, and
12
+ * `bundleMatchesFilter` is a predicate over a bundle plus the ambient facts it
13
+ * cannot derive itself (which bundles the broker currently holds, and the
14
+ * current time). That keeps the whole surface unit-testable without a keychain.
15
+ *
16
+ * Shape follows the `agents sessions` house style: comma-separated lists, an
17
+ * unknown value is a loud error naming the valid set (never a silent ignore),
18
+ * and every axis narrows independently so they AND-compose.
19
+ */
20
+ import { type SecretsBundle, type SecretsPolicy, type SecretsBackend, type SecretType } from './bundles.js';
21
+ /** Ref kinds a var's value can have, from `describeBundle`. */
22
+ export declare const REF_KINDS: readonly ["literal", "keychain", "env", "file", "exec"];
23
+ export type RefKind = typeof REF_KINDS[number];
24
+ /** Default window for `--expiring` with no argument — matches the EXPIRING column. */
25
+ export declare const DEFAULT_EXPIRING_DAYS = 30;
26
+ /** A parsed, validated filter. Every field is optional; absent ⇒ that axis does
27
+ * not narrow. All present axes must match (AND). */
28
+ export interface SecretsListFilter {
29
+ /** Case-insensitive substring over bundle name and description. */
30
+ query?: string;
31
+ policy?: SecretsPolicy[];
32
+ backend?: SecretsBackend[];
33
+ type?: SecretType[];
34
+ kind?: RefKind[];
35
+ /** true ⇒ only bundles the broker holds; false ⇒ only those it does not. */
36
+ held?: boolean;
37
+ /** Only bundles with at least one var whose `expires` is already past. */
38
+ expired?: boolean;
39
+ /** Only bundles with at least one var expiring within this many days. */
40
+ expiringDays?: number;
41
+ /** Only bundles whose `last_used` is older than this epoch-ms (or never used). */
42
+ unusedBefore?: number;
43
+ }
44
+ /** The raw option bag commander hands us. */
45
+ export interface SecretsListFilterOpts {
46
+ policy?: string;
47
+ backend?: string;
48
+ type?: string;
49
+ kind?: string;
50
+ held?: boolean;
51
+ notHeld?: boolean;
52
+ expired?: boolean;
53
+ expiring?: string | boolean;
54
+ unused?: string;
55
+ }
56
+ /**
57
+ * Validate one comma-separated enum list. An unknown value throws and names the
58
+ * whole valid set — a silent ignore would let `--policy hodl` quietly return
59
+ * every bundle, which reads as "nothing matches that" and is worse than an error.
60
+ * Values are lowercased, matching `parsePolicyOpt`'s handling of policy names.
61
+ */
62
+ export declare function parseEnumList<T extends string>(raw: string, flag: string, valid: readonly T[]): T[];
63
+ /** Build a validated filter from commander's option bag. Throws on bad input. */
64
+ export declare function parseListFilters(opts: SecretsListFilterOpts, query?: string): SecretsListFilter;
65
+ /** True when any axis is set — used to decide whether the empty state should
66
+ * explain itself rather than claim there are no bundles at all. */
67
+ export declare function filterIsActive(f: SecretsListFilter): boolean;
68
+ /** Expiry tallies for one bundle: how many vars are already past, and how many
69
+ * fall due within `withinDays`. */
70
+ export declare function bundleExpiry(b: SecretsBundle, now: number, withinDays?: number): {
71
+ expired: number;
72
+ soon: number;
73
+ };
74
+ /** Ambient facts a bundle can't answer about itself. */
75
+ export interface FilterContext {
76
+ /** Bundle name → hold expiry epoch-ms, from the broker. Empty off macOS. */
77
+ held: Map<string, number>;
78
+ now: number;
79
+ }
80
+ /** Does this bundle satisfy every set axis? Pure. */
81
+ export declare function bundleMatchesFilter(b: SecretsBundle, f: SecretsListFilter, ctx: FilterContext): boolean;
82
+ /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
83
+ export declare const SORT_FIELDS: readonly ["name", "used", "created", "updated", "expiry"];
84
+ export type SortField = typeof SORT_FIELDS[number];
85
+ export declare function parseSortField(raw: string | undefined): SortField;
86
+ /** Sort a copy. Time fields are most-recent-first (the useful direction for
87
+ * "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
88
+ * so the order is stable. */
89
+ export declare function sortBundles(bundles: SecretsBundle[], field: SortField): SecretsBundle[];
90
+ /** Human summary of the active filters, for the empty state. `sessions` only
91
+ * echoes --project/--all on a miss, which leaves you guessing which flag emptied
92
+ * the list; naming every active axis is the difference between "nothing matched"
93
+ * and knowing what to relax. */
94
+ export declare function describeFilter(f: SecretsListFilter): string;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Filters for `agents secrets list`.
3
+ *
4
+ * The listing had no filtering at all: `--host`/`--device` pick a machine and
5
+ * `--json` picks a format, but nothing selected over the bundles themselves. On
6
+ * a fleet with fifty-odd bundles the answerable questions — which ones read with
7
+ * no Touch ID at all, which hold a raw literal, which have already expired, what
8
+ * has not been touched in three months — meant piping the table through grep, or
9
+ * were simply unanswerable.
10
+ *
11
+ * Everything here is pure: parsing produces a `SecretsListFilter`, and
12
+ * `bundleMatchesFilter` is a predicate over a bundle plus the ambient facts it
13
+ * cannot derive itself (which bundles the broker currently holds, and the
14
+ * current time). That keeps the whole surface unit-testable without a keychain.
15
+ *
16
+ * Shape follows the `agents sessions` house style: comma-separated lists, an
17
+ * unknown value is a loud error naming the valid set (never a silent ignore),
18
+ * and every axis narrows independently so they AND-compose.
19
+ */
20
+ import { parseCommaSeparatedList } from '../../commands/utils.js';
21
+ // From the leaf module, NOT discover.js — the latter imports `../sqlite.js`, so
22
+ // reaching through it for one parser would pull node:sqlite into every `agents
23
+ // secrets` invocation and print Node's SQLite ExperimentalWarning on stderr.
24
+ import { parseTimeFilter } from '../session/relative-time.js';
25
+ import { describeBundle, bundlePolicy, SECRET_TYPES, } from './bundles.js';
26
+ /** Ref kinds a var's value can have, from `describeBundle`. */
27
+ export const REF_KINDS = ['literal', 'keychain', 'env', 'file', 'exec'];
28
+ const POLICIES = ['always', 'hold', 'never'];
29
+ const BACKENDS = ['keychain', 'file', 'vault'];
30
+ /** Default window for `--expiring` with no argument — matches the EXPIRING column. */
31
+ export const DEFAULT_EXPIRING_DAYS = 30;
32
+ /**
33
+ * Validate one comma-separated enum list. An unknown value throws and names the
34
+ * whole valid set — a silent ignore would let `--policy hodl` quietly return
35
+ * every bundle, which reads as "nothing matches that" and is worse than an error.
36
+ * Values are lowercased, matching `parsePolicyOpt`'s handling of policy names.
37
+ */
38
+ export function parseEnumList(raw, flag, valid) {
39
+ const parts = parseCommaSeparatedList(raw).map((s) => s.toLowerCase());
40
+ if (parts.length === 0) {
41
+ throw new Error(`${flag} requires at least one value. Valid values: ${valid.join(', ')}`);
42
+ }
43
+ for (const p of parts) {
44
+ if (!valid.includes(p)) {
45
+ throw new Error(`Invalid value "${p}" for ${flag}. Valid values: ${valid.join(', ')}`);
46
+ }
47
+ }
48
+ // De-dup so `--policy hold,hold` behaves like `--policy hold`.
49
+ return [...new Set(parts)];
50
+ }
51
+ /** Parse `--expiring` — a bare flag means the default window, a value means N days. */
52
+ function parseExpiringDays(raw) {
53
+ if (raw === undefined || raw === false)
54
+ return undefined;
55
+ if (raw === true || raw === '')
56
+ return DEFAULT_EXPIRING_DAYS;
57
+ const n = Number(raw);
58
+ // 0 is rejected rather than accepted as a no-op: the window is `0 <= d < N`,
59
+ // so `--expiring 0` can never match anything, not even a key expiring today.
60
+ // A flag that silently returns nothing is worse than one that says why.
61
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
62
+ throw new Error(`Invalid --expiring '${raw}'. Use a whole number of days >= 1, e.g. --expiring 7. ` +
63
+ 'For keys that have already lapsed, use --expired.');
64
+ }
65
+ return n;
66
+ }
67
+ /** Build a validated filter from commander's option bag. Throws on bad input. */
68
+ export function parseListFilters(opts, query) {
69
+ if (opts.held && opts.notHeld) {
70
+ throw new Error('--held and --not-held are mutually exclusive');
71
+ }
72
+ const filter = {};
73
+ const trimmedQuery = query?.trim();
74
+ if (trimmedQuery)
75
+ filter.query = trimmedQuery.toLowerCase();
76
+ if (opts.policy)
77
+ filter.policy = parseEnumList(opts.policy, '--policy', POLICIES);
78
+ if (opts.backend)
79
+ filter.backend = parseEnumList(opts.backend, '--backend', BACKENDS);
80
+ if (opts.type)
81
+ filter.type = parseEnumList(opts.type, '--type', SECRET_TYPES);
82
+ if (opts.kind)
83
+ filter.kind = parseEnumList(opts.kind, '--kind', REF_KINDS);
84
+ if (opts.held)
85
+ filter.held = true;
86
+ if (opts.notHeld)
87
+ filter.held = false;
88
+ if (opts.expired)
89
+ filter.expired = true;
90
+ const expiringDays = parseExpiringDays(opts.expiring);
91
+ if (expiringDays !== undefined)
92
+ filter.expiringDays = expiringDays;
93
+ if (opts.unused) {
94
+ // parseTimeFilter turns '90d' into "the epoch-ms 90 days ago"; a bundle is
95
+ // unused when its last_used predates that instant.
96
+ const cutoff = parseTimeFilter(opts.unused);
97
+ if (!cutoff) {
98
+ throw new Error(`Invalid --unused '${opts.unused}'. Use e.g. 30d, 4w, 3mo, or an ISO date.`);
99
+ }
100
+ filter.unusedBefore = cutoff;
101
+ }
102
+ return filter;
103
+ }
104
+ /** True when any axis is set — used to decide whether the empty state should
105
+ * explain itself rather than claim there are no bundles at all. */
106
+ export function filterIsActive(f) {
107
+ return Object.keys(f).length > 0;
108
+ }
109
+ /** Whole days from now until end-of-day UTC of an ISO date. Negative once past.
110
+ * Mirrors the `daysUntil` used by the human render so the filter and the column
111
+ * can never disagree about whether something has expired. */
112
+ function daysUntil(iso, now) {
113
+ const target = new Date(`${iso}T23:59:59Z`).getTime();
114
+ return Math.floor((target - now) / (24 * 60 * 60 * 1000));
115
+ }
116
+ /** Expiry tallies for one bundle: how many vars are already past, and how many
117
+ * fall due within `withinDays`. */
118
+ export function bundleExpiry(b, now, withinDays = DEFAULT_EXPIRING_DAYS) {
119
+ let expired = 0;
120
+ let soon = 0;
121
+ for (const m of Object.values(b.meta ?? {})) {
122
+ if (!m.expires)
123
+ continue;
124
+ const d = daysUntil(m.expires, now);
125
+ if (d < 0)
126
+ expired++;
127
+ else if (d < withinDays)
128
+ soon++;
129
+ }
130
+ return { expired, soon };
131
+ }
132
+ /** Does this bundle satisfy every set axis? Pure. */
133
+ export function bundleMatchesFilter(b, f, ctx) {
134
+ if (f.query) {
135
+ const haystack = `${b.name} ${b.description ?? ''}`.toLowerCase();
136
+ if (!haystack.includes(f.query))
137
+ return false;
138
+ }
139
+ if (f.policy && !f.policy.includes(bundlePolicy(b)))
140
+ return false;
141
+ if (f.backend && !f.backend.includes(b.backend ?? 'keychain'))
142
+ return false;
143
+ if (f.held !== undefined) {
144
+ // A lapsed entry is not held — same liveness rule the POLICY column uses.
145
+ const exp = ctx.held.get(b.name);
146
+ const isHeld = exp !== undefined && exp > ctx.now;
147
+ if (isHeld !== f.held)
148
+ return false;
149
+ }
150
+ if (f.type) {
151
+ const types = Object.values(b.meta ?? {}).map((m) => m.type).filter(Boolean);
152
+ if (!types.some((t) => f.type.includes(t)))
153
+ return false;
154
+ }
155
+ if (f.kind) {
156
+ const kinds = describeBundle(b).map((e) => e.kind);
157
+ if (!kinds.some((k) => f.kind.includes(k)))
158
+ return false;
159
+ }
160
+ if (f.expired || f.expiringDays !== undefined) {
161
+ const { expired, soon } = bundleExpiry(b, ctx.now, f.expiringDays ?? DEFAULT_EXPIRING_DAYS);
162
+ if (f.expired && expired === 0)
163
+ return false;
164
+ if (f.expiringDays !== undefined && soon === 0)
165
+ return false;
166
+ }
167
+ if (f.unusedBefore !== undefined) {
168
+ // Never used counts as unused — it is the strongest form of the answer.
169
+ if (b.last_used && new Date(b.last_used).getTime() >= f.unusedBefore)
170
+ return false;
171
+ }
172
+ return true;
173
+ }
174
+ /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
175
+ export const SORT_FIELDS = ['name', 'used', 'created', 'updated', 'expiry'];
176
+ export function parseSortField(raw) {
177
+ if (!raw)
178
+ return 'name';
179
+ const v = raw.toLowerCase();
180
+ if (!SORT_FIELDS.includes(v)) {
181
+ throw new Error(`Invalid --sort '${raw}'. Valid values: ${SORT_FIELDS.join(', ')}`);
182
+ }
183
+ return v;
184
+ }
185
+ /** Epoch-ms of a bundle's soonest expiry, or Infinity when nothing expires — so
186
+ * `--sort expiry` puts the most urgent first and never-expiring bundles last. */
187
+ function soonestExpiry(b) {
188
+ let soonest = Infinity;
189
+ for (const m of Object.values(b.meta ?? {})) {
190
+ if (!m.expires)
191
+ continue;
192
+ const t = new Date(`${m.expires}T23:59:59Z`).getTime();
193
+ if (t < soonest)
194
+ soonest = t;
195
+ }
196
+ return soonest;
197
+ }
198
+ /** Sort a copy. Time fields are most-recent-first (the useful direction for
199
+ * "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
200
+ * so the order is stable. */
201
+ export function sortBundles(bundles, field) {
202
+ const stamp = (iso) => (iso ? new Date(iso).getTime() : 0);
203
+ const byName = (a, z) => a.name.localeCompare(z.name);
204
+ const out = [...bundles];
205
+ if (field === 'name')
206
+ return out.sort(byName);
207
+ out.sort((a, z) => {
208
+ if (field === 'expiry') {
209
+ const d = soonestExpiry(a) - soonestExpiry(z);
210
+ return d !== 0 ? d : byName(a, z);
211
+ }
212
+ const key = field === 'used' ? 'last_used' : field === 'created' ? 'created_at' : 'updated_at';
213
+ const d = stamp(z[key]) - stamp(a[key]);
214
+ return d !== 0 ? d : byName(a, z);
215
+ });
216
+ return out;
217
+ }
218
+ /** Human summary of the active filters, for the empty state. `sessions` only
219
+ * echoes --project/--all on a miss, which leaves you guessing which flag emptied
220
+ * the list; naming every active axis is the difference between "nothing matched"
221
+ * and knowing what to relax. */
222
+ export function describeFilter(f) {
223
+ const parts = [];
224
+ if (f.query)
225
+ parts.push(`matching "${f.query}"`);
226
+ if (f.policy)
227
+ parts.push(`policy ${f.policy.join('/')}`);
228
+ if (f.backend)
229
+ parts.push(`backend ${f.backend.join('/')}`);
230
+ if (f.type)
231
+ parts.push(`type ${f.type.join('/')}`);
232
+ if (f.kind)
233
+ parts.push(`kind ${f.kind.join('/')}`);
234
+ if (f.held === true)
235
+ parts.push('currently held');
236
+ if (f.held === false)
237
+ parts.push('not currently held');
238
+ if (f.expired)
239
+ parts.push('with an expired key');
240
+ if (f.expiringDays !== undefined)
241
+ parts.push(`expiring within ${f.expiringDays}d`);
242
+ if (f.unusedBefore !== undefined)
243
+ parts.push('unused since the given cutoff');
244
+ return parts.join(', ');
245
+ }
@@ -6,6 +6,7 @@ import { type SessionAttachment } from './types.js';
6
6
  import { type SessionProvenance } from './provenance.js';
7
7
  import { type DeviceRegistry } from '../devices/registry.js';
8
8
  import { type Presence } from './detached.js';
9
+ import { type HostLink } from './host-link.js';
9
10
  /**
10
11
  * The owner (actor id) to show for a session in `--active`. Prefers the actor
11
12
  * recorded on the live-attribution source (the pid registry / teammate record),
@@ -44,7 +45,11 @@ export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
44
45
  * antigravity) gets a real working/waiting/idle from its own parser — see
45
46
  * {@link computeLiveSignals}, {@link lifecycleStatus} and {@link resolveFallbackStatus}.
46
47
  */
47
- export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'closed' | 'abandoned' | 'unknown';
48
+ export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'closed' | 'abandoned'
49
+ /** Alive, but no client is attached — the host window died and the agent outlived it. */
50
+ | 'orphaned'
51
+ /** The host window died and took the agent with it — an unclean exit, not a normal close. */
52
+ | 'crashed' | 'unknown';
48
53
  export interface ActiveSession {
49
54
  context: ActiveContext;
50
55
  kind: string;
@@ -122,6 +127,35 @@ export interface ActiveSession {
122
127
  * from the detach store — never asserted by a source.
123
128
  */
124
129
  presence?: Presence;
130
+ /**
131
+ * Whether anything is still on the other end of this session — folded on at the
132
+ * end of {@link getActiveSessions} by {@link foldHostLink} from the raw signals
133
+ * below, never asserted by a source. Drives the `orphaned` / `crashed` statuses.
134
+ */
135
+ hostLink?: HostLink;
136
+ /**
137
+ * Whether this session's process was alive at scan time — the boolean
138
+ * {@link applyState} already computes, kept rather than thrown away.
139
+ *
140
+ * `status` cannot stand in for it. `abandoned` fires on transcript staleness
141
+ * BEFORE the liveness check, so it covers a live-but-stuck process as well as
142
+ * a long-dead one; only `closed`/`crashed` are unconditionally dead. A consumer
143
+ * that must tell "still there, just quiet" from "gone" needs this, not the
144
+ * status. Absent from cloud rows (no pid) and from a peer running an older CLI.
145
+ */
146
+ pidAlive?: boolean;
147
+ /**
148
+ * Clients attached to this session's tmux session (`#{session_attached}`), for
149
+ * a tmux-hosted row. Absent — NOT zero — when the session is not tmux-hosted:
150
+ * zero means "tmux says nobody is looking", absent means "we cannot tell".
151
+ */
152
+ tmuxClients?: number;
153
+ /**
154
+ * When the owning IDE window last refreshed its slice of the live-terminals
155
+ * registry. Absent for a session no IDE window owns. A stale value means that
156
+ * window is gone — see {@link HOST_HEARTBEAT_STALE_MS}.
157
+ */
158
+ windowHeartbeatMs?: number;
125
159
  /** How many live PIDs resolve to this same session (subagents/forks). 1 unless collapsed. */
126
160
  pidCount?: number;
127
161
  /**
@@ -208,6 +242,24 @@ export interface ActiveSession {
208
242
  app: string;
209
243
  tab?: number;
210
244
  };
245
+ /**
246
+ * The editor tab that launched this agent (`AGENT_TERMINAL_ID`), from the pid
247
+ * registry. This is the one identifier that survives an SSH hop AND a session
248
+ * rotation: a Factory tab offloaded to a device has no local process to inspect,
249
+ * and its spawn-time session id goes stale the moment the agent moves to another
250
+ * session (`/clear`, exit-and-rerun), so `--active --host <device>` joined on
251
+ * this is how that tab re-identifies its own session. Absent for any launch that
252
+ * did not inherit a terminal id.
253
+ */
254
+ terminalId?: string;
255
+ /**
256
+ * tmux pane id (`%N`) when this row was discovered via the tmux source AND its
257
+ * session id could not be resolved (a born-unidentifiable non-Claude pane). It
258
+ * is the dedupe key for such id-less rows, so two anonymous panes in the same
259
+ * cwd render as two distinct rows instead of collapsing onto each other. Unset
260
+ * once a session id resolves (the id is the identity then).
261
+ */
262
+ paneId?: string;
211
263
  }
212
264
  export declare function activeStatusFromCloudStatus(status: CloudTaskStatus): ActiveStatus;
213
265
  export interface ActiveQueryOptions {
@@ -238,6 +290,22 @@ export declare const ABANDONED_STALE_MS: number;
238
290
  * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
239
291
  */
240
292
  export declare function agentKindFromComm(commRaw: string): string | undefined;
293
+ /** Agent kind from an `ag-<agent>-<shortid>` tmux session name, else undefined. */
294
+ export declare function agentKindFromName(sessName: string): string | undefined;
295
+ /** The 8-char session-id prefix from an `ag-<agent>-<shortid>` name, else undefined. */
296
+ export declare function shortIdFromName(sessName: string): string | undefined;
297
+ /**
298
+ * Map every `ag-<agent>-<shortid>` tmux session name to its full session UUID in
299
+ * ONE batched DB lookup. The live scan calls this once per poll (not per pane),
300
+ * then resolvePaneIdentity reads the map — the recovery that makes a detached
301
+ * agent findable by `focus <id>` even when its durable identity records are gone.
302
+ * `findSessionsByShortIds` is injected so this stays unit-testable without a DB.
303
+ */
304
+ export declare function resolveNamesToSessionIds(sessionNames: string[], deps: {
305
+ findSessionsByShortIds: (shortIds: string[]) => Map<string, {
306
+ id: string;
307
+ }>;
308
+ }): Map<string, string>;
241
309
  /**
242
310
  * True when `pid` names a live process AND — when a session's recorded
243
311
  * `startedAtMs` is supplied — that process is plausibly the SAME one, not a later
@@ -446,11 +514,11 @@ export interface PaneIdentity {
446
514
  * `source: 'teams'` panes are skipped — teammates are surfaced by listTeamsActive.
447
515
  * Pure so it is unit-tested without tmux.
448
516
  */
449
- export declare function resolvePaneIdentity(pane: string, meta: {
517
+ export declare function resolvePaneIdentity(pane: string, sessName: string, meta: {
450
518
  labels?: Record<string, string>;
451
519
  source?: string;
452
520
  pane?: string;
453
- } | null, liveEntry: PidSessionEntry | undefined, getHookIndex: () => HookSessionIndex): PaneIdentity | undefined;
521
+ } | null, liveEntry: PidSessionEntry | undefined, getHookIndex: () => HookSessionIndex, nameToFullId: Map<string, string>): PaneIdentity | undefined;
454
522
  /**
455
523
  * Agents hosted in the shared-socket tmux server — the authoritative source for
456
524
  * tmux-hosted interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
@@ -471,6 +539,44 @@ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
471
539
  * terminal/headless row for the same session id.
472
540
  */
473
541
  export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
542
+ /**
543
+ * Fold tmux's attached-client count onto every tmux-hosted row.
544
+ *
545
+ * Keyed off `provenance.mux` — which {@link enrichProvenance} has already stamped
546
+ * on any row whose process env names a tmux pane — rather than off
547
+ * {@link listTmuxAgentSessions}. That source only emits a row when it can resolve
548
+ * the pane's agent IDENTITY (launch registry or session meta), and on a machine
549
+ * where neither resolves it emits nothing at all while the same sessions still
550
+ * arrive through the terminal/headless sources carrying full tmux provenance.
551
+ * Hanging the client count off the identity-resolving source would have made the
552
+ * whole orphan signal silently dead on exactly those machines.
553
+ *
554
+ * One `list-panes` per distinct socket, and only when some row is tmux-hosted —
555
+ * a fleet with no tmux pays nothing. A query failure leaves the count undefined,
556
+ * which the classifier reads as "cannot tell", never as a false zero.
557
+ */
558
+ export declare function foldTmuxClients(rows: ActiveSession[]): Promise<void>;
559
+ /**
560
+ * Fold the host link onto each row and, where it changes the answer, onto the
561
+ * status. Runs AFTER {@link foldPresence}, because a deliberately backgrounded
562
+ * session (`presence` `background`/`parked`) is supposed to have no client and
563
+ * must not be reported as an orphan.
564
+ *
565
+ * Precedence is deliberate, and the two new statuses slot in where they add
566
+ * information rather than destroy it:
567
+ *
568
+ * - `abandoned` wins outright. A days-stale session is already dangling; that
569
+ * it also lost its window is not the headline, and it keeps a crashed row
570
+ * from lingering as an alert forever.
571
+ * - `crashed` REPLACES `closed`. Both mean the process is gone, but `closed`
572
+ * reads as a normal exit; `crashed` says the host window went down with it
573
+ * and never cleaned up.
574
+ * - `orphaned` replaces only `idle` / `input_required`. A session still WORKING
575
+ * with nobody watching is a normal headless run, and flagging every one would
576
+ * bury the real signal. A session sitting idle — or worse, waiting on a
577
+ * question — with no client attached is the stranded case: nobody is coming.
578
+ */
579
+ export declare function foldHostLink(rows: ActiveSession[]): void;
474
580
  /**
475
581
  * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
476
582
  * when that orchestrator session is itself in the active set (it usually is — the