@phnx-labs/agents-cli 1.22.114 → 1.22.116

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 (243) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +210 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +214 -148
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/feed.js +65 -14
  19. package/dist/commands/focus.js +1 -1
  20. package/dist/commands/go.d.ts +0 -17
  21. package/dist/commands/go.js +2 -19
  22. package/dist/commands/logs.js +1 -1
  23. package/dist/commands/mcp.js +8 -83
  24. package/dist/commands/memory.js +4 -47
  25. package/dist/commands/message.js +4 -4
  26. package/dist/commands/plugins.js +1 -93
  27. package/dist/commands/repo.js +0 -44
  28. package/dist/commands/resume.js +4 -1
  29. package/dist/commands/secrets-passthrough.js +2 -2
  30. package/dist/commands/send.d.ts +4 -4
  31. package/dist/commands/send.js +6 -51
  32. package/dist/commands/sessions-backup-setup.js +1 -1
  33. package/dist/commands/sessions-picker.d.ts +11 -0
  34. package/dist/commands/sessions-picker.js +88 -7
  35. package/dist/commands/sessions-resume.js +0 -1
  36. package/dist/commands/sessions-share.d.ts +5 -7
  37. package/dist/commands/sessions-share.js +98 -49
  38. package/dist/commands/sessions.d.ts +21 -2
  39. package/dist/commands/sessions.js +158 -11
  40. package/dist/commands/setup-browser.js +18 -2
  41. package/dist/commands/setup-computer.js +20 -6
  42. package/dist/commands/setup-secrets.d.ts +2 -2
  43. package/dist/commands/setup-secrets.js +24 -4
  44. package/dist/commands/setup-term.d.ts +24 -0
  45. package/dist/commands/setup-term.js +70 -0
  46. package/dist/commands/setup-terminal.d.ts +3 -0
  47. package/dist/commands/setup-terminal.js +22 -0
  48. package/dist/commands/setup.d.ts +1 -1
  49. package/dist/commands/setup.js +29 -11
  50. package/dist/commands/skills.js +0 -8
  51. package/dist/commands/ssh.d.ts +6 -0
  52. package/dist/commands/ssh.js +93 -302
  53. package/dist/commands/sync.js +14 -5
  54. package/dist/commands/teams.js +1 -1
  55. package/dist/commands/traces.js +1 -1
  56. package/dist/lib/accounting/rotate.d.ts +63 -1
  57. package/dist/lib/accounting/rotate.js +56 -0
  58. package/dist/lib/accounts/add.d.ts +0 -5
  59. package/dist/lib/accounts/add.js +3 -9
  60. package/dist/lib/accounts/slots.js +32 -2
  61. package/dist/lib/answer-router.d.ts +11 -2
  62. package/dist/lib/answer-router.js +26 -2
  63. package/dist/lib/artifacts-client.d.ts +20 -0
  64. package/dist/lib/artifacts-client.js +46 -0
  65. package/dist/lib/auth-mint.d.ts +5 -4
  66. package/dist/lib/auth-mint.js +6 -5
  67. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  68. package/dist/lib/browser/runtime-state.d.ts +55 -0
  69. package/dist/lib/browser/runtime-state.js +99 -18
  70. package/dist/lib/browser/service.d.ts +10 -0
  71. package/dist/lib/browser/service.js +229 -37
  72. package/dist/lib/browser/types.d.ts +18 -0
  73. package/dist/lib/cli-resources.js +3 -1
  74. package/dist/lib/cloud/dispatch.js +1 -1
  75. package/dist/lib/cloudflare/creds.d.ts +10 -0
  76. package/dist/lib/cloudflare/creds.js +46 -0
  77. package/dist/lib/cloudflare/provision.d.ts +35 -0
  78. package/dist/lib/cloudflare/provision.js +144 -0
  79. package/dist/lib/computer/sessions-list.d.ts +55 -0
  80. package/dist/lib/computer/sessions-list.js +168 -1
  81. package/dist/lib/config-keys.d.ts +1 -1
  82. package/dist/lib/config-keys.js +5 -0
  83. package/dist/lib/daemon/daemon.js +8 -1
  84. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  85. package/dist/lib/daemon/feed-stream-service.js +40 -0
  86. package/dist/lib/daemon-services.d.ts +1 -1
  87. package/dist/lib/daemon-services.js +5 -0
  88. package/dist/lib/device-config.js +61 -0
  89. package/dist/lib/devices/connect.d.ts +49 -5
  90. package/dist/lib/devices/connect.js +169 -21
  91. package/dist/lib/devices/doctor-findings.js +2 -6
  92. package/dist/lib/feed/answer.d.ts +153 -4
  93. package/dist/lib/feed/answer.js +716 -105
  94. package/dist/lib/feed/envelope.d.ts +79 -0
  95. package/dist/lib/feed/envelope.js +23 -0
  96. package/dist/lib/feed/events.d.ts +6 -0
  97. package/dist/lib/feed/events.js +8 -0
  98. package/dist/lib/feed/feed.d.ts +61 -1
  99. package/dist/lib/feed/feed.js +226 -14
  100. package/dist/lib/feed/hub-server.d.ts +141 -0
  101. package/dist/lib/feed/hub-server.js +586 -0
  102. package/dist/lib/feed/hub.d.ts +95 -0
  103. package/dist/lib/feed/hub.js +255 -0
  104. package/dist/lib/feed/pr-status.d.ts +8 -0
  105. package/dist/lib/feed/pr-status.js +9 -1
  106. package/dist/lib/feed/tool-activity.d.ts +108 -0
  107. package/dist/lib/feed/tool-activity.js +313 -0
  108. package/dist/lib/feed/tools.d.ts +198 -0
  109. package/dist/lib/feed/tools.js +265 -0
  110. package/dist/lib/feed/watch.d.ts +50 -50
  111. package/dist/lib/feed/watch.js +147 -16
  112. package/dist/lib/feed-outcome.d.ts +1 -1
  113. package/dist/lib/feed-outcome.js +9 -2
  114. package/dist/lib/feed-policy.js +9 -3
  115. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  116. package/dist/lib/fleet/auth-sync.js +2 -89
  117. package/dist/lib/format.d.ts +1 -1
  118. package/dist/lib/format.js +1 -1
  119. package/dist/lib/git.d.ts +0 -16
  120. package/dist/lib/git.js +0 -58
  121. package/dist/lib/harness-auth-capabilities.js +7 -2
  122. package/dist/lib/helper-versions.js +1 -1
  123. package/dist/lib/hosts/dispatch.d.ts +20 -1
  124. package/dist/lib/hosts/dispatch.js +52 -30
  125. package/dist/lib/hosts/remote-cmd.d.ts +72 -1
  126. package/dist/lib/hosts/remote-cmd.js +151 -10
  127. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  128. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  129. package/dist/lib/mailbox.d.ts +12 -0
  130. package/dist/lib/mailbox.js +16 -2
  131. package/dist/lib/mcp.js +17 -11
  132. package/dist/lib/menubar/snapshot.d.ts +51 -0
  133. package/dist/lib/menubar/snapshot.js +42 -3
  134. package/dist/lib/open-url.js +2 -2
  135. package/dist/lib/probe.d.ts +4 -1
  136. package/dist/lib/probe.js +5 -2
  137. package/dist/lib/projects.d.ts +23 -0
  138. package/dist/lib/projects.js +78 -0
  139. package/dist/lib/pwsh.d.ts +33 -0
  140. package/dist/lib/pwsh.js +56 -0
  141. package/dist/lib/redact.d.ts +8 -0
  142. package/dist/lib/redact.js +11 -0
  143. package/dist/lib/refresh.d.ts +6 -2
  144. package/dist/lib/refresh.js +92 -72
  145. package/dist/lib/secrets-cli.d.ts +11 -0
  146. package/dist/lib/secrets-cli.js +30 -0
  147. package/dist/lib/secrets-client.js +3 -2
  148. package/dist/lib/session/active.d.ts +1 -0
  149. package/dist/lib/session/active.js +8 -0
  150. package/dist/lib/session/db.d.ts +67 -3
  151. package/dist/lib/session/db.js +381 -126
  152. package/dist/lib/session/detached.d.ts +7 -0
  153. package/dist/lib/session/detached.js +29 -0
  154. package/dist/lib/session/prompt.d.ts +23 -7
  155. package/dist/lib/session/prompt.js +46 -8
  156. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  157. package/dist/lib/session/remote/peer-stream.js +33 -6
  158. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  159. package/dist/lib/session/remote/remote-list.js +22 -6
  160. package/dist/lib/session/remote/watch.d.ts +12 -0
  161. package/dist/lib/session/remote/watch.js +9 -0
  162. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  163. package/dist/lib/session/remote-preview-cache.js +373 -0
  164. package/dist/lib/session/sync/backend.d.ts +3 -3
  165. package/dist/lib/session/sync/backend.js +3 -3
  166. package/dist/lib/session/sync/provision.d.ts +1 -1
  167. package/dist/lib/session/sync/provision.js +2 -2
  168. package/dist/lib/session/tail.d.ts +50 -0
  169. package/dist/lib/session/tail.js +219 -0
  170. package/dist/lib/sessions-client.js +0 -3
  171. package/dist/lib/setup-tool-install.d.ts +3 -0
  172. package/dist/lib/setup-tool-install.js +27 -0
  173. package/dist/lib/setup-tool-status.d.ts +22 -0
  174. package/dist/lib/setup-tool-status.js +220 -0
  175. package/dist/lib/share-runtime.d.ts +11 -0
  176. package/dist/lib/share-runtime.js +63 -0
  177. package/dist/lib/signin-badge.d.ts +19 -4
  178. package/dist/lib/signin-badge.js +29 -11
  179. package/dist/lib/smart-launch.d.ts +1 -5
  180. package/dist/lib/smart-launch.js +3 -11
  181. package/dist/lib/ssh-exec.d.ts +44 -0
  182. package/dist/lib/ssh-exec.js +119 -0
  183. package/dist/lib/startup/command-registry.d.ts +6 -4
  184. package/dist/lib/startup/command-registry.js +10 -7
  185. package/dist/lib/state.js +2 -2
  186. package/dist/lib/storage/selection.d.ts +2 -2
  187. package/dist/lib/storage/selection.js +2 -2
  188. package/dist/lib/sync-umbrella.d.ts +5 -0
  189. package/dist/lib/sync-umbrella.js +18 -10
  190. package/dist/lib/term-driver.d.ts +24 -0
  191. package/dist/lib/term-driver.js +36 -0
  192. package/dist/lib/terminal/index.d.ts +1 -1
  193. package/dist/lib/terminal/index.js +1 -1
  194. package/dist/lib/terminal/inject.d.ts +38 -0
  195. package/dist/lib/terminal/inject.js +55 -9
  196. package/dist/lib/terminal/transport.d.ts +15 -5
  197. package/dist/lib/terminal/transport.js +61 -11
  198. package/dist/lib/traces/backend.d.ts +1 -2
  199. package/dist/lib/traces/backend.js +1 -2
  200. package/dist/lib/traces/provision.d.ts +1 -1
  201. package/dist/lib/traces/provision.js +2 -2
  202. package/dist/lib/types.d.ts +8 -6
  203. package/package.json +2 -3
  204. package/dist/commands/artifacts-setup.d.ts +0 -53
  205. package/dist/commands/artifacts-setup.js +0 -161
  206. package/dist/commands/artifacts.d.ts +0 -18
  207. package/dist/commands/artifacts.js +0 -58
  208. package/dist/commands/attach.d.ts +0 -12
  209. package/dist/commands/attach.js +0 -86
  210. package/dist/commands/auth-mint.d.ts +0 -12
  211. package/dist/commands/auth-mint.js +0 -108
  212. package/dist/commands/reconnect.d.ts +0 -46
  213. package/dist/commands/reconnect.js +0 -115
  214. package/dist/commands/share.d.ts +0 -293
  215. package/dist/commands/share.js +0 -1424
  216. package/dist/lib/fleet/remote-login.d.ts +0 -170
  217. package/dist/lib/fleet/remote-login.js +0 -568
  218. package/dist/lib/share/analytics.d.ts +0 -13
  219. package/dist/lib/share/analytics.js +0 -45
  220. package/dist/lib/share/backend.d.ts +0 -120
  221. package/dist/lib/share/backend.js +0 -176
  222. package/dist/lib/share/capture.d.ts +0 -31
  223. package/dist/lib/share/capture.js +0 -174
  224. package/dist/lib/share/config.d.ts +0 -72
  225. package/dist/lib/share/config.js +0 -211
  226. package/dist/lib/share/delete.d.ts +0 -123
  227. package/dist/lib/share/delete.js +0 -173
  228. package/dist/lib/share/html.d.ts +0 -20
  229. package/dist/lib/share/html.js +0 -88
  230. package/dist/lib/share/http-error.d.ts +0 -53
  231. package/dist/lib/share/http-error.js +0 -65
  232. package/dist/lib/share/og.d.ts +0 -26
  233. package/dist/lib/share/og.js +0 -84
  234. package/dist/lib/share/provision.d.ts +0 -127
  235. package/dist/lib/share/provision.js +0 -285
  236. package/dist/lib/share/publish.d.ts +0 -379
  237. package/dist/lib/share/publish.js +0 -818
  238. package/dist/lib/share/worker-template.d.ts +0 -27
  239. package/dist/lib/share/worker-template.js +0 -2424
  240. package/dist/lib/storage/index.d.ts +0 -14
  241. package/dist/lib/storage/index.js +0 -14
  242. package/dist/lib/storage/visibility.d.ts +0 -82
  243. package/dist/lib/storage/visibility.js +0 -99
