@phnx-labs/agents-cli 1.22.117 → 1.22.118

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 (248) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/browser.d.ts +52 -70
  4. package/dist/commands/browser.js +192 -3562
  5. package/dist/commands/daemon.d.ts +39 -2
  6. package/dist/commands/daemon.js +72 -73
  7. package/dist/commands/events.js +1 -1
  8. package/dist/commands/exec.js +53 -1
  9. package/dist/commands/repo.js +13 -49
  10. package/dist/commands/route.js +5 -5
  11. package/dist/commands/routines.js +39 -12
  12. package/dist/commands/sessions-render.d.ts +1 -1
  13. package/dist/commands/sessions-render.js +0 -1
  14. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  15. package/dist/commands/sessions.test-fixture.js +0 -37
  16. package/dist/commands/setup-browser.d.ts +17 -12
  17. package/dist/commands/setup-browser.js +52 -87
  18. package/dist/commands/setup-preferences.d.ts +0 -17
  19. package/dist/commands/setup-preferences.js +18 -49
  20. package/dist/commands/setup.js +9 -9
  21. package/dist/commands/ssh.js +10 -1
  22. package/dist/commands/sync.js +6 -44
  23. package/dist/commands/teams.js +0 -1
  24. package/dist/commands/update.d.ts +19 -0
  25. package/dist/commands/update.js +34 -6
  26. package/dist/commands/view.d.ts +12 -1
  27. package/dist/commands/view.js +47 -2
  28. package/dist/index.js +107 -26
  29. package/dist/lib/account-capabilities.js +0 -2
  30. package/dist/lib/account-catalog.d.ts +38 -0
  31. package/dist/lib/account-catalog.js +110 -26
  32. package/dist/lib/account-provider-registry.js +1 -1
  33. package/dist/lib/account-registry.d.ts +1 -1
  34. package/dist/lib/account-registry.js +36 -2
  35. package/dist/lib/accounting/rotate.js +22 -8
  36. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  37. package/dist/lib/accounting/usage-ingest.js +115 -45
  38. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  39. package/dist/lib/accounting/usage-sync.js +227 -66
  40. package/dist/lib/accounting/usage.d.ts +16 -3
  41. package/dist/lib/accounting/usage.js +26 -8
  42. package/dist/lib/accounts/slots.d.ts +8 -0
  43. package/dist/lib/accounts/slots.js +17 -0
  44. package/dist/lib/add-dir.js +0 -1
  45. package/dist/lib/agent-cli-commands.js +0 -1
  46. package/dist/lib/agent-spec/agents.d.ts +6 -7
  47. package/dist/lib/agent-spec/agents.js +6 -60
  48. package/dist/lib/auth-health.d.ts +69 -1
  49. package/dist/lib/auth-health.js +133 -4
  50. package/dist/lib/browser/context.d.ts +82 -0
  51. package/dist/lib/browser/context.js +88 -0
  52. package/dist/lib/browser/paths.d.ts +25 -0
  53. package/dist/lib/browser/paths.js +51 -0
  54. package/dist/lib/browser/record.d.ts +38 -0
  55. package/dist/lib/browser/record.js +57 -0
  56. package/dist/lib/browser/sessions-list.js +1 -2
  57. package/dist/lib/browser-client.d.ts +158 -0
  58. package/dist/lib/browser-client.js +234 -0
  59. package/dist/lib/channels/owner-sink.d.ts +1 -18
  60. package/dist/lib/channels/owner-sink.js +29 -82
  61. package/dist/lib/channels/providers/rush.d.ts +7 -7
  62. package/dist/lib/channels/providers/rush.js +127 -57
  63. package/dist/lib/claude-statusline.d.ts +1 -1
  64. package/dist/lib/claude-statusline.js +15 -3
  65. package/dist/lib/commands.js +1 -1
  66. package/dist/lib/crabbox/lease.js +1 -1
  67. package/dist/lib/crabbox/runtimes.js +1 -3
  68. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  69. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  70. package/dist/lib/daemon/auth-sync-service.js +37 -43
  71. package/dist/lib/daemon/daemon.d.ts +25 -14
  72. package/dist/lib/daemon/daemon.js +92 -154
  73. package/dist/lib/daemon/runner.d.ts +2 -2
  74. package/dist/lib/daemon/runner.js +32 -9
  75. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  76. package/dist/lib/daemon/self-update-service.js +40 -36
  77. package/dist/lib/daemon/service.d.ts +12 -8
  78. package/dist/lib/daemon/supervisor.d.ts +65 -54
  79. package/dist/lib/daemon/supervisor.js +136 -171
  80. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  81. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  82. package/dist/lib/daemon/usage-sync-service.js +46 -78
  83. package/dist/lib/daemon-health.d.ts +22 -5
  84. package/dist/lib/daemon-health.js +47 -3
  85. package/dist/lib/daemon-services.d.ts +1 -1
  86. package/dist/lib/daemon-services.js +0 -10
  87. package/dist/lib/daemon-ticks.js +4 -2
  88. package/dist/lib/devices/doctor-findings.js +8 -11
  89. package/dist/lib/devices/harness-inventory.js +14 -5
  90. package/dist/lib/exec-account-home.js +5 -0
  91. package/dist/lib/exec.js +0 -11
  92. package/dist/lib/feed/events.js +0 -1
  93. package/dist/lib/feed/tool-activity.js +5 -2
  94. package/dist/lib/fleet-shared-state.d.ts +45 -0
  95. package/dist/lib/fleet-shared-state.js +78 -9
  96. package/dist/lib/fs-atomic.js +25 -0
  97. package/dist/lib/harness-auth-capabilities.js +0 -1
  98. package/dist/lib/helper-versions.js +1 -1
  99. package/dist/lib/hooks/cache.d.ts +7 -0
  100. package/dist/lib/hooks/cache.js +27 -2
  101. package/dist/lib/hooks/install.js +7 -1
  102. package/dist/lib/hosts/ready.d.ts +38 -8
  103. package/dist/lib/hosts/ready.js +62 -14
  104. package/dist/lib/installations/active-check.d.ts +27 -0
  105. package/dist/lib/installations/active-check.js +61 -0
  106. package/dist/lib/installations/migrate.js +6 -3
  107. package/dist/lib/installations/shims.js +9 -7
  108. package/dist/lib/installations/update.js +21 -15
  109. package/dist/lib/installations/versions.js +12 -1
  110. package/dist/lib/manifest.js +1 -1
  111. package/dist/lib/models.js +0 -2
  112. package/dist/lib/open-url.js +28 -54
  113. package/dist/lib/profiles.js +0 -2
  114. package/dist/lib/refresh.js +1 -1
  115. package/dist/lib/routine-readiness.d.ts +35 -0
  116. package/dist/lib/routine-readiness.js +74 -16
  117. package/dist/lib/rules/compile.js +6 -0
  118. package/dist/lib/sandbox.js +0 -2
  119. package/dist/lib/secrets-policy.d.ts +89 -11
  120. package/dist/lib/secrets-policy.js +175 -28
  121. package/dist/lib/session/db.d.ts +1 -1
  122. package/dist/lib/session/db.js +65 -18
  123. package/dist/lib/session/discover.js +1 -283
  124. package/dist/lib/session/mirror.d.ts +4 -3
  125. package/dist/lib/session/mirror.js +4 -3
  126. package/dist/lib/session/parse.d.ts +0 -2
  127. package/dist/lib/session/parse.js +1 -138
  128. package/dist/lib/session/throughput.d.ts +1 -3
  129. package/dist/lib/session/throughput.js +0 -20
  130. package/dist/lib/session/types.d.ts +2 -2
  131. package/dist/lib/session/types.js +2 -2
  132. package/dist/lib/sessions-client.d.ts +60 -1
  133. package/dist/lib/sessions-client.js +227 -4
  134. package/dist/lib/setup-tool-status.js +5 -10
  135. package/dist/lib/signin-badge.d.ts +3 -2
  136. package/dist/lib/signin-badge.js +6 -3
  137. package/dist/lib/smart-launch.js +4 -1
  138. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  139. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  140. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  141. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  142. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  143. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  144. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  145. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  146. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  147. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  148. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  149. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  150. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  151. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  152. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  153. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  154. package/dist/lib/staleness/writers/sources.js +5 -0
  155. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  156. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  157. package/dist/lib/state.d.ts +6 -4
  158. package/dist/lib/state.js +7 -17
  159. package/dist/lib/teams/parsers.d.ts +1 -1
  160. package/dist/lib/teams/parsers.js +1 -136
  161. package/dist/lib/teams/placement-probe.js +50 -15
  162. package/dist/lib/teams/scheduler.d.ts +7 -0
  163. package/dist/lib/types.d.ts +10 -1
  164. package/dist/lib/types.js +1 -1
  165. package/dist/lib/view-types.d.ts +32 -0
  166. package/dist/lib/watchdog/read.js +0 -1
  167. package/dist/lib/watchdog/rotate.js +1 -2
  168. package/dist/lib/watchdog/runner.js +2 -2
  169. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  170. package/dist/lib/watchdog/watchdogTail.js +0 -31
  171. package/package.json +1 -1
  172. package/scripts/postinstall.js +8 -8
  173. package/dist/browser.d.ts +0 -2
  174. package/dist/browser.js +0 -17
  175. package/dist/commands/browser-picker.d.ts +0 -18
  176. package/dist/commands/browser-picker.js +0 -97
  177. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  178. package/dist/lib/browser/arc-discovery.js +0 -197
  179. package/dist/lib/browser/arc-dom.d.ts +0 -14
  180. package/dist/lib/browser/arc-dom.js +0 -121
  181. package/dist/lib/browser/caller-identity.d.ts +0 -40
  182. package/dist/lib/browser/caller-identity.js +0 -175
  183. package/dist/lib/browser/cdp.d.ts +0 -51
  184. package/dist/lib/browser/cdp.js +0 -254
  185. package/dist/lib/browser/chrome.d.ts +0 -141
  186. package/dist/lib/browser/chrome.js +0 -704
  187. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  188. package/dist/lib/browser/chromium-discovery.js +0 -96
  189. package/dist/lib/browser/devices.d.ts +0 -34
  190. package/dist/lib/browser/devices.js +0 -61
  191. package/dist/lib/browser/domain-skills.d.ts +0 -71
  192. package/dist/lib/browser/domain-skills.js +0 -195
  193. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  194. package/dist/lib/browser/drivers/arc.js +0 -276
  195. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  196. package/dist/lib/browser/drivers/firefox.js +0 -377
  197. package/dist/lib/browser/drivers/local.d.ts +0 -55
  198. package/dist/lib/browser/drivers/local.js +0 -266
  199. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  200. package/dist/lib/browser/drivers/ssh.js +0 -467
  201. package/dist/lib/browser/editor.d.ts +0 -3
  202. package/dist/lib/browser/editor.js +0 -50
  203. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  204. package/dist/lib/browser/ffmpeg.js +0 -184
  205. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  206. package/dist/lib/browser/firefox-discovery.js +0 -162
  207. package/dist/lib/browser/har.d.ts +0 -85
  208. package/dist/lib/browser/har.js +0 -77
  209. package/dist/lib/browser/hygiene.d.ts +0 -97
  210. package/dist/lib/browser/hygiene.js +0 -153
  211. package/dist/lib/browser/index.d.ts +0 -5
  212. package/dist/lib/browser/index.js +0 -5
  213. package/dist/lib/browser/input.d.ts +0 -7
  214. package/dist/lib/browser/input.js +0 -92
  215. package/dist/lib/browser/ipc.d.ts +0 -204
  216. package/dist/lib/browser/ipc.js +0 -1453
  217. package/dist/lib/browser/login-detection.d.ts +0 -87
  218. package/dist/lib/browser/login-detection.js +0 -274
  219. package/dist/lib/browser/profiles.d.ts +0 -422
  220. package/dist/lib/browser/profiles.js +0 -1157
  221. package/dist/lib/browser/refs.d.ts +0 -89
  222. package/dist/lib/browser/refs.js +0 -191
  223. package/dist/lib/browser/registry.d.ts +0 -70
  224. package/dist/lib/browser/registry.js +0 -252
  225. package/dist/lib/browser/remote-control.d.ts +0 -59
  226. package/dist/lib/browser/remote-control.js +0 -85
  227. package/dist/lib/browser/resolve-target.d.ts +0 -66
  228. package/dist/lib/browser/resolve-target.js +0 -264
  229. package/dist/lib/browser/runtime-state.d.ts +0 -291
  230. package/dist/lib/browser/runtime-state.js +0 -584
  231. package/dist/lib/browser/secret-ref.d.ts +0 -10
  232. package/dist/lib/browser/secret-ref.js +0 -14
  233. package/dist/lib/browser/service.d.ts +0 -812
  234. package/dist/lib/browser/service.js +0 -4693
  235. package/dist/lib/browser/stream.d.ts +0 -17
  236. package/dist/lib/browser/stream.js +0 -72
  237. package/dist/lib/browser/task-index.d.ts +0 -78
  238. package/dist/lib/browser/task-index.js +0 -199
  239. package/dist/lib/browser/types.d.ts +0 -647
  240. package/dist/lib/browser/types.js +0 -61
  241. package/dist/lib/browser/upload.d.ts +0 -29
  242. package/dist/lib/browser/upload.js +0 -298
  243. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  244. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  245. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  246. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  247. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  248. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -18,7 +18,7 @@ import { isHostPinned, isDevicePinned, managedKnownHostsPath } from './devices/k
