@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
@@ -9,9 +9,13 @@ import { machineId, normalizeHost } from '../../machine-id.js';
9
9
  import { SSH_OPTS, controlOpts, shellQuote } from '../../ssh-exec.js';
10
10
  import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
11
11
  import { isReapableOrphan } from '../active.js';
12
+ import { querySessions } from '../db.js';
13
+ import { linearIssueUrl } from '../linear.js';
14
+ import { sessionAgentSupportsResume } from '../recovery.js';
12
15
  import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, noteActiveSessionsJournalReader, } from '../session-cache.js';
13
16
  export const SESSION_WATCH_VERSION = 1;
14
17
  export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
18
+ export const SESSION_WATCH_PREVIOUS_LIMIT = 50;
15
19
  /** Stable, opaque identity for one row within one device scope. */
16
20
  export function sessionWatchRowKey(scope, row) {
17
21
  const identity = activeSessionJournalIdentity(row);
@@ -24,6 +28,9 @@ export function toSessionWatchRow(scope, row) {
24
28
  // leaked --device tunnel sessions (RUSH-3011 / issue #3b). A live or
25
29
  // recently-exited session stays resumable.
26
30
  const resumable = Boolean(row.sessionId) && !isReapableOrphan(row);
31
+ const previous = row.status === 'closed'
32
+ || row.status === 'crashed'
33
+ || (row.status === 'abandoned' && row.pidAlive === false);
27
34
  const viewingIn = row.viewingIn
28
35
  ? [row.viewingIn.app, row.viewingIn.tab ? `tab ${row.viewingIn.tab}` : undefined].filter(Boolean).join(' ')
29
36
  : null;
@@ -31,6 +38,7 @@ export function toSessionWatchRow(scope, row) {
31
38
  ...row,
32
39
  rowKey,
33
40
  sourceDevice: scope,
41
+ previous,
34
42
  resumable,
35
43
  unwatched: !viewingIn,
36
44
  viewingIn,
@@ -39,6 +47,88 @@ export function toSessionWatchRow(scope, row) {
39
47
  : null,
40
48
  };
41
49
  }
50
+ function epochMs(value) {
51
+ const parsed = value ? Date.parse(value) : Number.NaN;
52
+ return Number.isFinite(parsed) ? parsed : 0;
53
+ }
54
+ /** Stable identity for a durable Previous row. It is deliberately distinct
55
+ * from the live row key for the same session id: both may coexist on the one
56
+ * stream, and the presentation layer lets the live row win while it exists. */
57
+ export function previousSessionWatchRowKey(scope, sessionId) {
58
+ return createHash('sha256').update(`${scope}\0previous\0${sessionId}`).digest('base64url').slice(0, 22);
59
+ }
60
+ /** Project one durable indexed session into the same canonical watch contract
61
+ * as live sessions. This is the only history backfill consumed by AGI EXT. */
62
+ export function toPreviousSessionWatchRow(scope, session) {
63
+ const resumable = sessionAgentSupportsResume(session.agent);
64
+ const sourceDevice = normalizeHost(session.machine ?? scope);
65
+ const startedAtMs = epochMs(session.timestamp);
66
+ const lastActivityMs = epochMs(session.lastActivity) || startedAtMs;
67
+ const worktree = session.worktreeSlug && session.cwd
68
+ ? { slug: session.worktreeSlug, path: session.cwd, ...(session.gitBranch ? { branch: session.gitBranch } : {}) }
69
+ : undefined;
70
+ return {
71
+ context: 'recent',
72
+ kind: session.agent,
73
+ ...(session.harness ? { harness: session.harness } : {}),
74
+ sessionId: session.id,
75
+ ...(session.cwd ? { cwd: session.cwd } : {}),
76
+ ...(session.project ? { project: session.project } : {}),
77
+ ...(session.label ? { label: session.label, title: session.label } : session.topic ? { title: session.topic } : {}),
78
+ ...(session.topic ? { topic: session.topic } : {}),
79
+ ...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
80
+ ...(session.version ? { version: session.version } : {}),
81
+ ...(session.account ? { account: session.account } : {}),
82
+ ...(session.prUrl ? { pr: { url: session.prUrl, number: session.prNumber } } : {}),
83
+ ...(worktree ? { worktree } : {}),
84
+ ...(session.gitBranch ? { branch: session.gitBranch } : {}),
85
+ ...(session.ticketId ? { ticket: { id: session.ticketId, url: linearIssueUrl(session.ticketId) } } : {}),
86
+ ...(session.createdTickets ? { createdTickets: session.createdTickets } : {}),
87
+ ...(session.spawnedTeam ? { spawnedTeam: session.spawnedTeam } : {}),
88
+ ...(session.tokenCount != null ? { tokenCount: session.tokenCount } : {}),
89
+ ...(session.durationMs != null ? { durationMs: session.durationMs } : {}),
90
+ ...(session.subAgentCount != null ? { subAgentCount: session.subAgentCount } : {}),
91
+ startedAtMs,
92
+ lastActivityMs,
93
+ status: 'closed',
94
+ phase: 'done',
95
+ pidAlive: false,
96
+ rowKey: previousSessionWatchRowKey(scope, session.id),
97
+ sourceDevice,
98
+ previous: true,
99
+ resumable,
100
+ unwatched: true,
101
+ viewingIn: null,
102
+ recovery: resumable
103
+ ? { command: 'agents', args: ['sessions', 'resume', session.id, '--device', sourceDevice], ...(session.cwd ? { cwd: session.cwd } : {}) }
104
+ : null,
105
+ };
106
+ }
107
+ function demoteWatchRow(scope, row) {
108
+ if (!row.sessionId || row.rowKey === previousSessionWatchRowKey(scope, row.sessionId))
109
+ return null;
110
+ const sourceDevice = row.sourceDevice || scope;
111
+ return {
112
+ ...row,
113
+ context: 'recent',
114
+ status: 'closed',
115
+ phase: 'done',
116
+ pidAlive: false,
117
+ rowKey: previousSessionWatchRowKey(scope, row.sessionId),
118
+ previous: true,
119
+ unwatched: true,
120
+ viewingIn: null,
121
+ recovery: row.resumable
122
+ ? { command: 'agents', args: ['sessions', 'resume', row.sessionId, '--device', sourceDevice], ...(row.cwd ? { cwd: row.cwd } : {}) }
123
+ : null,
124
+ };
125
+ }
126
+ function isDurablePreviousRow(scope, row) {
127
+ return Boolean(row.sessionId) && row.rowKey === previousSessionWatchRowKey(scope, row.sessionId);
128
+ }
129
+ function rowRecency(row) {
130
+ return row.lastActivityMs ?? row.startedAtMs ?? 0;
131
+ }
42
132
  function sameRow(a, b) {
43
133
  return JSON.stringify(a) === JSON.stringify(b);
44
134
  }
@@ -51,24 +141,77 @@ export class SessionWatchState {
51
141
  base(type) {
52
142
  return { version: SESSION_WATCH_VERSION, type, streamId: this.streamId, sequence: ++this.sequence, capturedAt: Date.now() };
53
143
  }
54
- reset(scope, sourceRows) {
55
- const rows = sourceRows.map((row) => toSessionWatchRow(scope, row));
56
- this.rows.set(scope, new Map(rows.map((row) => [row.rowKey, row])));
57
- return { ...this.base('reset'), scope, rows };
144
+ /** Keep one visible Previous row per session and the newest bounded window.
145
+ * Returns removed keys so delta callers can converge already-connected clients. */
146
+ prunePrevious(scope, rows) {
147
+ const removed = [];
148
+ const bySession = new Map();
149
+ for (const row of rows.values()) {
150
+ if (!row.previous || !row.sessionId)
151
+ continue;
152
+ const existing = bySession.get(row.sessionId);
153
+ if (!existing) {
154
+ bySession.set(row.sessionId, row);
155
+ continue;
156
+ }
157
+ const keepRow = rowRecency(row) > rowRecency(existing)
158
+ || (rowRecency(row) === rowRecency(existing) && isDurablePreviousRow(scope, row));
159
+ const drop = keepRow ? existing : row;
160
+ const keep = keepRow ? row : existing;
161
+ if (rows.delete(drop.rowKey))
162
+ removed.push(drop.rowKey);
163
+ bySession.set(row.sessionId, keep);
164
+ }
165
+ const previous = [...bySession.values()].sort((a, b) => rowRecency(b) - rowRecency(a));
166
+ for (const row of previous.slice(SESSION_WATCH_PREVIOUS_LIMIT)) {
167
+ if (rows.delete(row.rowKey))
168
+ removed.push(row.rowKey);
169
+ }
170
+ return removed;
171
+ }
172
+ reset(scope, sourceRows, indexedRows = []) {
173
+ const liveRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
174
+ const sourceIds = new Set(liveRows.map((row) => row.sessionId).filter((id) => Boolean(id)));
175
+ const rows = new Map(liveRows.map((row) => [row.rowKey, row]));
176
+ for (const indexed of indexedRows) {
177
+ if (sourceIds.has(indexed.id))
178
+ continue;
179
+ const row = toPreviousSessionWatchRow(scope, indexed);
180
+ rows.set(row.rowKey, row);
181
+ }
182
+ this.prunePrevious(scope, rows);
183
+ this.rows.set(scope, rows);
184
+ return { ...this.base('reset'), scope, rows: [...rows.values()] };
58
185
  }
59
186
  update(scope, sourceRows) {
60
187
  const previous = this.rows.get(scope) ?? new Map();
61
188
  const nextRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
62
- const next = new Map(nextRows.map((row) => [row.rowKey, row]));
189
+ const activeIds = new Set(nextRows.filter((row) => !row.previous).map((row) => row.sessionId).filter((id) => Boolean(id)));
190
+ const next = new Map([...previous.values()]
191
+ .filter((row) => isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
192
+ .map((row) => [row.rowKey, row]));
193
+ for (const row of nextRows)
194
+ next.set(row.rowKey, row);
63
195
  const events = [];
64
196
  for (const [rowKey, row] of next) {
65
197
  if (!previous.has(rowKey) || !sameRow(previous.get(rowKey), row)) {
66
198
  events.push({ ...this.base('upsert'), scope, rowKey, row });
67
199
  }
68
200
  }
69
- for (const rowKey of previous.keys()) {
70
- if (!next.has(rowKey))
71
- events.push({ ...this.base('remove'), scope, rowKey });
201
+ for (const [rowKey, row] of previous) {
202
+ if (next.has(rowKey))
203
+ continue;
204
+ if (isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
205
+ continue;
206
+ events.push({ ...this.base('remove'), scope, rowKey });
207
+ const demoted = demoteWatchRow(scope, row);
208
+ if (demoted && (!next.has(demoted.rowKey) || !sameRow(next.get(demoted.rowKey), demoted))) {
209
+ next.set(demoted.rowKey, demoted);
210
+ events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
211
+ }
212
+ }
213
+ for (const rowKey of this.prunePrevious(scope, next)) {
214
+ events.push({ ...this.base('remove'), scope, rowKey });
72
215
  }
73
216
  this.rows.set(scope, next);
74
217
  return events;
@@ -78,6 +221,11 @@ export class SessionWatchState {
78
221
  const events = [];
79
222
  for (const source of upserts) {
80
223
  const row = toSessionWatchRow(scope, source);
224
+ if (!row.previous && row.sessionId) {
225
+ const previousKey = previousSessionWatchRowKey(scope, row.sessionId);
226
+ if (current.delete(previousKey))
227
+ events.push({ ...this.base('remove'), scope, rowKey: previousKey });
228
+ }
81
229
  if (!current.has(row.rowKey) || !sameRow(current.get(row.rowKey), row)) {
82
230
  current.set(row.rowKey, row);
83
231
  events.push({ ...this.base('upsert'), scope, rowKey: row.rowKey, row });
@@ -85,8 +233,18 @@ export class SessionWatchState {
85
233
  }
86
234
  for (const identity of removes) {
87
235
  const rowKey = createHash('sha256').update(`${scope}\0${identity}`).digest('base64url').slice(0, 22);
88
- if (current.delete(rowKey))
89
- events.push({ ...this.base('remove'), scope, rowKey });
236
+ const removed = current.get(rowKey);
237
+ if (!current.delete(rowKey))
238
+ continue;
239
+ events.push({ ...this.base('remove'), scope, rowKey });
240
+ const demoted = removed ? demoteWatchRow(scope, removed) : null;
241
+ if (demoted && (!current.has(demoted.rowKey) || !sameRow(current.get(demoted.rowKey), demoted))) {
242
+ current.set(demoted.rowKey, demoted);
243
+ events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
244
+ }
245
+ }
246
+ for (const rowKey of this.prunePrevious(scope, current)) {
247
+ events.push({ ...this.base('remove'), scope, rowKey });
90
248
  }
91
249
  this.rows.set(scope, current);
92
250
  return events;
@@ -100,6 +258,24 @@ export class SessionWatchState {
100
258
  return { ...this.base('heartbeat'), scope, capturedAt };
101
259
  }
102
260
  }
261
+ /** One bounded index read when the watch starts. The stream then owns this
262
+ * projection: active rows update through the journal, and removals demote into
263
+ * Previous rows without another transcript/index poll. */
264
+ export function readPreviousSessionsForWatch(scope) {
265
+ try {
266
+ return querySessions({
267
+ machine: normalizeHost(scope),
268
+ agents: ['claude', 'codex', 'muse', 'opencode'],
269
+ sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000,
270
+ excludeTeamOrigin: true,
271
+ })
272
+ .filter((session) => !session.archived && Boolean(session.filePath))
273
+ .slice(0, SESSION_WATCH_PREVIOUS_LIMIT);
274
+ }
275
+ catch {
276
+ return [];
277
+ }
278
+ }
103
279
  /**
104
280
  * Keep one local subscription alive. Startup reads one canonical reset snapshot;
105
281
  * steady state tails the canonical writer journal and never invokes a gather.
@@ -107,6 +283,7 @@ export class SessionWatchState {
107
283
  export async function watchLocalSessions(options) {
108
284
  const state = new SessionWatchState();
109
285
  const readCache = options.readCache ?? readActiveSessionsCache;
286
+ const readPrevious = options.readPrevious ?? readPreviousSessionsForWatch;
110
287
  const journal = options.journalPath ?? activeSessionsJournalPath();
111
288
  const heartbeatMs = options.heartbeatMs ?? SESSION_WATCH_HEARTBEAT_MS;
112
289
  let offset = 0;
@@ -115,7 +292,7 @@ export async function watchLocalSessions(options) {
115
292
  }
116
293
  catch { /* first publication */ }
117
294
  const initial = readCache('local');
118
- options.emit(state.reset(options.scope, initial?.sessions ?? []));
295
+ options.emit(state.reset(options.scope, initial?.sessions ?? [], readPrevious(options.scope)));
119
296
  // Known gap (out of scope for RUSH-2484): a present cache alone marks
120
297
  // 'available' with no staleness/age check, so a reconnect can briefly render
121
298
  // a stale snapshot as live before the out-of-band gather this file triggers
@@ -32,6 +32,7 @@ export declare function activeSessionJournalIdentity(row: ActiveSession): string
32
32
  */
33
33
  export interface ImmutableSessionFields {
34
34
  topic?: string;
35
+ firstUserMessage?: string;
35
36
  label?: string;
36
37
  name?: string;
37
38
  cwd?: string;
@@ -51,7 +52,7 @@ export interface ImmutableSessionFields {
51
52
  context?: ActiveSession['context'];
52
53
  }
53
54
  /** Keys stored in the immutable memo (transcript-stable). */
54
- export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
55
+ export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "firstUserMessage", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
55
56
  /**
56
57
  * Live / volatile fields that MUST NOT be served from the immutable memo.
57
58
  * They either change without a transcript write (pid death, attach state) or
@@ -51,6 +51,7 @@ const activeSnapshotMemory = createMemoryCache({
51
51
  /** Keys stored in the immutable memo (transcript-stable). */
52
52
  export const IMMUTABLE_FIELD_KEYS = [
53
53
  'topic',
54
+ 'firstUserMessage',
54
55
  'label',
55
56
  'name',
56
57
  'cwd',
@@ -19,6 +19,7 @@ import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './
19
19
  import { isShellExecTool } from './shell-programs.js';
20
20
  import { classifyFileChanges } from './digest.js';
21
21
  import { extractArtifacts } from './highlights.js';
22
+ import { LINEAR_KEY_DENYLIST, linearIssueKeys } from './linear.js';
22
23
  /**
23
24
  * Detect per-session rate-limit / usage-limit signals in assistant or error
24
25
  * text (RUSH-1523). Matches the same shapes the ext's prewarm detectBlockingPrompt
@@ -187,15 +188,12 @@ export function extractRecentDirectoriesTouched(events, cwd) {
187
188
  const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
188
189
  const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
189
190
  /**
190
- * Linear/Jira-style ref, e.g. RUSH-1234. Team key is letters-only (2–6) so a
191
- * regex snippet like `[A-Z0-9]-\d` in a code discussion can't masquerade as a
192
- * ticket. Uppercase-only so we don't match `utf-8`.
191
+ * Linear/Jira-style ref detection reuses the canonical key matcher +
192
+ * {@link LINEAR_KEY_DENYLIST} from `./linear.js`, so the transcript detector and
193
+ * the owner-ping linkifier agree on what a real key is (no second copy to drift).
193
194
  */
194
- const TICKET_RE = /\b([A-Z]{2,6}-\d{1,6})\b/;
195
195
  /** Lowercase branch form (Linear branch names): muqsit/rush-1234-fix. */
196
196
  const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
197
- /** Keys that look like tickets but aren't — avoid false positives from branches. */
198
- const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
199
197
  const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
200
198
  // Either separator: a Windows session cwd is `…\.agents\worktrees\<slug>`, and a
201
199
  // forward-slash-only pattern silently derived no slug there (the RUSH-2358
@@ -283,15 +281,15 @@ export function detectWorktree(cwd, branch) {
283
281
  /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
284
282
  export function detectTicket(text, branch) {
285
283
  if (text) {
286
- const m = text.match(TICKET_RE);
287
- if (m && !TICKET_DENYLIST.has(m[1].split('-')[0]))
288
- return { id: m[1] };
284
+ const key = linearIssueKeys(text)[0];
285
+ if (key)
286
+ return { id: key };
289
287
  }
290
288
  if (branch) {
291
289
  const m = branch.match(TICKET_BRANCH_RE);
292
290
  if (m) {
293
291
  const key = m[1].toUpperCase();
294
- if (!TICKET_DENYLIST.has(key))
292
+ if (!LINEAR_KEY_DENYLIST.has(key))
295
293
  return { id: `${key}-${m[2]}` };
296
294
  }
297
295
  }
@@ -355,9 +353,9 @@ export function isTicketCreateTool(name, command) {
355
353
  export function extractCreatedTicket(text) {
356
354
  if (!text)
357
355
  return undefined;
358
- const lin = text.match(TICKET_RE);
359
- if (lin && !TICKET_DENYLIST.has(lin[1].split('-')[0]))
360
- return lin[1];
356
+ const lin = linearIssueKeys(text)[0];
357
+ if (lin)
358
+ return lin;
361
359
  const gh = text.match(GH_ISSUE_URL_RE);
362
360
  if (gh)
363
361
  return `#${gh[1]}`;
@@ -232,6 +232,8 @@ export interface SessionMeta {
232
232
  /** Effective normalized launch mode captured by the SessionStart hook. */
233
233
  mode?: SessionRunMode;
234
234
  topic?: string;
235
+ /** Full, cleaned first genuine user turn; distinct from the one-line topic. */
236
+ firstUserMessage?: string;
235
237
  /**
236
238
  * The session's human-readable name — one field, several sources with a plain
237
239
  * priority: an agent-generated title / Claude `/rename` wins; else the launch
@@ -88,8 +88,8 @@ export declare function phoenixIdBaseForDeploy(opts?: {
88
88
  domain?: string;
89
89
  }): string | undefined;
90
90
  /**
91
- * Pick the principal. Signed-in (`readSession() != null`) AND no explicit BYO
92
- * override → managed; otherwise BYO.
91
+ * Pick the principal via the shared selection policy. Signed-in AND no explicit
92
+ * BYO override → managed; otherwise BYO.
93
93
  */
94
94
  export declare function shouldUseManaged(opts?: ResolveShareBackendOpts): boolean;
95
95
  export declare function resolveShareBackend(opts?: ResolveShareBackendOpts): ShareBackend;
@@ -18,6 +18,7 @@
18
18
  * - `AGENTS_SHARE_BACKEND=byo`
19
19
  */
20
20
  import { PHOENIX_ID_BASE, readSession } from '../identity/client.js';
21
+ import { selectStorageBackendKind } from '../storage/selection.js';
21
22
  import { DEFAULT_SHARE_DOMAIN, readShareConfig, readWriteToken, readWriteTokenEnv, } from './config.js';
22
23
  /** Env var that forces the BYO path. Value must be exactly `byo`. */
23
24
  export const SHARE_BACKEND_ENV = 'AGENTS_SHARE_BACKEND';
@@ -82,18 +83,28 @@ export function phoenixIdBaseForDeploy(opts = {}, cfg) {
82
83
  return base;
83
84
  }
84
85
  /**
85
- * Pick the principal. Signed-in (`readSession() != null`) AND no explicit BYO
86
- * override → managed; otherwise BYO.
86
+ * The share surface's BYO-override signals: an explicit `--byo`, a
87
+ * caller-supplied static write token, or `AGENTS_SHARE_BACKEND=byo`. Detecting
88
+ * WHICH signals count is surface-specific; the managed-vs-BYO decision itself is
89
+ * the shared policy (`selectStorageBackendKind`). A persisted BYO endpoint config
90
+ * is deliberately NOT an override here — a signed-in user with a stale BYO config
91
+ * still publishes to managed unless they opt out explicitly (the product's
92
+ * managed-first contract).
87
93
  */
88
- export function shouldUseManaged(opts = {}) {
94
+ function shareByoOverride(opts) {
89
95
  if (opts.byo === true)
90
- return false;
96
+ return true;
91
97
  if (opts.writeToken)
92
- return false;
93
- if ((process.env[SHARE_BACKEND_ENV] ?? '').trim().toLowerCase() === 'byo')
94
- return false;
95
- const session = opts.session === undefined ? readSession() : opts.session;
96
- return session != null;
98
+ return true;
99
+ return (process.env[SHARE_BACKEND_ENV] ?? '').trim().toLowerCase() === 'byo';
100
+ }
101
+ /**
102
+ * Pick the principal via the shared selection policy. Signed-in AND no explicit
103
+ * BYO override → managed; otherwise BYO.
104
+ */
105
+ export function shouldUseManaged(opts = {}) {
106
+ return (selectStorageBackendKind({ byoOverride: shareByoOverride(opts), session: opts.session }) ===
107
+ 'managed');
97
108
  }
98
109
  export function resolveShareBackend(opts = {}) {
99
110
  if (shouldUseManaged(opts)) {
@@ -1,8 +1,12 @@
1
1
  import { type ShareConfig } from './config.js';
2
- /** DI seam for tests — override the real HTTP DELETE. */
2
+ /** DI seam for tests — override the real HTTP DELETE. `body`/`retryAfter` carry
3
+ * the Worker's error response (read only on `!ok`) so a failed delete surfaces
4
+ * WHY, via the bounded {@link extractShareHttpError}. */
3
5
  export type DeleteFn = (url: string, headers: Record<string, string>) => Promise<{
4
6
  ok: boolean;
5
7
  status: number;
8
+ body?: string;
9
+ retryAfter?: string;
6
10
  }>;
7
11
  /** DI seam for tests — override the real HTTP existence check (HEAD). */
8
12
  export type CheckFn = (url: string) => Promise<{
@@ -9,6 +9,7 @@
9
9
  // the operation is reported as successful.
10
10
  import { resolveShareBackend } from './backend.js';
11
11
  import { buildShareKey, resolveShareUsername } from './publish.js';
12
+ import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
12
13
  /**
13
14
  * Normalize any of the three accepted target forms to the R2 key that publish
14
15
  * would have written:
@@ -59,7 +60,10 @@ async function defaultCheck(url) {
59
60
  }
60
61
  async function defaultDelete(url, headers) {
61
62
  const res = await fetch(url, { method: 'DELETE', headers });
62
- return { ok: res.ok, status: res.status };
63
+ if (res.ok)
64
+ return { ok: true, status: res.status };
65
+ const body = await res.text().catch(() => undefined);
66
+ return { ok: false, status: res.status, body, retryAfter: res.headers.get('retry-after') ?? undefined };
63
67
  }
64
68
  async function defaultRevisionsFetch(url) {
65
69
  const res = await fetch(url, { headers: { accept: 'application/json' } });
@@ -103,7 +107,8 @@ export async function deleteObject(endpoint, key, opts = {}) {
103
107
  const existedBefore = before.status !== 404;
104
108
  const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
105
109
  if (!r.ok) {
106
- throw new Error(`Delete failed (${r.status}) for ${url}. Check the bearer (Phoenix session or WRITE_TOKEN), or that 'agents artifacts setup' completed.`);
110
+ const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter }));
111
+ throw new Error(`Delete failed (${r.status}) for ${url}${detail}. Check the bearer (Phoenix session or WRITE_TOKEN), or that 'agents artifacts setup' completed.`);
107
112
  }
108
113
  const after = await check(url);
109
114
  const verified404 = after.status === 404;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ONE bounded extractor for the share Worker's error responses.
3
+ *
4
+ * Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
5
+ * previously threw on `!ok` with only the status code, discarding the JSON error
6
+ * body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
7
+ * `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
8
+ * surfaced as a bare "Publish failed (429)" with no reason. This pulls the
9
+ * server's own `error` string and preserves the status + `Retry-After`, so the
10
+ * user sees why.
11
+ *
12
+ * Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
13
+ * of the body, only lifts a string `error` field, caps its length, and NEVER
14
+ * surfaces an arbitrary body — a non-JSON or oversized body yields no message,
15
+ * so an HTML error page or a huge payload can't leak into a CLI error string.
16
+ */
17
+ /** The raw pieces a caller's fetch wrapper hands in — status plus the (optional)
18
+ * response body text and `Retry-After` header value. */
19
+ export interface ShareHttpResponse {
20
+ status: number;
21
+ /** The response body text, when the caller read it (only on `!ok` paths). */
22
+ body?: string;
23
+ /** The `Retry-After` header value, when present (a 429 carries it). */
24
+ retryAfter?: string;
25
+ }
26
+ /** The extracted, bounded error facts. `serverMessage` is present only when the
27
+ * body parsed as JSON carrying a non-empty string `error`. */
28
+ export interface ShareHttpError {
29
+ status: number;
30
+ serverMessage?: string;
31
+ retryAfter?: string;
32
+ }
33
+ /** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
34
+ * is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
35
+ export declare const MAX_ERROR_BODY_BYTES = 4096;
36
+ /** Cap the surfaced message so even a pathological (but valid-JSON) `error`
37
+ * string can't blow up the CLI output. */
38
+ export declare const MAX_ERROR_MESSAGE_CHARS = 300;
39
+ /**
40
+ * Extract the bounded error facts from a share Worker response. Pulls a string
41
+ * `error` field from a small JSON body, preserves status + `Retry-After`, and
42
+ * never surfaces an arbitrary body.
43
+ */
44
+ export declare function extractShareHttpError(res: ShareHttpResponse): ShareHttpError;
45
+ /**
46
+ * A one-line suffix summarizing the server's structured error, or `''` when the
47
+ * response carried nothing extractable. Appended after the status-bearing prefix
48
+ * a caller already builds, e.g.:
49
+ * `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
50
+ * → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
51
+ */
52
+ export declare function formatShareHttpErrorDetail(err: ShareHttpError): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ONE bounded extractor for the share Worker's error responses.
3
+ *
4
+ * Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
5
+ * previously threw on `!ok` with only the status code, discarding the JSON error
6
+ * body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
7
+ * `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
8
+ * surfaced as a bare "Publish failed (429)" with no reason. This pulls the
9
+ * server's own `error` string and preserves the status + `Retry-After`, so the
10
+ * user sees why.
11
+ *
12
+ * Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
13
+ * of the body, only lifts a string `error` field, caps its length, and NEVER
14
+ * surfaces an arbitrary body — a non-JSON or oversized body yields no message,
15
+ * so an HTML error page or a huge payload can't leak into a CLI error string.
16
+ */
17
+ /** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
18
+ * is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
19
+ export const MAX_ERROR_BODY_BYTES = 4096;
20
+ /** Cap the surfaced message so even a pathological (but valid-JSON) `error`
21
+ * string can't blow up the CLI output. */
22
+ export const MAX_ERROR_MESSAGE_CHARS = 300;
23
+ /**
24
+ * Extract the bounded error facts from a share Worker response. Pulls a string
25
+ * `error` field from a small JSON body, preserves status + `Retry-After`, and
26
+ * never surfaces an arbitrary body.
27
+ */
28
+ export function extractShareHttpError(res) {
29
+ const out = { status: res.status };
30
+ const retryAfter = res.retryAfter?.trim();
31
+ if (retryAfter)
32
+ out.retryAfter = retryAfter;
33
+ const raw = res.body;
34
+ if (raw && raw.length <= MAX_ERROR_BODY_BYTES) {
35
+ try {
36
+ const parsed = JSON.parse(raw);
37
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
38
+ const err = parsed.error;
39
+ if (typeof err === 'string' && err.trim()) {
40
+ out.serverMessage = err.trim().slice(0, MAX_ERROR_MESSAGE_CHARS);
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ // Not JSON (an HTML error page, a proxy body, truncated bytes) — deliberately
46
+ // surface nothing rather than dump an arbitrary body into the CLI error.
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * A one-line suffix summarizing the server's structured error, or `''` when the
53
+ * response carried nothing extractable. Appended after the status-bearing prefix
54
+ * a caller already builds, e.g.:
55
+ * `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
56
+ * → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
57
+ */
58
+ export function formatShareHttpErrorDetail(err) {
59
+ const parts = [];
60
+ if (err.serverMessage)
61
+ parts.push(err.serverMessage);
62
+ if (err.retryAfter)
63
+ parts.push(`retry after ${err.retryAfter}s`);
64
+ return parts.length ? ` — ${parts.join('; ')}` : '';
65
+ }