@@ -0,0 +1,373 @@
1
+ /** Bounded, single-owner preview fetch with a durable requester cache and cross-process coalescing. */
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import lockfile from 'proper-lockfile';
6
+ import { normalizeHost } from '../machine-id.js';
7
+ import { getCacheDir } from '../state.js';
8
+ import { isCompleteSessionId } from './discover.js';
9
+ import { fetchPeerPreviewEnvelope, } from './remote/remote-list.js';
10
+ import { readRemotePreviewCache, writeRemotePreviewCacheFailure, writeRemotePreviewCacheSuccess, withSessionDBTimeout, REMOTE_PREVIEW_ENVELOPE_MAX_BYTES, } from './db.js';
11
+ /** How long a successful fetch is served with zero SSH on an ordinary call. */
12
+ const REMOTE_PREVIEW_FRESH_MS = 45_000;
13
+ /** Exponential backoff after a failed fetch, capped, so a persistently
14
+ * unreachable/erroring peer is not re-dialed on every call. */
15
+ function nextAttemptBackoffMs(consecutiveFailures) {
16
+ const capped = Math.min(consecutiveFailures, 6);
17
+ return Math.min(30_000 * 2 ** (capped - 1), 30 * 60_000);
18
+ }
19
+ /**
20
+ * Overall interactive budget for a call that must actually dial the peer:
21
+ * lease-wait + SSH attempt. A caller that provides its own `timeoutMs` still
22
+ * has this outer ceiling applied, since the lease-wait time is additive to it.
23
+ */
24
+ const REMOTE_PREVIEW_TOTAL_DEADLINE_MS = 10_000;
25
+ const CACHE_BUSY_TIMEOUT_MS = 250;
26
+ /** Default SSH attempt bound for this fast path specifically — tighter than
27
+ * the general-purpose picker's `PEER_PREVIEW_TIMEOUT_MS` (15s), which is
28
+ * tuned for an interactive "peek" the user is already waiting on, not a
29
+ * budget-conscious automated caller. */
30
+ const REMOTE_PREVIEW_SSH_TIMEOUT_MS = 8_000;
31
+ function cacheKey(device, sessionId) {
32
+ return `${device}::${sessionId}`;
33
+ }
34
+ const defaultDeps = { fetchEnvelope: fetchPeerPreviewEnvelope };
35
+ export async function getRemoteSessionPreview(sessionId, device, opts = {}, deps = defaultDeps) {
36
+ if (!isCompleteSessionId(sessionId)) {
37
+ return {
38
+ cache: {
39
+ source: 'cache', fetchedAt: null, stale: false, state: 'invalid-id',
40
+ device: normalizeHost(device),
41
+ reason: 'sessions preview --device requires a full session id (bare UUID, session_<uuid>, or ses_<ulid>) for the durable-cache fast path',
42
+ },
43
+ };
44
+ }
45
+ const normalizedDevice = normalizeHost(device);
46
+ try {
47
+ return await fetchOrServe(sessionId, normalizedDevice, opts, deps);
48
+ }
49
+ catch {
50
+ return noCacheOutcome(normalizedDevice, 'The session preview cache is unavailable. Try again.');
51
+ }
52
+ }
53
+ /** Cap on a caller-supplied `--revision` value: it's an opaque cursor, never
54
+ * free text, so this is a sanity bound, not a format check. */
55
+ const REVISION_MAX_CHARS = 128;
56
+ function isValidRevision(revision) {
57
+ return revision.length > 0 && revision.length <= REVISION_MAX_CHARS;
58
+ }
59
+ async function fetchOrServe(sessionId, device, opts, deps) {
60
+ const deadlineAt = Date.now() + REMOTE_PREVIEW_TOTAL_DEADLINE_MS;
61
+ const accessCache = (operation) => withSessionDBTimeout(Math.max(0, Math.min(CACHE_BUSY_TIMEOUT_MS, deadlineAt - Date.now())), operation);
62
+ const now = opts.now ?? Date.now();
63
+ const readCache = () => {
64
+ const stored = accessCache(() => readRemotePreviewCache(device, sessionId));
65
+ return stored?.ok && !validateEnvelope(stored.envelope, sessionId, device).ok
66
+ ? { ...stored, ok: false, envelope: undefined } : stored;
67
+ };
68
+ const cached = readCache();
69
+ const recordFailure = (reason) => {
70
+ try {
71
+ accessCache(() => writeRemotePreviewCacheFailure(device, sessionId, reason, nextAttemptBackoffMs, opts.now ?? Date.now()));
72
+ }
73
+ catch { /* The response still carries the failure and any previously read content. */ }
74
+ };
75
+ const revision = opts.revision !== undefined && isValidRevision(opts.revision) ? opts.revision : undefined;
76
+ if (!opts.refresh && cached?.ok && cached.consecutiveFailures === 0) {
77
+ const revisionConfirmedUnchanged = revision !== undefined
78
+ && cached.lastCallerRevision !== undefined
79
+ && revision === cached.lastCallerRevision;
80
+ // A revision was supplied and there is no prior recorded caller revision,
81
+ // or it DIFFERS: skip the TTL bypass below (activity-driven invalidation)
82
+ // and fall through to a real attempt, still subject to backoff. No
83
+ // revision supplied at all: fall back to the plain TTL freshness window,
84
+ // unchanged from before `--revision` existed.
85
+ const age = now - cached.fetchedAt;
86
+ const withinFreshWindow = revision === undefined && age >= 0 && age < REMOTE_PREVIEW_FRESH_MS;
87
+ if (revisionConfirmedUnchanged || withinFreshWindow) {
88
+ if (validateEnvelope(cached.envelope, sessionId, device).ok)
89
+ return freshFromCache(cached, device);
90
+ }
91
+ }
92
+ if (!opts.refresh && cached && now < cached.nextAttemptAt) {
93
+ // Still inside the negative-backoff window from a recent failure: honor
94
+ // it rather than dialing again, and serve whatever the cache holds.
95
+ const reason = cached.failureReason ?? 'peer unreachable';
96
+ return cached.ok ? staleFromCache(cached, device, reason) : noCacheOutcome(device, reason);
97
+ }
98
+ // From here on we are actually about to dial the peer. This is the
99
+ // realistic duplication case (one CLI process per user action, e.g. the
100
+ // Menu worker shelling out a fresh `agents sessions preview` per click), so
101
+ // this takes a bounded, OS-visible lease keyed on (device, sessionId) —
102
+ // never an in-process-only guard, which cannot help two separate processes.
103
+ const beforeFetchedAt = cached?.fetchedAt ?? 0;
104
+ const timeoutMs = opts.timeoutMs ?? REMOTE_PREVIEW_SSH_TIMEOUT_MS;
105
+ const priorConsecutiveFailures = cached?.consecutiveFailures ?? 0;
106
+ const attempt = async (remainingMs) => {
107
+ const afterLease = readCache();
108
+ if (afterLease?.ok && afterLease.consecutiveFailures === 0 && afterLease.fetchedAt > beforeFetchedAt) {
109
+ // Another process completed a SUCCESSFUL fetch for this exact
110
+ // (device, id) while we waited for the lease — reuse it. True under
111
+ // --refresh too: refresh means "current data now", and data fetched a
112
+ // moment ago while we queued already IS current.
113
+ return freshFromCache(afterLease, device);
114
+ }
115
+ if (!opts.refresh && afterLease && now < afterLease.nextAttemptAt) {
116
+ // Another process just recorded a FAILURE (and its backoff) while we
117
+ // waited: honor it rather than firing a second attempt right behind it.
118
+ return afterLease.ok
119
+ ? staleFromCache(afterLease, device, afterLease.failureReason ?? 'peer unreachable')
120
+ : noCacheOutcome(device, afterLease.failureReason ?? 'peer unreachable');
121
+ }
122
+ if (opts.refresh && afterLease
123
+ && afterLease.consecutiveFailures > priorConsecutiveFailures) {
124
+ // Even under --refresh (which otherwise ignores backoff): another
125
+ // process's OWN refresh attempt just failed while we waited for the
126
+ // lease. Reuse that failure rather than immediately trying a third
127
+ // time — "explicit retry" still means at most one attempt per genuinely
128
+ // concurrent request, not one per caller.
129
+ const reason = afterLease.failureReason ?? 'peer unreachable';
130
+ return afterLease.ok ? staleFromCache(afterLease, device, reason) : noCacheOutcome(device, reason);
131
+ }
132
+ const fetchBudget = Math.min(timeoutMs, remainingMs, deadlineAt - Date.now() - CACHE_BUSY_TIMEOUT_MS - 100);
133
+ if (fetchBudget <= 0)
134
+ return cached?.ok
135
+ ? staleFromCache(cached, device, 'another request for this session was already in flight')
136
+ : noCacheOutcome(device, 'another request for this session was already in flight');
137
+ const result = await deps.fetchEnvelope(sessionId, device, fetchBudget);
138
+ if (!result.ok) {
139
+ const reason = describeFailure(result);
140
+ recordFailure(reason);
141
+ if (cached?.ok)
142
+ return staleFromCache(cached, device, reason);
143
+ return noCacheOutcome(device, reason);
144
+ }
145
+ const validation = validateEnvelope(result.envelope, sessionId, device);
146
+ if (!validation.ok) {
147
+ // A peer answered, but the payload doesn't check out (wrong session id,
148
+ // wrong schema version, malformed shape — a version-skewed or
149
+ // misbehaving peer). Never cache it under this id/device key, and never
150
+ // pass it through as if it were a genuine success.
151
+ recordFailure(validation.reason);
152
+ if (cached?.ok)
153
+ return staleFromCache(cached, device, validation.reason);
154
+ return noCacheOutcome(device, validation.reason);
155
+ }
156
+ const envelopeBytes = Buffer.byteLength(JSON.stringify(result.envelope), 'utf8');
157
+ if (envelopeBytes > REMOTE_PREVIEW_ENVELOPE_MAX_BYTES) {
158
+ // The live fetch genuinely succeeded, but the payload is too large to
159
+ // persist AND too large to hand back unbounded. Never silently pass an
160
+ // oversized blob through as "fresh" — degrade to the last-good stale
161
+ // copy (explicitly labeled) when one exists, else an explicit bounded
162
+ // error, and do not cache the oversized response either way.
163
+ const reason = `peer response was ${envelopeBytes} bytes, over the ${REMOTE_PREVIEW_ENVELOPE_MAX_BYTES}-byte bounded-cache limit`;
164
+ recordFailure(reason);
165
+ if (cached?.ok)
166
+ return staleFromCache(cached, device, reason);
167
+ return noCacheOutcome(device, reason);
168
+ }
169
+ const fetchedAt = Math.max(opts.now ?? Date.now(), (afterLease?.fetchedAt ?? 0) + 1);
170
+ let persistenceReason = null;
171
+ try {
172
+ accessCache(() => writeRemotePreviewCacheSuccess(device, sessionId, result.envelope, fetchedAt, revision));
173
+ }
174
+ catch {
175
+ persistenceReason = 'Session details loaded, but could not be saved for offline use.';
176
+ }
177
+ return {
178
+ envelope: result.envelope,
179
+ cache: { source: 'live', fetchedAt, stale: false, state: 'fresh', device, reason: persistenceReason },
180
+ };
181
+ };
182
+ try {
183
+ return await withBoundedRemoteLease(device, sessionId, timeoutMs, deadlineAt, attempt, () => {
184
+ // Could not acquire the lease within the interactive budget (another
185
+ // process is holding it, presumably mid-fetch, longer than our deadline).
186
+ // Degrade to whatever the cache holds rather than piling on a second SSH
187
+ // attempt — bounded wait, no serial redial, no unbounded queueing.
188
+ const current = cached;
189
+ if (current?.ok)
190
+ return staleFromCache(current, device, 'another request for this session was already in flight');
191
+ return noCacheOutcome(device, 'another request for this session was already in flight and did not finish in time');
192
+ });
193
+ }
194
+ catch {
195
+ const reason = 'The session preview cache is unavailable. Try again.';
196
+ return cached?.ok ? staleFromCache(cached, device, reason) : noCacheOutcome(device, reason);
197
+ }
198
+ }
199
+ /**
200
+ * Validate a peer's response before it is trusted at all: schema-version 1,
201
+ * a `session` object present, its `id` an EXACT non-empty string match for
202
+ * the id we asked for (never missing/null/numeric/mismatched), and — when the
203
+ * peer names its own machine — that name must agree with the device we
204
+ * actually dialed. Without these checks a version-skewed, misbehaving, or
205
+ * misconfigured peer's response could be cached under the WRONG key (this
206
+ * caller's requested sessionId) or attributed to the wrong owning device,
207
+ * poisoning a later read for a completely different session/device pair.
208
+ */
209
+ function validateEnvelope(envelope, sessionId, device) {
210
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
211
+ const text = (value) => typeof value === 'string' && value.trim().length > 0;
212
+ const string = (value) => typeof value === 'string';
213
+ const number = (value) => typeof value === 'number' && Number.isFinite(value);
214
+ const integer = (value) => Number.isSafeInteger(value);
215
+ const boolean = (value) => typeof value === 'boolean';
216
+ const shape = (fields) => value => object(value)
217
+ && Object.entries(fields).every(([key, check]) => value[key] == null || check(value[key]));
218
+ const array = (check) => value => Array.isArray(value) && value.every(check);
219
+ const artifact = shape({ path: string, basename: string, bucket: string });
220
+ const request = shape({ kind: string, text: string, headline: string, turns: integer });
221
+ const step = shape({ text: string, at: string, source: string, live: boolean, now: string,
222
+ mix: value => object(value) && Object.values(value).every(integer) });
223
+ const timeline = shape({ tools: integer, failed: integer, blocked: integer, spanMs: number,
224
+ state: string, reason: string, steps: array(step), earlier: shape({ steps: integer, tools: integer, failed: integer }) });
225
+ const files = shape({ total: integer, source: string,
226
+ changes: array(shape({ path: string, op: string, edits: integer, at: string })) });
227
+ const preview = shape({ firstUser: string, lastAssistant: string, artifacts: array(artifact) });
228
+ const details = shape({ sourceRevision: string, reason: string, partial: boolean, request, timeline, files,
229
+ messages: array(value => object(value) && typeof value.role === 'string' && ['user', 'assistant'].includes(value.role)
230
+ && string(value.text) && (value.at == null || string(value.at))) });
231
+ if (!object(envelope))
232
+ return { ok: false, reason: 'The device returned a preview that was not a JSON object.' };
233
+ if (envelope.schemaVersion !== 1)
234
+ return { ok: false, reason: 'The device returned an unsupported preview schema.' };
235
+ if (!object(envelope.session) || envelope.session.id !== sessionId)
236
+ return { ok: false, reason: 'The device returned a preview for a different or missing session ID.' };
237
+ if (!shape({ agent: string, machine: string, project: string, cwd: string, title: string,
238
+ lastActivity: string, lastActivityMs: number })(envelope.session))
239
+ return { ok: false, reason: 'The device returned malformed session metadata.' };
240
+ if (envelope.cache != null && !object(envelope.cache))
241
+ return { ok: false, reason: 'The device returned malformed cache metadata.' };
242
+ const owners = [envelope.session.machine, object(envelope.cache) ? envelope.cache.device : undefined]
243
+ .filter(value => value !== undefined);
244
+ if (owners.length === 0 || owners.some(owner => typeof owner !== 'string' || !owner.trim() || normalizeHost(owner) !== device))
245
+ return { ok: false, reason: 'The device returned a preview without a matching owner.' };
246
+ if ((envelope.preview != null && !preview(envelope.preview))
247
+ || (envelope.details != null && !details(envelope.details))
248
+ || (envelope.active != null && !shape({ status: string, lastActivityMs: number })(envelope.active))
249
+ || (envelope.error != null && !string(envelope.error)))
250
+ return { ok: false, reason: 'The device returned malformed session details.' };
251
+ const digest = object(envelope.preview) ? envelope.preview : {};
252
+ const detail = object(envelope.details) ? envelope.details : {};
253
+ const hasContent = text(digest.firstUser) || text(digest.lastAssistant)
254
+ || (Array.isArray(detail.messages) && detail.messages.length > 0)
255
+ || (object(detail.request) && text(detail.request.text))
256
+ || (object(detail.timeline) && Array.isArray(detail.timeline.steps) && detail.timeline.steps.length > 0)
257
+ || (object(detail.files) && Array.isArray(detail.files.changes) && detail.files.changes.length > 0)
258
+ || (Array.isArray(digest.artifacts) && digest.artifacts.length > 0);
259
+ if (!hasContent)
260
+ return { ok: false, reason: 'The device has no recorded session details available yet.' };
261
+ return { ok: true };
262
+ }
263
+ /** Cross-process lock target for one (device, sessionId) pair. Deliberately a
264
+ * separate lock namespace from `refresh-coordinator.ts` — different SLA,
265
+ * different consumers, no reason to share contention. */
266
+ function leaseTarget(device, sessionId) {
267
+ const digest = createHash('sha256').update(`${device}::${sessionId}`).digest('hex');
268
+ return path.join(getCacheDir(), 'remote-preview-locks', `${digest}.lock`);
269
+ }
270
+ /** Lock wait, transport, and cache writes share one deadline; no pending retry outlives the call. */
271
+ async function withBoundedRemoteLease(device, sessionId, fetchTimeoutMs, deadlineAt, fn, onDeadline) {
272
+ const target = leaseTarget(device, sessionId);
273
+ await fs.mkdir(path.dirname(target), { recursive: true });
274
+ try {
275
+ await fs.writeFile(target, '', { flag: 'wx', mode: 0o600 });
276
+ }
277
+ catch (error) {
278
+ if (error?.code !== 'EEXIST')
279
+ throw error;
280
+ }
281
+ // Stale slightly above the fetch's own bound: a genuinely dead holder
282
+ // (crashed mid-fetch) is reclaimed soon after its own attempt would have
283
+ // timed out anyway; a live holder's lock never goes stale mid-fetch.
284
+ const staleMs = fetchTimeoutMs + 2_000;
285
+ let release;
286
+ while (!release && Date.now() < deadlineAt) {
287
+ try {
288
+ release = await lockfile.lock(target, {
289
+ realpath: false, stale: staleMs, update: staleMs / 4, retries: 0,
290
+ });
291
+ }
292
+ catch (error) {
293
+ if (error?.code !== 'ELOCKED')
294
+ throw error;
295
+ const remaining = deadlineAt - Date.now();
296
+ if (remaining > 0)
297
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, remaining)));
298
+ }
299
+ }
300
+ if (!release)
301
+ return onDeadline();
302
+ const remainingMs = deadlineAt - Date.now();
303
+ if (remainingMs <= 0) {
304
+ // Acquired right at the wire: no budget left to do any real work with it.
305
+ if (release) {
306
+ await release();
307
+ await cleanupLeaseFile(target);
308
+ }
309
+ return onDeadline();
310
+ }
311
+ try {
312
+ return await fn(remainingMs);
313
+ }
314
+ finally {
315
+ if (release) {
316
+ await release();
317
+ await cleanupLeaseFile(target);
318
+ }
319
+ }
320
+ }
321
+ /** Best-effort removal of the lease marker file after release, so the
322
+ * lock directory doesn't grow one file per (device, sessionId) pair this box
323
+ * has ever previewed. Self-healing either way: `withBoundedRemoteLease`
324
+ * recreates it on demand (`{ flag: 'wx' }` — a no-op if it's still there). */
325
+ async function cleanupLeaseFile(target) {
326
+ try {
327
+ await fs.unlink(target);
328
+ }
329
+ catch {
330
+ // Another concurrent caller may already be using it, or it's already
331
+ // gone — either way, nothing to do.
332
+ }
333
+ }
334
+ function describeFailure(result) {
335
+ switch (result.reason) {
336
+ case 'no-target': return 'device is not a registered, dialable peer';
337
+ case 'unreachable': return 'device did not answer within the bounded window';
338
+ case 'invalid-json': return 'device returned an unparsable response (version skew?)';
339
+ default: return 'unknown failure';
340
+ }
341
+ }
342
+ function freshFromCache(cached, device) {
343
+ return {
344
+ envelope: cached.envelope,
345
+ cache: { source: 'cache', fetchedAt: cached.fetchedAt, stale: false, state: 'fresh', device, reason: null },
346
+ };
347
+ }
348
+ function staleFromCache(cached, device, reason) {
349
+ const offline = reason === 'device did not answer within the bounded window' || reason === 'backoff'
350
+ || reason === 'another request for this session was already in flight';
351
+ return {
352
+ envelope: cached.envelope,
353
+ cache: {
354
+ source: 'cache',
355
+ fetchedAt: cached.fetchedAt,
356
+ stale: true,
357
+ state: offline ? 'stale-offline' : 'stale-error',
358
+ device,
359
+ reason,
360
+ },
361
+ };
362
+ }
363
+ function noCacheOutcome(device, reason) {
364
+ const offline = reason.includes('unreachable') || reason.includes('not answer') || reason === 'backoff'
365
+ || reason.includes('already in flight');
366
+ return {
367
+ cache: {
368
+ source: 'cache', fetchedAt: null, stale: false,
369
+ state: offline ? 'no-cache-offline' : 'no-cache-error',
370
+ device, reason,
371
+ },
372
+ };
373
+ }
@@ -18,9 +18,9 @@
18
18
  * MANAGED-FIRST — the mere PRESENCE of an r2.backups bundle is NOT a BYO