18
18
  import { machineId, normalizeHost } from './session/sync/config.js';
19
19
  import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from './fleet-shared-state.js';
20
20
  import { getCacheDir, getUserAgentsDir, readMeta } from './state.js';
21
- import { listNativeAccounts, readSlots } from './account-registry.js';
21
+ import { dropSlots, listNativeAccounts, readSlots } from './account-registry.js';
22
22
  import { claudeAccountTokenKey, isClaudeWorkerHomeSeeded, provisionWorkerSlot, readReservedCredential } from './claude-account-token.js';
23
23
  import { configuredDeviceRole, isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
24
24
  import { AUTH_STORE_ALIAS, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, } from './reserved-stores.js';
@@ -245,6 +245,26 @@ export async function syncReservedAuthBundle(deps = {}) {
245
245
  // The functions below fix both. The plan is per ACCOUNT and per KEY
246
246
  // (`<ENV>_<accountId>`), not per bundle, and targets `role=worker` peers only.
247
247
  //
248
+ // PRESENCE IS FIRST-HAND + ROTATION-AWARE (PHNX-4116). Each device's account-
249
+ // state daemon publishes a per-account verdict row into `accounts.rows`
250
+ // (`account-state-daemon-service.ts`), and the usage-sync SSH exchange stores
251
+ // each peer's reply at `devices/<peer>/daemon-state.json`. A key counts as
252
+ // present on a peer only when BOTH hold:
253
+ // 1. FIRST-HAND — the peer's own reply reports a NON-`missing` verdict for the
254
+ // account (its slot materialized). This is what a publisher-side memo alone
255
+ // could never see: a key removed on the worker out of band flips the next
256
+ // verdict to `missing`, so the push resumes within a tick.
257
+ // 2. ROTATION — the fingerprint (`workerCredential.mintedAt`) we last delivered
258
+ // to that peer matches the account's CURRENT fingerprint. A re-mint
259
+ // (`accounts login`) rotates the reserved key and bumps `mintedAt` while the
260
+ // peer's OLD token keeps authenticating, so its verdict stays non-`missing`;
261
+ // verdict alone would read `present` forever and the rotated key would never
262
+ // reach the worker. The delivery memo is a LOCAL rotation cursor keyed per
263
+ // (peer, bundle, key) → fingerprint, never synced, never the primary signal.
264
+ // A peer reply with no `accounts.rows` field at all (an older CLI) is not proof
265
+ // of an empty inventory, so the plan FAILS CLOSED and skips it (INFO), rather
266
+ // than treating "no rows" as "holds nothing" and pushing every key blindly.
267
+ //
248
268
  // INVARIANT 1 (transport, retain nothing): materializing a worker slot happens
249
269
  // on the box where the key LANDED (`reconcileLocalWorkerSlots` ->
250
270
  // `provisionWorkerSlot`); the credential itself only ever moves over the
@@ -254,10 +274,34 @@ export async function syncReservedAuthBundle(deps = {}) {
254
274
  // `isCredentialSafeToPropagate` stays false).
255
275
  // ---------------------------------------------------------------------------
256
276
  const EMPTY_KEY_SET = new Set();
277
+ /**
278
+ * Skip reason for a peer that has never sent a daemon-state reply (no
279
+ * `devices/<peer>/daemon-state.json`). auth-sync logs these at INFO — a
280
+ * brand-new or never-dialed worker legitimately has no reply on an early tick,
281
+ * so it is informational, not a warning. Stable so the service classifies the
282
+ * skip without matching a substring.
283
+ */
284
+ export const SKIP_REASON_NO_PEER_REPLY = 'no daemon-state reply from this peer yet';
285
+ /**
286
+ * Skip reason for a peer whose reply exists but carries no `accounts.rows` field
287
+ * at all (an older CLI that does not publish per-account verdicts, or a partial
288
+ * state written before the account-state daemon ran). Without those rows there is
289
+ * no first-hand knowledge of what the peer holds, so the plan FAILS CLOSED and
290
+ * skips rather than pushing every key blindly. auth-sync logs it at INFO — it is
291
+ * transient during a rolling upgrade. An EMPTY `accounts.rows` array (a peer with
292
+ * no registered account) is NOT this case: it is a legitimate "holds nothing",
293
+ * and its keys are (correctly) pushed to provision it.
294
+ */
295
+ export const SKIP_REASON_NO_ACCOUNT_ROWS = 'daemon-state reply carries no account rows (fail closed)';
257
296
  /**
258
297
  * Pure plan: for each worker peer, push each bundle it is missing at least one
259
298
  * key of. Deterministic (peers and bundles sorted) so it pins exactly in tests.
260
- * A headed peer is skipped BEFORE any key comparison -- it never receives a key.
299
+ * A headed peer is skipped BEFORE any key comparison -- it never receives a key;
300
+ * a peer with no reply yet (`hasReply: false`) is skipped next -- there is no
301
+ * first-hand knowledge of what it holds, so nothing is pushed to it this tick;
302
+ * a peer whose reply carries no `accounts.rows` field (`hasAccountRows: false`)
303
+ * is skipped FAIL-CLOSED for the same reason (an older CLI's reply is not proof
304
+ * of an empty inventory), rather than pushing every key blindly.
261
305
  */
262
306
  export function planReservedStoreSync(accounts, peers) {
263
307
  const keysByBundle = new Map();
@@ -274,6 +318,14 @@ export function planReservedStoreSync(accounts, peers) {
274
318
  items.push({ action: 'skip', device: peer.name, reason: 'headed device receives the account row, never a durable key' });
275
319
  continue;
276
320
  }
321
+ if (!peer.hasReply) {
322
+ items.push({ action: 'skip', device: peer.name, reason: SKIP_REASON_NO_PEER_REPLY });
323
+ continue;
324
+ }
325
+ if (!peer.hasAccountRows) {
326
+ items.push({ action: 'skip', device: peer.name, reason: SKIP_REASON_NO_ACCOUNT_ROWS });
327
+ continue;
328
+ }
277
329
  if (!peer.reachable) {
278
330
  items.push({ action: 'skip', device: peer.name, reason: 'unreachable' });
279
331
  continue;
@@ -318,14 +370,44 @@ export function reservedSyncTargets(meta) {
318
370
  }
319
371
  return out;
320
372
  }
321
- // --- publisher-side delivery memo -----------------------------------------
322
- // A push sends a WHOLE bundle; the receiver reports no per-key inventory today
323
- // (that is T3's per-account verdict seam). So the publisher records what it has
324
- // delivered to each peer, keyed by the credential's fingerprint, to answer "does
325
- // the peer already have this key" without re-pushing an unchanged bundle every
326
- // tick. A new account (no memo entry) or a re-mint (fingerprint change) is not a
327
- // hit, so it propagates within one tick. The memo is LOCAL publisher bookkeeping,
328
- // never synced.
373
+ /** Narrow a peer's opaque `accounts.rows` to the verdict fields the planner reads. */
374
+ export function readPeerAccountVerdicts(state) {
375
+ const rows = state?.accounts?.rows;
376
+ if (!Array.isArray(rows))
377
+ return [];
378
+ const out = [];
379
+ for (const row of rows) {
380
+ if (!row || typeof row !== 'object' || Array.isArray(row))
381
+ continue;
382
+ const r = row;
383
+ if (typeof r.accountId === 'string' && typeof r.harness === 'string' && typeof r.verdict === 'string') {
384
+ out.push({ accountId: r.accountId, harness: r.harness, verdict: r.verdict });
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+ /**
390
+ * True when the peer's reply carried an `accounts.rows` array at all (present,
391
+ * even if empty). An older CLI, or a partial state written before the account-
392
+ * state daemon ran, has no such field — and its absence must NOT read as "the
393
+ * peer holds nothing", or the plan would push every key blindly. An EMPTY array
394
+ * (a peer with no registered account) IS a valid first-hand "holds nothing".
395
+ */
396
+ export function peerHasAccountRows(state) {
397
+ return Array.isArray(state?.accounts?.rows);
398
+ }
399
+ // --- publisher-side delivery memo (rotation) -------------------------------
400
+ // A peer's verdict says WHETHER it holds a working credential for an account, but
401
+ // not WHICH one: a re-mint (`accounts login`) rotates the reserved key and bumps
402
+ // `workerCredential.mintedAt`, yet the peer's OLD token keeps authenticating, so
403
+ // its verdict stays `live`/`unverified` and never flips to `missing`. Verdict
404
+ // alone would therefore read `present` forever and the new key would never reach
405
+ // the worker. The publisher records the fingerprint it last delivered per
406
+ // (peer, bundle, key); presence requires BOTH the peer's own verdict AND a
407
+ // delivered-fingerprint match, so a re-mint (fingerprint change) re-pushes within
408
+ // a tick while a key removed on the worker (verdict `missing`) also re-pushes.
409
+ // LOCAL publisher bookkeeping, never synced — it is a rotation cursor, not a
410
+ // substitute for the first-hand verdict.
329
411
  function deliveryMemoPath(root = getCacheDir()) {
330
412
  return path.join(root, 'reserved-sync-delivered.json');
331
413
  }
@@ -346,18 +428,29 @@ function writeDeliveryMemo(memo, root = getCacheDir()) {
346
428
  fs.writeFileSync(deliveryMemoPath(root), `${JSON.stringify(memo, null, 2)}\n`, 'utf-8');
347
429
  }
348
430
  /**
349
- * The keys a peer is KNOWN to hold, per bundle, from what we have delivered to it
350
- * (memo, matched by fingerprint) plus the coarse legacy-`auth` verdict: a peer
351
- * reporting `auth.status === 'ready'` is known to hold every legacy `auth` key.
352
- * Reserved `__<harness>__` bundles have no coarse verdict, so their presence comes
353
- * from the memo alone -- which is what makes a newly-added reserved account push.
431
+ * The reserved keys a peer is KNOWN to hold, per bundle. Two conditions must BOTH
432
+ * hold (PHNX-4116):
433
+ *
434
+ * 1. FIRST-HAND — the peer's own daemon-state reply reports a NON-`missing`
435
+ * verdict for the account, so it has a working credential for it (its slot
436
+ * materialized). A `missing` verdict, or no row for the account at all, means
437
+ * it does not — self-correcting: a key removed on the worker flips its next
438
+ * verdict to `missing` and the push resumes, which a publisher-side memo alone
439
+ * could never see.
440
+ * 2. ROTATION — the fingerprint we last delivered to this peer (`delivered`)
441
+ * matches the account's CURRENT fingerprint. A re-mint bumps
442
+ * `workerCredential.mintedAt` while the old token still authenticates, so the
443
+ * verdict stays non-`missing`; without this the rotated key would never
444
+ * propagate. A never-delivered key (no memo entry ⇒ `undefined`) never
445
+ * matches, so a newly-added account still pushes.
354
446
  */
355
- export function peerPresentKeys(peer, accounts, memo, legacyAuthReady) {
447
+ export function peerPresentKeys(accounts, verdicts, delivered) {
448
+ const held = new Set(verdicts.filter((v) => v.verdict !== 'missing').map((v) => `${v.harness}:${v.accountId}`));
356
449
  const present = {};
357
450
  for (const account of accounts) {
358
- const known = memo[memoKey(peer, account.bundle, account.key)] === account.fingerprint
359
- || (legacyAuthReady && account.bundle === AUTH_STORE_ALIAS);
360
- if (!known)
451
+ if (!held.has(`${account.harness}:${account.accountId}`))
452
+ continue;
453
+ if (delivered(account.bundle, account.key) !== account.fingerprint)
361
454
  continue;
362
455
  (present[account.bundle] ??= new Set()).add(account.key);
363
456
  }
@@ -425,13 +518,18 @@ export async function syncReservedStores(deps = {}) {
425
518
  }
426
519
  const memo = readDeliveryMemo(deps.cacheDir);
427
520
  const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
428
- const peers = devices.map((d) => ({
429
- name: d.name,
430
- headed: isHeadedDeviceRole(peerRole(d.name)),
431
- reachable: isDialableDevice(d),
432
- pinned: isDevicePinned(d, pinned),
433
- presentKeys: peerPresentKeys(d.name, targets, memo, stateByDevice.get(normalizeHost(d.name))?.auth?.status === 'ready'),
434
- }));
521
+ const peers = devices.map((d) => {
522
+ const state = stateByDevice.get(normalizeHost(d.name));
523
+ return {
524
+ name: d.name,
525
+ headed: isHeadedDeviceRole(peerRole(d.name)),
526
+ reachable: isDialableDevice(d),
527
+ pinned: isDevicePinned(d, pinned),
528
+ hasReply: state !== undefined,
529
+ hasAccountRows: peerHasAccountRows(state),
530
+ presentKeys: peerPresentKeys(targets, readPeerAccountVerdicts(state), (bundle, key) => memo[memoKey(d.name, bundle, key)]),
531
+ };
532
+ });
435
533
  const plan = planReservedStoreSync(targets, peers);
436
534
  const byName = new Map(devices.map((d) => [d.name, d]));
437
535
  const push = deps.push ?? ((bundle, host) => pushBundleToHostAsync(bundle, host, {
@@ -452,6 +550,9 @@ export async function syncReservedStores(deps = {}) {
452
550
  const out = await push(item.bundle, sshTarget(profile));
453
551
  if (out.ok) {
454
552
  result.pushed.push({ device: item.device, bundle: item.bundle, keys: item.keys });
553
+ // Record the fingerprint delivered so an UNCHANGED key is not re-pushed
554
+ // next tick; a re-mint (new fingerprint) or a removed key (verdict flips
555
+ // `missing`) still re-pushes.
455
556
  for (const key of item.keys) {
456
557
  const fp = targets.find((t) => t.bundle === item.bundle && t.key === key)?.fingerprint;
457
558
  if (fp)
@@ -469,6 +570,15 @@ export async function syncReservedStores(deps = {}) {
469
570
  writeDeliveryMemo(memo, deps.cacheDir ?? getCacheDir());
470
571
  return result;
471
572
  }
573
+ /**
574
+ * Fire-and-forget the daemon log without pulling its module graph into every
575
+ * consumer of this file: `reconcileLocalWorkerSlots` is synchronous and only
576
+ * runs inside the daemon (where daemon.js is already loaded), so the dynamic
577
+ * import is paid only on the rare tick that actually drops a slot.
578
+ */
579
+ function defaultWorkerSlotLog(level, message) {
580
+ void import('./daemon/daemon.js').then((m) => m.log(level, message)).catch(() => { });
581
+ }
472
582
  function defaultSlotSeeded(harness, slotDir) {
473
583
  return harness === 'claude' ? isClaudeWorkerHomeSeeded(slotDir) : true;
474
584
  }
@@ -482,8 +592,10 @@ function defaultSlotSeeded(harness, slotDir) {
482
592
  * seeded (no `hasCompletedOnboarding`) is provisioned again so it converges.
483
593
  */
484
594
  export function reconcileLocalWorkerSlots(deps = {}) {
485
- const result = { provisioned: [], skipped: [], errors: [] };
486
- const role = deps.selfRole ?? selfConfiguredDeviceRole();
595
+ const result = { provisioned: [], dropped: [], skipped: [], errors: [] };
596
+ // `'selfRole' in deps` (not `??`) so a caller can inject an explicit `undefined`
597
+ // to mean "unmarked device"; a caller that omits it reads the real machine role.
598
+ const role = 'selfRole' in deps ? deps.selfRole : selfConfiguredDeviceRole();
487
599
  if (isHeadedDeviceRole(role))
488
600
  return result; // headed boxes provision via native login
489
601
  const meta = (deps.readMetaFn ?? readMeta)();
@@ -492,6 +604,41 @@ export function reconcileLocalWorkerSlots(deps = {}) {
492
604
  const provision = deps.provision ?? provisionWorkerSlot;
493
605
  const slotSeeded = deps.slotSeeded ?? defaultSlotSeeded;
494
606
  const byId = new Map(listNativeAccounts(meta).map((account) => [account.id, account]));
607
+ // Drop stale worker slots (PHNX-4116). A `deviceAccounts.slots` record whose
608
+ // accountId is no longer a registered native account is a leftover from an
609
+ // older account-id generation — every worker held 8 such duplicates, each a
610
+ // full HOME carrying a live setup-token in `.claude/.oauth_token`. Delete that
611
+ // credential (a 0600 file) and drop the record, but KEEP the slot directory:
612
+ // `.claude/projects` holds session transcripts. Fail closed — an empty
613
+ // registry is a transient read, and stripping every slot on it would wipe a
614
+ // healthy box's whole slot set, so drop nothing until the registry answers.
615
+ // This deletion runs ONLY on a device explicitly marked `worker`: provisioning
616
+ // above is safe on an unmarked box, but destroying a credential is not — an
617
+ // unmarked device is not headed (`isHeadedDeviceRole(undefined)` is false) yet
618
+ // must never run a credential-deleting branch.
619
+ if (role === 'worker' && byId.size > 0) {
620
+ const stale = Object.values(slots).filter((slot) => !byId.has(slot.accountId));
621
+ const droppable = [];
622
+ for (const slot of stale) {
623
+ try {
624
+ // force ignores an already-absent token; a real failure (EACCES) keeps
625
+ // the slot so the record is never dropped while its credential lingers.
626
+ fs.rmSync(path.join(slot.slotDir, '.claude', '.oauth_token'), { force: true });
627
+ droppable.push(slot);
628
+ }
629
+ catch (err) {
630
+ result.errors.push({ accountId: slot.accountId, message: `stale slot credential not removed, slot kept: ${err.message}` });
631
+ }
632
+ }
633
+ if (droppable.length > 0) {
634
+ (deps.dropSlots ?? dropSlots)(droppable.map((slot) => slot.accountId));
635
+ const log = deps.log ?? defaultWorkerSlotLog;
636
+ for (const slot of droppable) {
637
+ result.dropped.push(slot.accountId);
638
+ log('INFO', `worker-slot: dropped stale slot ${slot.accountId} — no longer a registered account; removed its credential, kept ${slot.slotDir} for transcripts`);
639
+ }
640
+ }
641
+ }
495
642
  // Resolve each account to the one (bundle, key) the push plan uses: a T1 row's
496
643
  // reserved `__<harness>__` key, or the legacy `auth` key by email for a claude
497
644
  // row predating T1. Both are worker credentials this box may already hold, so
@@ -14,7 +14,7 @@ import { type TimelineState } from './timeline.js';
14
14
  /** Current schema version; bumped when migrations are added. Exported so tests
15
15
  * assert against the constant instead of hardcoding a number that every bump
16
16
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
17
- export declare const SCHEMA_VERSION = 50;
17
+ export declare const SCHEMA_VERSION = 51;
18
18
  /**
19
19
  * Bump to force the content extractor (assistant-answer text, alongside the
20
20
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -29,7 +29,7 @@ const DB_PATH = getSessionsDbPath();
29
29
  /** Current schema version; bumped when migrations are added. Exported so tests
30
30
  * assert against the constant instead of hardcoding a number that every bump
31
31
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
32
- export const SCHEMA_VERSION = 50;
32
+ export const SCHEMA_VERSION = 51;
33
33
  /**
34
34
  * Bump to force the content extractor (assistant-answer text, alongside the
35
35
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -182,6 +182,10 @@ CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
182
182
  -- v17 guarantees the machine column exists (same pattern as last_activity);
183
183
  -- idx_sessions_mirror_synced likewise waits for migration v46's column add.
184
184
 
185
+ -- A row sits at the rowid of the sessions row it describes (v51) and is
186
+ -- addressed by that rowid, never by session_id: an UNINDEXED FTS5 column cannot
187
+ -- be seeked, so a session_id predicate scans every row's content — the same
188
+ -- rule tool_call_text follows against tool_calls.rowid (v36).
185
189
  CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
186
190
  session_id UNINDEXED,
187
191
  label,
@@ -1411,6 +1415,39 @@ function migrateSchema(db, fromVersion) {
1411
1415
  if (!cols.has('generated_title_at'))
1412
1416
  db.exec(`ALTER TABLE sessions ADD COLUMN generated_title_at INTEGER`);
1413
1417
  }
1418
+ if (fromVersion < 51) {
1419
+ // v50 -> v51: session_text rows move to the rowid of the sessions row they
1420
+ // describe (PHNX-4154), the repair v36 made to tool_call_text. The only key
1421
+ // was the UNINDEXED session_id, so every delete and content read was a full
1422
+ // scan of the FTS content — 669 MB and 0.2-0.5 s per statement on a real
1423
+ // box — and the mirror ingest issues one per mirrored row (2,298 there)
1424
+ // inside a daemon tick: the event loop froze for minutes, every service
1425
+ // blew its deadline, and browser verbs timed out behind it.
1426
+ //
1427
+ // Same shape as v42: rename, recreate, copy back keyed by sessions.rowid,
1428
+ // drop — search never blacks out. OR REPLACE keeps the newest of any
1429
+ // duplicate session_id rows. A text row whose session is gone has no rowid
1430
+ // to sit at and is dropped; nothing could reach it anyway.
1431
+ db.exec(`ALTER TABLE session_text RENAME TO session_text_v50`);
1432
+ db.exec(`
1433
+ CREATE VIRTUAL TABLE session_text USING fts5(
1434
+ session_id UNINDEXED,
1435
+ label,
1436
+ topic,
1437
+ project,
1438
+ content,
1439
+ assistant,
1440
+ tokenize = 'unicode61 remove_diacritics 2'
1441
+ );
1442
+ `);
1443
+ db.exec(`
1444
+ INSERT OR REPLACE INTO session_text (rowid, session_id, label, topic, project, content, assistant)
1445
+ SELECT s.rowid, t.session_id, t.label, t.topic, t.project, t.content, t.assistant
1446
+ FROM session_text_v50 t JOIN sessions s ON s.id = t.session_id
1447
+ ORDER BY t.rowid
1448
+ `);
1449
+ db.exec(`DROP TABLE session_text_v50`);
1450
+ }
1414
1451
  }
1415
1452
  /**
1416
1453
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -2292,8 +2329,15 @@ function enrichCachedSessionMeta(meta) {
2292
2329
  return meta;
2293
2330
  }
2294
2331
  }
2295
- const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
2296
- const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content, assistant) VALUES (?, ?, ?, ?, ?, ?)`);
2332
+ /**
2333
+ * The one way to address a session's FTS row: its rowid is the sessions rowid
2334
+ * (schema comment on session_text). A `session_id = ?` predicate scans the whole
2335
+ * index instead — the PHNX-4154 daemon freeze.
2336
+ */
2337
+ const SESSION_TEXT_ROWID = `(SELECT rowid FROM sessions WHERE id = ?)`;
2338
+ const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
2339
+ // Binds the session id twice: once for the rowid lookup, once for the column.
2340
+ const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (rowid, session_id, label, topic, project, content, assistant) VALUES (${SESSION_TEXT_ROWID}, ?, ?, ?, ?, ?, ?)`);
2297
2341
  // Read back the label the upsert actually stored (which may be the preserved
2298
2342
  // one, not the incoming blank) so the FTS label column stays consistent with
2299
2343
  // sessions.label after a bare rescan.
@@ -2406,7 +2450,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2406
2450
  const txn = db.transaction(() => {
2407
2451
  upsert.run(row);
2408
2452
  delText.run(meta.id);
2409
- insText.run(meta.id,
2453
+ insText.run(meta.id, meta.id,
2410
2454
  // Use the label the upsert actually stored (preserve-non-empty rule),
2411
2455
  // not the raw incoming one, so FTS label ranking survives a bare rescan.
2412
2456
  storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
@@ -2427,8 +2471,10 @@ function reconcileCodexFileOwners(db, metas) {
2427
2471
  for (const row of rows) {
2428
2472
  if (owners.get(row.file_path) === row.id)
2429
2473
  continue;
2474
+ // The text row is keyed by the sessions rowid, so it goes before the row.
2475
+ db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`).run(row.id);
2430
2476
  db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id);
2431
- for (const table of ['session_text', 'session_preview_cache', 'session_summaries', 'session_insights', 'session_topics', 'session_phenotypes', 'session_resource_usage', 'resource_scan_ledger']) {
2477
+ for (const table of ['session_preview_cache', 'session_summaries', 'session_insights', 'session_topics', 'session_phenotypes', 'session_resource_usage', 'resource_scan_ledger']) {
2432
2478
  db.prepare(`DELETE FROM ${table} WHERE session_id = ?`).run(row.id);
2433
2479
  }
2434
2480
  // The tool ledger has a unique file path too; release the wrong binding
@@ -2671,7 +2717,7 @@ export function upsertSessionsBatch(entries) {
2671
2717
  };
2672
2718
  upsert.run(row);
2673
2719
  delText.run(meta.id);
2674
- insText.run(meta.id,
2720
+ insText.run(meta.id, meta.id,
2675
2721
  // Mirror upsertSession: index the label the upsert actually stored
2676
2722
  // (preserve-non-empty rule), not the raw incoming one.
2677
2723
  storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
@@ -2746,7 +2792,7 @@ export function syncLabels(labelMap) {
2746
2792
  if (updates.length === 0)
2747
2793
  return 0;
2748
2794
  const updSessions = db.prepare(`UPDATE sessions SET label = ? WHERE id = ?`);
2749
- const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE session_id = ?`);
2795
+ const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE rowid = ${SESSION_TEXT_ROWID}`);
2750
2796
  const txn = db.transaction((items) => {
2751
2797
  for (const { id, label } of items) {
2752
2798
  updSessions.run(label, id);
@@ -2797,7 +2843,7 @@ export function seedLabelsFromNames(nameMap) {
2797
2843
  if (updates.length === 0)
2798
2844
  return 0;
2799
2845
  const updSessions = db.prepare(`UPDATE sessions SET label = ? WHERE id = ?`);
2800
- const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE session_id = ?`);
2846
+ const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE rowid = ${SESSION_TEXT_ROWID}`);
2801
2847
  const txn = db.transaction((items) => {
2802
2848
  for (const { id, label } of items) {
2803
2849
  updSessions.run(label, id);
@@ -2839,7 +2885,7 @@ export function syncTopics(topicMap) {
2839
2885
  if (updates.length === 0)
2840
2886
  return 0;
2841
2887
  const updSessions = db.prepare(`UPDATE sessions SET topic = ? WHERE id = ?`);
2842
- const updFts = db.prepare(`UPDATE session_text SET topic = ? WHERE session_id = ?`);
2888
+ const updFts = db.prepare(`UPDATE session_text SET topic = ? WHERE rowid = ${SESSION_TEXT_ROWID}`);
2843
2889
  const txn = db.transaction((items) => {
2844
2890
  for (const { id, topic } of items) {
2845
2891
  updSessions.run(topic, id);
@@ -3231,7 +3277,7 @@ export function querySessions(options = {}) {
3231
3277
  const missingIds = new Set(missing.map(r => r.id));
3232
3278
  const phantomIds = new Set();
3233
3279
  if (missing.length > 0) {
3234
- const readContent = db.prepare(`SELECT content FROM session_text WHERE session_id = ?`);
3280
+ const readContent = db.prepare(`SELECT content FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
3235
3281
  const markArchived = db.prepare(`UPDATE sessions SET archived_at = ? WHERE id = ? AND archived_at IS NULL`);
3236
3282
  const now = Date.now();
3237
3283
  const classify = db.transaction(() => {
@@ -3706,7 +3752,7 @@ export function writeRemotePreviewCacheFailure(device, sessionId, reason, backof
3706
3752
  * when no row exists; an empty string when a row exists but carried no content.
3707
3753
  */
3708
3754
  export function readSessionContent(id) {
3709
- const row = getDB().prepare(`SELECT content FROM session_text WHERE session_id = ?`).get(id);
3755
+ const row = getDB().prepare(`SELECT content FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`).get(id);
3710
3756
  return row?.content;
3711
3757
  }
3712
3758
  /**
@@ -3964,11 +4010,11 @@ export function upsertMirrorSession(row, source, syncedAt) {
3964
4010
  if (result.changes === 0)
3965
4011
  return false;
3966
4012
  // Keep the mirror row searchable by topic + first user turn, like a local row.
3967
- db.prepare(`DELETE FROM session_text WHERE session_id = ?`).run(row.id);
4013
+ db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`).run(row.id);
3968
4014
  db.prepare(`
3969
- INSERT INTO session_text (session_id, label, topic, project, content, assistant)
3970
- VALUES (?, ?, ?, '', ?, '')
3971
- `).run(row.id, row.label ?? '', row.topic ?? '', row.firstUser ?? '');
4015
+ INSERT INTO session_text (rowid, session_id, label, topic, project, content, assistant)
4016
+ VALUES (${SESSION_TEXT_ROWID}, ?, ?, ?, '', ?, '')
4017
+ `).run(row.id, row.id, row.label ?? '', row.topic ?? '', row.firstUser ?? '');
3972
4018
  // Carry the peer's daemon-computed summary into the same session_summaries
3973
4019
  // cache the local merge reads (PHNX-3939), so a peer session renders its
3974
4020
  // goal/checkpoints/checklist inline with no transcript. Stamp is null: the
@@ -4072,14 +4118,15 @@ export function pruneMirrorSessions(cutoffMs) {
4072
4118
  if (stale.length === 0)
4073
4119
  return 0;
4074
4120
  const delRow = db.prepare(`DELETE FROM sessions WHERE id = ?`);
4075
- const delText = db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
4121
+ const delText = db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
4076
4122
  const delPreview = db.prepare(`DELETE FROM session_preview_cache WHERE session_id = ?`);
4077
4123
  const delSummary = db.prepare(`DELETE FROM session_summaries WHERE session_id = ?`);
4078
4124
  const delTimeline = db.prepare(`DELETE FROM session_timelines WHERE session_id = ?`);
4079
4125
  const txn = db.transaction(() => {
4080
4126
  for (const { id } of stale) {
4081
- delRow.run(id);
4127
+ // Text first: its rowid lookup needs the sessions row still present.
4082
4128
  delText.run(id);
4129
+ delRow.run(id);
4083
4130
  delPreview.run(id);
4084
4131
  delSummary.run(id);
4085
4132
  delTimeline.run(id);
@@ -4461,7 +4508,7 @@ export function topSessionsByCost(n, options = {}) {
4461
4508
  // still excluded. archived_at is the persisted signal; stamp it here too (once)
4462
4509
  // so a session first surfaced through the cost rollup reports `archived`
4463
4510
  // consistently with the listing path.
4464
- const readContent = db.prepare(`SELECT content FROM session_text WHERE session_id = ?`);
4511
+ const readContent = db.prepare(`SELECT content FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
4465
4512
  const markArchived = db.prepare(`UPDATE sessions SET archived_at = ? WHERE id = ? AND archived_at IS NULL`);
4466
4513
  const now = Date.now();
4467
4514
  const toStamp = [];