19
19
  * override. A signed-in user with a stale r2.backups bundle still backs up to
20
20
  * managed unless they opt out explicitly: `--byo`, `AGENTS_SESSIONS_BACKEND=byo`,
21
- * or a DI write token. This mirrors `lib/share/backend.ts` (a persisted BYO
22
- * config is deliberately not an override) so the product's managed-first
23
- * contract is identical across surfaces.
21
+ * or a DI write token. This mirrors the artifact-share backend contract (a
22
+ * persisted BYO config is deliberately not an override) so the product's
23
+ * managed-first contract is identical across surfaces.
24
24
  */
25
25
  import { type PhoenixSession } from '../../identity/client.js';
26
26
  import { type R2Config } from './config.js';
@@ -18,9 +18,9 @@
18
18
  * MANAGED-FIRST — the mere PRESENCE of an r2.backups bundle is NOT a BYO
19
19
  * override. A signed-in user with a stale r2.backups bundle still backs up to
20
20
  * managed unless they opt out explicitly: `--byo`, `AGENTS_SESSIONS_BACKEND=byo`,
21
- * or a DI write token. This mirrors `lib/share/backend.ts` (a persisted BYO
22
- * config is deliberately not an override) so the product's managed-first
23
- * contract is identical across surfaces.
21
+ * or a DI write token. This mirrors the artifact-share backend contract (a
22
+ * persisted BYO config is deliberately not an override) so the product's
23
+ * managed-first contract is identical across surfaces.
24
24
  */
25
25
  import { readSession } from '../../identity/client.js';
26
26
  import { selectStorageBackendKind } from '../../storage/selection.js';
@@ -1,4 +1,4 @@
1
- import { type CloudflareRequester } from '../../share/provision.js';
1
+ import { type CloudflareRequester } from '../../cloudflare/provision.js';
2
2
  interface ProvisionOptions {
3
3
  request?: CloudflareRequester;
4
4
  }
@@ -1,11 +1,11 @@
1
1
  // Cloudflare orchestration for the managed session-backup store. The generic
2
- // request primitives live in share/provision; only the isolated sessions
2
+ // request primitives live in cloudflare/provision; only the isolated sessions
3
3
  // resource choices and Worker template belong here. Mirrors lib/traces/provision.ts.
4
4
  //
5
5
  // This is the OPERATOR provisioning path — cutting the `agents-sessions` Worker +
6
6
  // bucket + custom domain once. Product traffic never runs it; a signed-in user
7
7
  // talks to the already-deployed Worker through `SessionsHttpClient`.
8
- import { addCustomDomain, createBucket, deployWorker, enableWorkersDev, findZoneId, putWorkerSecret, } from '../../share/provision.js';
8
+ import { addCustomDomain, createBucket, deployWorker, enableWorkersDev, findZoneId, putWorkerSecret, } from '../../cloudflare/provision.js';
9
9
  import { DEFAULT_SESSIONS_BUCKET_NAME, DEFAULT_SESSIONS_DOMAIN, DEFAULT_SESSIONS_WORKER_NAME, } from './managed-config.js';
10
10
  import { renderSessionsWorkerScript } from './worker-template.js';
11
11
  /** Bind the Phoenix identity base URL used to verify every session read and write. */
@@ -35,4 +35,54 @@ export declare function readSessionTailWithRaw(filePath: string, agent: SessionA
35
35
  * wrapper over {@link readSessionTailWithRaw} for callers that only need events.
36
36
  */
37
37
  export declare function readSessionTail(filePath: string, agent: SessionAgentId, maxBytes?: number, maxEvents?: number): SessionEvent[];
38
+ /**
39
+ * Read the FIRST `maxBytes` of a JSONL transcript as cleaned text — the mirror
40
+ * of {@link readSessionTailContent}, for recovering the session's ACTUAL
41
+ * original request boundedly when it isn't already indexed
42
+ * (`SessionMeta.firstUserMessage`). Returns '' on any error, an empty file, or
43
+ * a first record too large to fit `maxBytes` (bare — no elision fallback; see
44
+ * {@link readSessionHeadContentBounded} for that).
45
+ */
46
+ export declare function readSessionHeadContent(filePath: string, maxBytes?: number): string;
47
+ /**
48
+ * Recover the transcript's opening JSONL record even when it doesn't fit the
49
+ * plain {@link DEFAULT_HEAD_MAX_BYTES} chunk — the case a first turn carrying
50
+ * a multi-megabyte inline image produces, where the record's own closing
51
+ * newline sits past whatever small chunk was read, so the old bare head
52
+ * reader dropped the WHOLE record (and with it, the real original request)
53
+ * rather than the oversized value inside it.
54
+ *
55
+ * Only invoked when the cheap path already failed to find a complete line
56
+ * (bounded — see {@link readSessionHeadChunk}'s `sawEof`), so an ordinary
57
+ * transcript never pays this cost. Reads up to {@link HEAD_ELISION_MAX_READ_BYTES}
58
+ * from byte 0, runs it through {@link elideOversizedJsonStrings} (bounded by
59
+ * {@link HEAD_ELISION_MAX_MS}), and returns the first complete elided line —
60
+ * text before and after the oversized value is preserved verbatim (only the
61
+ * oversized value itself is shortened), UTF-8 decoding and escape handling
62
+ * both go through the exact same path an unelided record would. Returns ''
63
+ * (a partial result, not a guess) when the scan is cut off by either budget
64
+ * before a complete line was ever produced — never a followup/tail turn
65
+ * substituted for it.
66
+ */
67
+ export declare function readSessionHeadContentBounded(filePath: string, maxReadBytes?: number): string;
68
+ /**
69
+ * Read the FIRST `maxEvents` normalized events from a JSONL transcript's
70
+ * head — the session's actual opening turns, bounded and cheap (one small
71
+ * read from byte 0, the same shared Claude/Codex content parsers `readSessionTail`
72
+ * uses, zero duplicated parse logic). Only Claude and Codex are supported,
73
+ * matching {@link readSessionTailWithRaw}; other agents return no events.
74
+ *
75
+ * When the plain bounded chunk holds no complete first line at all — an
76
+ * opening user turn embedding a multi-megabyte inline image is the real case
77
+ * this covers — falls back to {@link readSessionHeadContentBounded}'s
78
+ * JSON-aware elision scan rather than returning nothing: the same content
79
+ * parsers then see a syntactically valid record with the oversized value
80
+ * shortened, so a text block before or after an elided image block is
81
+ * recovered exactly as if the image had never been oversized (the parser's
82
+ * own image-handling path, e.g. Claude's `normalizedAttachmentEvent`, still
83
+ * runs — it just sees a short placeholder `source.data` instead of the real
84
+ * payload, so an attachment's derived byte size is not meaningful when this
85
+ * fallback fired, only the surrounding TEXT is trustworthy).
86
+ */
87
+ export declare function readSessionHead(filePath: string, agent: SessionAgentId, maxBytes?: number, maxEvents?: number): SessionEvent[];
38
88
  export {};