@phnx-labs/agents-cli 1.22.92 → 1.22.93

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 (36) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +5 -0
  3. package/dist/lib/claude-account-token.d.ts +13 -0
  4. package/dist/lib/claude-account-token.js +29 -0
  5. package/dist/lib/daemon/attention-notify-service.d.ts +5 -2
  6. package/dist/lib/daemon/attention-notify-service.js +9 -2
  7. package/dist/lib/exec.js +11 -0
  8. package/dist/lib/feed/answer.js +9 -0
  9. package/dist/lib/feed/attention.d.ts +18 -2
  10. package/dist/lib/feed/attention.js +0 -0
  11. package/dist/lib/feed/feed.d.ts +6 -2
  12. package/dist/lib/feed/feed.js +10 -4
  13. package/dist/lib/harness/adapter.d.ts +23 -0
  14. package/dist/lib/harness/adapter.js +29 -0
  15. package/dist/lib/harness/adapters/claude.js +5 -3
  16. package/dist/lib/harness/adapters/copilot.js +4 -2
  17. package/dist/lib/harness/adapters/grok.js +4 -2
  18. package/dist/lib/harness/adapters/kimi.js +4 -2
  19. package/dist/lib/harness/adapters/muse.js +4 -3
  20. package/dist/lib/harness/adapters/opencode.js +4 -2
  21. package/dist/lib/helper-download.js +1 -1
  22. package/dist/lib/helper-versions.js +1 -1
  23. package/dist/lib/installations/shims.d.ts +2 -2
  24. package/dist/lib/installations/shims.js +16 -8
  25. package/dist/lib/menubar/download-menubar.js +4 -1
  26. package/dist/lib/menubar/install-menubar.d.ts +10 -9
  27. package/dist/lib/menubar/install-menubar.js +17 -15
  28. package/dist/lib/secrets-policy.d.ts +5 -2
  29. package/dist/lib/secrets-policy.js +18 -4
  30. package/dist/lib/session/active.d.ts +18 -11
  31. package/dist/lib/session/active.js +52 -44
  32. package/dist/lib/session/session-cache.d.ts +1 -1
  33. package/dist/lib/session/session-cache.js +1 -0
  34. package/dist/lib/session/state.d.ts +26 -0
  35. package/dist/lib/session/state.js +43 -27
  36. package/package.json +1 -1
@@ -13,6 +13,7 @@ import { AGENTS, agentConfigDirName, readAuthAccountIdentity } from '../agents.j
13
13
  import { acquireAuthOperationLock } from '../accounts/auth-operation-lock.js';
14
14
  import { codexHomeShimBash } from '../codex-home.js';
15
15
  import { resolveHarnessAdapter } from '../harness/index.js';
16
+ import { slotAwareConfigEnvBash } from '../harness/adapter.js';
16
17
  import { randomUUID } from 'node:crypto';
17
18
  import { captureProcessStartTime } from '../platform/process.js';
18
19
  import { atomicWriteFileSync } from '../fs-atomic.js';
@@ -267,7 +268,10 @@ async function promptConflictStrategy(conflictInfos) {
267
268
  // grok-0.2.118-* file — a 99-byte wrapper that exec'd cursor-agent —
268
269
  // sorted alphabetically before the real self-updated grok binary and
269
270
  // was silently launched instead.
270
- export const SHIM_SCHEMA_VERSION = 31;
271
+ // v32 — every config-dir pin (claude, grok, opencode, kimi, muse, copilot) yields to
272
+ // an account-slot launch (AGENTS_EXEC_HOME from `agents run`), so a slot run
273
+ // reads and writes its own slot home instead of the shared version home.
274
+ export const SHIM_SCHEMA_VERSION = 32;
271
275
  /** Internal marker string used to embed the schema version in shim scripts. */
272
276
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
273
277
  function shellQuote(value) {
@@ -1095,7 +1099,12 @@ export function removeShim(agent) {
1095
1099
  // v18 — Cursor aliases select the file credential store and swap HOME to the
1096
1100
  // version home because current Cursor writes auth.json under ~/.cursor
1097
1101
  // and ignores XDG_CONFIG_HOME for credential storage.
1098
- export const VERSIONED_ALIAS_SCHEMA_VERSION = 20;
1102
+ // v21 — every alias config-dir pin (claude, grok, opencode, kimi, muse, copilot)
1103
+ // yields to an account-slot launch (AGENTS_EXEC_HOME from `agents run`). Before,
1104
+ // the alias re-pinned every slot launch onto the shared version home, so a worker
1105
+ // run picked as one account onboarded from scratch and kept its history in
1106
+ // another account's home.
1107
+ export const VERSIONED_ALIAS_SCHEMA_VERSION = 21;
1099
1108
  /** Internal marker string used to embed the schema version in versioned alias scripts. */
1100
1109
  const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
1101
1110
  // The version string is interpolated into a generated bash script and into
@@ -1229,35 +1238,34 @@ ${resolveHarnessAdapter(agent).shimConfigEnvBash?.({ configDirName }) ?? ''}`
1229
1238
  # Copilot honors COPILOT_HOME to relocate ~/.copilot (settings, mcp-config.json,
1230
1239
  # session-state, logs). Point direct aliases at the versioned home so per-
1231
1240
  # version MCP and session state are isolated.
1232
- export COPILOT_HOME="$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
1241
+ ${slotAwareConfigEnvBash([{ env: 'COPILOT_HOME', rel: configDirName }], `$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home`)}
1233
1242
  `
1234
1243
  : agent === 'grok'
1235
1244
  ? `
1236
1245
  # Grok Build uses GROK_HOME to isolate its entire configuration tree (skills,
1237
1246
  # hooks, plugins, agents, memory, sessions, config.toml, MCP). Point direct
1238
1247
  # aliases at the versioned home for isolation parity with the main shim.
1239
- export GROK_HOME="$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
1248
+ ${slotAwareConfigEnvBash([{ env: 'GROK_HOME', rel: configDirName }], `$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home`)}
1240
1249
  `
1241
1250
  : agent === 'opencode'
1242
1251
  ? `
1243
1252
  # OpenCode reads plugins, agents, commands, and other config-directory
1244
1253
  # resources from OPENCODE_CONFIG_DIR. Point direct aliases at the versioned
1245
1254
  # global config tree where agents-cli syncs OpenCode resources.
1246
- export OPENCODE_CONFIG_DIR="$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home/.config/opencode"
1255
+ ${slotAwareConfigEnvBash([{ env: 'OPENCODE_CONFIG_DIR', rel: '.config/opencode' }], `$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home`)}
1247
1256
  `
1248
1257
  : agent === 'kimi'
1249
1258
  ? `
1250
1259
  # Kimi Code CLI honors KIMI_CODE_HOME to relocate ~/.kimi-code (config.toml,
1251
1260
  # mcp.json, sessions, skills, hooks). Point direct aliases at the versioned home.
1252
- export KIMI_CODE_HOME="$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
1261
+ ${slotAwareConfigEnvBash([{ env: 'KIMI_CODE_HOME', rel: configDirName }], `$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home`)}
1253
1262
  `
1254
1263
  : agent === 'muse'
1255
1264
  ? `
1256
1265
  # Muse Code: no dedicated config env var. Pin XDG so config/sessions live under
1257
1266
  # the version home as real directories (not via the adopt symlink at
1258
1267
  # ~/.config/muse, which Muse rejects with SymlinkOrReparse).
1259
- export XDG_CONFIG_HOME="$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home/.config"
1260
- export XDG_DATA_HOME="$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home/.local/share"
1268
+ ${slotAwareConfigEnvBash([{ env: 'XDG_CONFIG_HOME', rel: '.config' }, { env: 'XDG_DATA_HOME', rel: '.local/share' }], `$AGENTS_REAL_HOME/.agents/.history/versions/${agent}/${version}/home`)}
1261
1269
  `
1262
1270
  : agent === 'cursor'
1263
1271
  ? `
@@ -36,7 +36,10 @@ export const MENUBAR_HELPER_SPEC = {
36
36
  cacheSubdir: ['menubar', 'mac-helper'],
37
37
  expectedTeamId: EXPECTED_TEAM_ID,
38
38
  expectedBundleId: MENUBAR_HELPER_BUNDLE_ID,
39
- localBuildHint: 'menubar/scripts/build.sh release, then cp -R menubar/dist/MenubarHelper.app bin/MenubarHelper.app',
39
+ // No local build exists in this repo: the source is phnx-labs/agi-menu. The
40
+ // staging script downloads + verifies the same published asset into
41
+ // bin/MenubarHelper.app, which the installer resolves from a checkout.
42
+ localBuildHint: 'scripts/stage-menubar-helper.sh (stages the published menubar/v<floor> asset into bin/MenubarHelper.app; source: https://github.com/phnx-labs/agi-menu)',
40
43
  };
41
44
  /** Release-asset URLs for the menu-bar helper zip + its checksum at `v<version>`. */
42
45
  export function menubarHelperAssetUrls(version) {
@@ -24,8 +24,8 @@
24
24
  * (`SERVICE_LABEL_BASE`, `MENUBAR_HELPER_BUNDLE_ID` in download-menubar.ts) —
25
25
  * those are what keep the existing Accessibility grant alive across upgrades.
26
26
  * Every basename-matching check reads this constant rather than re-deriving
27
- * the string; the Swift side has its own equal in
28
- * `menubar/Sources/MenubarHelper/HelperIdentity.swift`.
27
+ * the string; the Swift side (phnx-labs/agi-menu,
28
+ * `Sources/MenubarHelper/HelperIdentity.swift`) has its own equal.
29
29
  */
30
30
  export declare const MENUBAR_HELPER_EXECUTABLE_NAME = "AGI Menu";
31
31
  /**
@@ -40,7 +40,7 @@ export declare function serviceLabel(): string;
40
40
  *
41
41
  * `source` matters because the two install paths have different notions of
42
42
  * "changed": a release bundle is identified by its helper version, while a local
43
- * dev build has none (menubar/scripts/build.sh hardcodes CFBundleShortVersionString),
43
+ * dev build has none (agi-menu's build.sh hardcodes CFBundleShortVersionString),
44
44
  * so it is identified by the source path + mtime it was copied from.
45
45
  */
46
46
  /** Version label for a bundle that has none of its own (a local dev build). */
@@ -75,10 +75,11 @@ export declare function codesignVerifies(appPath: string): boolean;
75
75
  * A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
76
76
  * which macOS surfaces as "the app is damaged" and can crash AppKit during
77
77
  * launch. This is separate from `codesign --verify`: a signature can be valid
78
- * while Gatekeeper still refuses to run it. The release notarizes + staples the
79
- * helper (menubar/scripts/build.sh, gated by verify-menubar-helper.sh), so a
80
- * shipped bundle passes this; the launch guards use it to fail loud rather than
81
- * bootstrap a helper macOS would reject.
78
+ * while Gatekeeper still refuses to run it. The helper's own release
79
+ * (phnx-labs/agi-menu scripts/release.sh) notarizes + staples it and this repo's
80
+ * stage-menubar-helper.sh re-verifies it, so a shipped bundle passes this; the
81
+ * launch guards use it to fail loud rather than bootstrap a helper macOS would
82
+ * reject.
82
83
  */
83
84
  export declare function gatekeeperAssesses(appPath: string): boolean;
84
85
  /** True when the bundle carries a Developer ID TeamIdentifier (not ad-hoc). */
@@ -258,7 +259,7 @@ export declare function disableMenubarService(): void;
258
259
  * There is deliberately NO content comparison here. Comparing the shipped helper
259
260
  * against the installed one cannot distinguish "real upgrade" from "another
260
261
  * install's copy": the helper is rebuilt, re-signed and re-notarized on every
261
- * release (`menubar/scripts/build.sh` via `release.sh`), so consecutive releases
262
+ * helper release (phnx-labs/agi-menu scripts/release.sh), so consecutive releases
262
263
  * ship byte-different bundles from identical Swift source — 1.22.20/21/22 all
263
264
  * have the same 2876288-byte executable and three different sha256s AND three
264
265
  * different CDHashes. Any digest gate therefore reports "changed" for exactly
@@ -323,7 +324,7 @@ export declare function shouldMigrateMenubarTcc(opts: {
323
324
  * Reset the stale Accessibility grant TCC recorded against the pre-migration
324
325
  * ad-hoc identity, then stamp the marker so this never runs again on this
325
326
  * machine. Targets `SERVICE_LABEL_BASE` — the bundle's actual
326
- * `CFBundleIdentifier`/codesign `--identifier` (menubar/scripts/build.sh) that
327
+ * `CFBundleIdentifier`/codesign `--identifier` (set by agi-menu's build.sh) that
327
328
  * TCC keys grants to — not the namespaced `serviceLabel()`, which exists only
328
329
  * to keep launchd job labels apart under a redirected HOME (RUSH-2639) and is
329
330
  * never the app's real identity. Best-effort: a missing/failing `tccutil`
@@ -40,8 +40,8 @@ const SERVICE_LABEL_BASE = 'com.phnx-labs.agents-menubar';
40
40
  * (`SERVICE_LABEL_BASE`, `MENUBAR_HELPER_BUNDLE_ID` in download-menubar.ts) —
41
41
  * those are what keep the existing Accessibility grant alive across upgrades.
42
42
  * Every basename-matching check reads this constant rather than re-deriving
43
- * the string; the Swift side has its own equal in
44
- * `menubar/Sources/MenubarHelper/HelperIdentity.swift`.
43
+ * the string; the Swift side (phnx-labs/agi-menu,
44
+ * `Sources/MenubarHelper/HelperIdentity.swift`) has its own equal.
45
45
  */
46
46
  export const MENUBAR_HELPER_EXECUTABLE_NAME = 'AGI Menu';
47
47
  /**
@@ -64,8 +64,9 @@ export function serviceLabel() {
64
64
  * respawn rate while staying well inside "the menu bar came back on its own".
65
65
  *
66
66
  * This only paces the restarts. What actually stops the pile-up is the helper
67
- * bounding and group-killing its own children (menubar/Sources/MenubarHelper/
68
- * ChildProcess.swift); the two are complementary, not alternatives.
67
+ * bounding and group-killing its own children (agi-menu's
68
+ * `Sources/MenubarHelper/ChildProcess.swift`); the two are complementary, not
69
+ * alternatives.
69
70
  */
70
71
  const MENUBAR_THROTTLE_SECONDS = 30;
71
72
  function onDarwin() {
@@ -94,7 +95,7 @@ function installedVersionMarkerPath() {
94
95
  *
95
96
  * `source` matters because the two install paths have different notions of
96
97
  * "changed": a release bundle is identified by its helper version, while a local
97
- * dev build has none (menubar/scripts/build.sh hardcodes CFBundleShortVersionString),
98
+ * dev build has none (agi-menu's build.sh hardcodes CFBundleShortVersionString),
98
99
  * so it is identified by the source path + mtime it was copied from.
99
100
  */
100
101
  /** Version label for a bundle that has none of its own (a local dev build). */
@@ -163,9 +164,10 @@ export function menubarServiceInstalled() {
163
164
  /**
164
165
  * Locate the source `.app` shipped alongside the compiled JS.
165
166
  * 1. dist/lib/menubar/MenubarHelper.app — npm install layout (sibling of this file)
166
- * 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev)
167
- * 3. cli/menubar/dist/MenubarHelper.app — fresh local build
168
- * 4. <on-disk install>/dist/lib/menubar/MenubarHelper.app — Bun single-file
167
+ * 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev): the
168
+ * published bundle staged by scripts/stage-menubar-helper.sh, or a build
169
+ * copied in from a phnx-labs/agi-menu checkout
170
+ * 3. <on-disk install>/dist/lib/menubar/MenubarHelper.app — Bun single-file
169
171
  * binary: `import.meta.url` is a virtual `/$bunfs/` path, so the sibling
170
172
  * candidates above can't see the on-disk bundle; recover it via the
171
173
  * `agents` launcher symlink.
@@ -176,7 +178,6 @@ function sourceAppPath() {
176
178
  const here = path.dirname(fileURLToPath(import.meta.url));
177
179
  candidates.push(path.join(here, APP_BUNDLE_NAME));
178
180
  candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
179
- candidates.push(path.resolve(here, '..', '..', '..', 'menubar', 'dist', APP_BUNDLE_NAME));
180
181
  }
181
182
  catch {
182
183
  /* import.meta.url unavailable */
@@ -242,10 +243,11 @@ export function codesignVerifies(appPath) {
242
243
  * A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
243
244
  * which macOS surfaces as "the app is damaged" and can crash AppKit during
244
245
  * launch. This is separate from `codesign --verify`: a signature can be valid
245
- * while Gatekeeper still refuses to run it. The release notarizes + staples the
246
- * helper (menubar/scripts/build.sh, gated by verify-menubar-helper.sh), so a
247
- * shipped bundle passes this; the launch guards use it to fail loud rather than
248
- * bootstrap a helper macOS would reject.
246
+ * while Gatekeeper still refuses to run it. The helper's own release
247
+ * (phnx-labs/agi-menu scripts/release.sh) notarizes + staples it and this repo's
248
+ * stage-menubar-helper.sh re-verifies it, so a shipped bundle passes this; the
249
+ * launch guards use it to fail loud rather than bootstrap a helper macOS would
250
+ * reject.
249
251
  */
250
252
  export function gatekeeperAssesses(appPath) {
251
253
  const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
@@ -751,7 +753,7 @@ export function disableMenubarService() {
751
753
  * There is deliberately NO content comparison here. Comparing the shipped helper
752
754
  * against the installed one cannot distinguish "real upgrade" from "another
753
755
  * install's copy": the helper is rebuilt, re-signed and re-notarized on every
754
- * release (`menubar/scripts/build.sh` via `release.sh`), so consecutive releases
756
+ * helper release (phnx-labs/agi-menu scripts/release.sh), so consecutive releases
755
757
  * ship byte-different bundles from identical Swift source — 1.22.20/21/22 all
756
758
  * have the same 2876288-byte executable and three different sha256s AND three
757
759
  * different CDHashes. Any digest gate therefore reports "changed" for exactly
@@ -968,7 +970,7 @@ export function shouldMigrateMenubarTcc(opts) {
968
970
  * Reset the stale Accessibility grant TCC recorded against the pre-migration
969
971
  * ad-hoc identity, then stamp the marker so this never runs again on this
970
972
  * machine. Targets `SERVICE_LABEL_BASE` — the bundle's actual
971
- * `CFBundleIdentifier`/codesign `--identifier` (menubar/scripts/build.sh) that
973
+ * `CFBundleIdentifier`/codesign `--identifier` (set by agi-menu's build.sh) that
972
974
  * TCC keys grants to — not the namespaced `serviceLabel()`, which exists only
973
975
  * to keep launchd job labels apart under a redirected HOME (RUSH-2639) and is
974
976
  * never the app's real identity. Best-effort: a missing/failing `tccutil`
@@ -240,13 +240,16 @@ export interface ReconcileWorkerSlotsDeps {
240
240
  readMetaFn?: () => Pick<Meta, 'accounts' | 'deviceAccounts'>;
241
241
  hasLocalKey?: (bundle: string, key: string) => boolean;
242
242
  provision?: (account: NativeAccountRecord) => void;
243
+ /** True when an existing durable slot already carries everything provisioning seeds. */
244
+ slotSeeded?: (harness: AgentId, slotDir: string) => boolean;
243
245
  }
244
246
  /**
245
247
  * Worker-side: after a durable key lands, materialize a slot for each portable
246
248
  * account whose credential is now present locally. Runs only on a NON-headed
247
249
  * (worker or unmarked) device -- a headed device provisions its slots from an
248
250
  * interactive native login (`accounts add`), never from an injected durable key
249
- * (invariant 7). Idempotent: an account already backed by a `durable` slot is
250
- * skipped.
251
+ * (invariant 7). Idempotent: an account already backed by a fully seeded
252
+ * `durable` slot is skipped; a claude slot provisioned before onboarding was
253
+ * seeded (no `hasCompletedOnboarding`) is provisioned again so it converges.
251
254
  */
252
255
  export declare function reconcileLocalWorkerSlots(deps?: ReconcileWorkerSlotsDeps): ReconcileWorkerSlotsResult;
@@ -19,7 +19,7 @@ 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
21
  import { listNativeAccounts, readSlots } from './account-registry.js';
22
- import { claudeAccountTokenKey, provisionWorkerSlot, readReservedCredential } from './claude-account-token.js';
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_NAME, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, } from './reserved-stores.js';
25
25
  // --- reserved per-harness credential stores (PHNX-3940 / PHNX-3989) --------
@@ -469,13 +469,17 @@ export async function syncReservedStores(deps = {}) {
469
469
  writeDeliveryMemo(memo, deps.cacheDir ?? getCacheDir());
470
470
  return result;
471
471
  }
472
+ function defaultSlotSeeded(harness, slotDir) {
473
+ return harness === 'claude' ? isClaudeWorkerHomeSeeded(slotDir) : true;
474
+ }
472
475
  /**
473
476
  * Worker-side: after a durable key lands, materialize a slot for each portable
474
477
  * account whose credential is now present locally. Runs only on a NON-headed
475
478
  * (worker or unmarked) device -- a headed device provisions its slots from an
476
479
  * interactive native login (`accounts add`), never from an injected durable key
477
- * (invariant 7). Idempotent: an account already backed by a `durable` slot is
478
- * skipped.
480
+ * (invariant 7). Idempotent: an account already backed by a fully seeded
481
+ * `durable` slot is skipped; a claude slot provisioned before onboarding was
482
+ * seeded (no `hasCompletedOnboarding`) is provisioned again so it converges.
479
483
  */
480
484
  export function reconcileLocalWorkerSlots(deps = {}) {
481
485
  const result = { provisioned: [], skipped: [], errors: [] };
@@ -486,6 +490,7 @@ export function reconcileLocalWorkerSlots(deps = {}) {
486
490
  const slots = readSlots(meta);
487
491
  const hasLocalKey = deps.hasLocalKey ?? defaultHasLocalKey;
488
492
  const provision = deps.provision ?? provisionWorkerSlot;
493
+ const slotSeeded = deps.slotSeeded ?? defaultSlotSeeded;
489
494
  const byId = new Map(listNativeAccounts(meta).map((account) => [account.id, account]));
490
495
  // Resolve each account to the one (bundle, key) the push plan uses: a T1 row's
491
496
  // reserved `__<harness>__` key, or the legacy `auth` key by email for a claude
@@ -502,12 +507,21 @@ export function reconcileLocalWorkerSlots(deps = {}) {
502
507
  result.skipped.push({ accountId: account.id, reason: 'durable key not synced yet' });
503
508
  continue;
504
509
  }
505
- if (slots[account.id]?.authMode === 'durable') {
510
+ const existing = slots[account.id];
511
+ const reseed = existing?.authMode === 'durable';
512
+ if (reseed && slotSeeded(account.agent, existing.slotDir)) {
506
513
  result.skipped.push({ accountId: account.id, reason: 'slot already provisioned' });
507
514
  continue;
508
515
  }
509
516
  try {
510
517
  provision(account);
518
+ // A re-seed that still reads as unseeded is a slot file this box cannot
519
+ // repair (a .claude.json that exists but does not parse is left alone by
520
+ // the seeder). Surface it instead of logging "provisioned" every tick.
521
+ if (reseed && !slotSeeded(account.agent, existing.slotDir)) {
522
+ result.errors.push({ accountId: account.id, message: `slot ${existing.slotDir} is still not fully seeded after re-provisioning; a config file there exists but cannot be parsed` });
523
+ continue;
524
+ }
511
525
  result.provisioned.push(account.id);
512
526
  }
513
527
  catch (err) {
@@ -267,6 +267,16 @@ export interface ActiveSession {
267
267
  * off this so an idle-but-old session doesn't read as freshly active.
268
268
  */
269
269
  lastActivityMs?: number;
270
+ /**
271
+ * The transcript's own cursor — the harness stamp on the last meaningful event
272
+ * (message / tool call / tool result), from {@link SessionState.lastEventMs}.
273
+ * Unlike {@link lastActivityMs} it does not move when a hook-firing record is
274
+ * appended, so the attention reconciler uses it to decide whether the agent
275
+ * worked past a hook-raised prompt (PHNX-3999). Absent when the harness stamps
276
+ * no times, when no transcript was parsed, and on rows from a peer running an
277
+ * older CLI.
278
+ */
279
+ lastEventMs?: number;
270
280
  status: ActiveStatus;
271
281
  /**
272
282
  * Coarse lifecycle bucket derived once from {@link status} (see {@link SessionPhase}).
@@ -642,26 +652,23 @@ interface LiveSignals {
642
652
  /** Rolling output-token throughput (tokens/sec). Undefined when no usage in the tail. */
643
653
  tokPerSec?: number;
644
654
  }
645
- /** Test seam: drop the in-process live-signals memo. */
655
+ /** Test seam: drop the in-process parsed-tail memo. */
646
656
  export declare function clearLiveSignalsCacheForTest(): void;
647
657
  /**
648
658
  * Derive the inferred state (working / waiting / idle + preview/badges) and, for
649
659
  * the two harnesses whose raw lines carry it, the output-token throughput.
650
660
  *
651
- * Claude/Codex take the fast bounded byte-tail ({@link readSessionTailWithRaw}) —
652
- * the hot path, and the only two that also yield throughput (their raw lines
653
- * carry usage the event model drops). EVERY OTHER tracked harness (grok, droid,
654
- * rush, gemini, kimi, hermes, opencode, antigravity, cursor) is parsed with its own
655
- * parser and run through the SAME {@link inferSessionState}, so a live
656
- * non-claude/codex agent gets a real working/waiting/idle instead of the blanket
657
- * `unknown` it used to fall through to. An opaque/untracked kind or an
661
+ * Every tracked harness runs through the SAME {@link inferSessionState}, so a
662
+ * live non-claude/codex agent gets a real working/waiting/idle instead of the
663
+ * blanket `unknown` it used to fall through to. An opaque/untracked kind or an
658
664
  * unreadable/empty transcript yields an empty signal set, and the caller's
659
665
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
660
666
  *
661
- * Unchanged-mtime hits reuse the previous result (see {@link liveSignalsCache})
662
- * so a 30s active-session poll does not re-tail every quiet transcript.
667
+ * An unchanged-mtime call reuses the parsed tail (see {@link liveTailCache}) so a
668
+ * 30s active-session poll does not re-tail every quiet transcript — but it always
669
+ * re-classifies against `nowMs`, so an elapsed-time verdict is never frozen.
663
670
  */
664
- export declare function computeLiveSignals(kind: string, sessionFile: string | undefined, cwd: string | undefined, pidAlive: boolean): LiveSignals;
671
+ export declare function computeLiveSignals(kind: string, sessionFile: string | undefined, cwd: string | undefined, pidAlive: boolean, nowMs?: number): LiveSignals;
665
672
  /**
666
673
  * One-line summary of a teammate's spawn prompt — the team's task/target. Takes
667
674
  * the first non-empty line, strips a leading `MISSION:`/`CONTEXT:`/`TASK:` label,
@@ -760,36 +760,56 @@ function parseTailEventsForKind(agent, sessionFile) {
760
760
  return events.length > LIVE_STATE_MAX_EVENTS ? events.slice(-LIVE_STATE_MAX_EVENTS) : events;
761
761
  }
762
762
  /**
763
- * Process-local memo for {@link computeLiveSignals}. The menu-bar / `--active`
764
- * poll re-reads every live session on a ~30s tick (#2047). When a transcript's
765
- * mtime (and pidAlive / kind / cwd) are unchanged, re-parsing the tail yields
766
- * the same signals — skip the parse. Bound the map so a long-lived daemon that
767
- * sees thousands of sessions over its life cannot retain them forever.
768
- */
769
- const LIVE_SIGNALS_CACHE_MAX = 512;
770
- const liveSignalsCache = new Map();
771
- /** Test seam: drop the in-process live-signals memo. */
763
+ * Process-local memo of the PARSED TAIL for {@link computeLiveSignals}. The
764
+ * menu-bar / `--active` poll re-reads every live session on a ~30s tick (#2047),
765
+ * and while a transcript's mtime is unchanged its bytes are unchanged — so the
766
+ * parse (the expensive half) is memoized on mtime. The CLASSIFICATION is not:
767
+ * `inferSessionState` is re-run on every call against the current clock and the
768
+ * current `pidAlive`, because a time-based verdict (the 30-minute prose-question
769
+ * decay) must expire on schedule even while the bytes sit still — a memo keyed
770
+ * on mtime froze that verdict for as long as nobody typed (PHNX-3999). Bound the
771
+ * map so a long-lived daemon that sees thousands of sessions cannot retain them
772
+ * forever.
773
+ */
774
+ const LIVE_TAIL_CACHE_MAX = 512;
775
+ const liveTailCache = new Map();
776
+ /** Test seam: drop the in-process parsed-tail memo. */
772
777
  export function clearLiveSignalsCacheForTest() {
773
- liveSignalsCache.clear();
778
+ liveTailCache.clear();
779
+ }
780
+ /**
781
+ * Parse the transcript tail for one harness. Claude/Codex take the fast bounded
782
+ * byte-tail ({@link readSessionTailWithRaw}) — the hot path, and the only two
783
+ * that also yield throughput (their raw lines carry usage the event model drops).
784
+ * EVERY OTHER tracked harness (grok, droid, rush, gemini, kimi, hermes, opencode,
785
+ * antigravity, cursor) is parsed with its own parser. An opaque/untracked kind
786
+ * yields no events.
787
+ */
788
+ function parseLiveTail(kind, sessionFile, mtimeMs) {
789
+ if (kind === 'claude' || kind === 'codex') {
790
+ const { events, content } = readSessionTailWithRaw(sessionFile, kind);
791
+ const tokPerSec = events.length > 0 ? computeTokPerSec(content, kind) : 0;
792
+ return { mtimeMs, events, tokPerSec: tokPerSec > 0 ? tokPerSec : undefined };
793
+ }
794
+ if (isSessionTrackedAgent(kind))
795
+ return { mtimeMs, events: parseTailEventsForKind(kind, sessionFile) };
796
+ return { mtimeMs, events: [] };
774
797
  }
775
798
  /**
776
799
  * Derive the inferred state (working / waiting / idle + preview/badges) and, for
777
800
  * the two harnesses whose raw lines carry it, the output-token throughput.
778
801
  *
779
- * Claude/Codex take the fast bounded byte-tail ({@link readSessionTailWithRaw}) —
780
- * the hot path, and the only two that also yield throughput (their raw lines
781
- * carry usage the event model drops). EVERY OTHER tracked harness (grok, droid,
782
- * rush, gemini, kimi, hermes, opencode, antigravity, cursor) is parsed with its own
783
- * parser and run through the SAME {@link inferSessionState}, so a live
784
- * non-claude/codex agent gets a real working/waiting/idle instead of the blanket
785
- * `unknown` it used to fall through to. An opaque/untracked kind or an
802
+ * Every tracked harness runs through the SAME {@link inferSessionState}, so a
803
+ * live non-claude/codex agent gets a real working/waiting/idle instead of the
804
+ * blanket `unknown` it used to fall through to. An opaque/untracked kind or an
786
805
  * unreadable/empty transcript yields an empty signal set, and the caller's
787
806
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
788
807
  *
789
- * Unchanged-mtime hits reuse the previous result (see {@link liveSignalsCache})
790
- * so a 30s active-session poll does not re-tail every quiet transcript.
808
+ * An unchanged-mtime call reuses the parsed tail (see {@link liveTailCache}) so a
809
+ * 30s active-session poll does not re-tail every quiet transcript — but it always
810
+ * re-classifies against `nowMs`, so an elapsed-time verdict is never frozen.
791
811
  */
792
- export function computeLiveSignals(kind, sessionFile, cwd, pidAlive) {
812
+ export function computeLiveSignals(kind, sessionFile, cwd, pidAlive, nowMs = Date.now()) {
793
813
  if (!sessionFile)
794
814
  return {};
795
815
  let mtimeMs;
@@ -798,30 +818,17 @@ export function computeLiveSignals(kind, sessionFile, cwd, pidAlive) {
798
818
  }
799
819
  catch { /* vanished between calls */ }
800
820
  const cacheKey = `${kind}\0${sessionFile}\0${cwd ?? ''}`;
801
- const cached = liveSignalsCache.get(cacheKey);
802
- if (cached && cached.mtimeMs === mtimeMs && cached.pidAlive === pidAlive) {
803
- return cached.signals;
804
- }
805
- const ctx = { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS };
806
- let signals = {};
807
- if (kind === 'claude' || kind === 'codex') {
808
- const { events, content } = readSessionTailWithRaw(sessionFile, kind);
809
- if (events.length > 0) {
810
- const state = inferSessionState(events, ctx);
811
- const tokPerSec = computeTokPerSec(content, kind);
812
- signals = { state, tokPerSec: tokPerSec > 0 ? tokPerSec : undefined };
813
- }
814
- }
815
- else if (isSessionTrackedAgent(kind)) {
816
- const events = parseTailEventsForKind(kind, sessionFile);
817
- if (events.length > 0) {
818
- signals = { state: inferSessionState(events, ctx) };
819
- }
820
- }
821
- if (liveSignalsCache.size >= LIVE_SIGNALS_CACHE_MAX)
822
- liveSignalsCache.clear();
823
- liveSignalsCache.set(cacheKey, { mtimeMs, pidAlive, signals });
824
- return signals;
821
+ let tail = liveTailCache.get(cacheKey);
822
+ if (!tail || tail.mtimeMs !== mtimeMs) {
823
+ tail = parseLiveTail(kind, sessionFile, mtimeMs);
824
+ if (liveTailCache.size >= LIVE_TAIL_CACHE_MAX)
825
+ liveTailCache.clear();
826
+ liveTailCache.set(cacheKey, tail);
827
+ }
828
+ if (tail.events.length === 0)
829
+ return {};
830
+ const state = inferSessionState(tail.events, { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS, nowMs });
831
+ return { state, tokPerSec: tail.tokPerSec };
825
832
  }
826
833
  /** Map inferred activity onto the coarse ActiveStatus used by the renderer and counts. */
827
834
  function statusFromActivity(activity) {
@@ -850,6 +857,7 @@ function applyState(base, state, fallbackFile, pidAlive) {
850
857
  activity: state.activity,
851
858
  awaitingReason: state.awaitingReason,
852
859
  question: state.question,
860
+ lastEventMs: state.lastEventMs,
853
861
  plan: state.plan,
854
862
  todos: state.todos,
855
863
  tail: state.tail,
@@ -59,7 +59,7 @@ export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "firstUserMessage"
59
59
  * are short-window signals (preview, tok/s). The short snapshot TTL is the
60
60
  * only cache that may carry them — and only as a whole-row snapshot.
61
61
  */
62
- export declare const LIVE_STATUS_KEYS: readonly ["status", "activity", "preview", "tokPerSec", "awaitingReason", "question", "todos", "tail", "lastActivityMs", "hostLink", "presence", "pidAlive", "tmuxClients", "windowHeartbeatMs", "provenance", "rateLimited", "plan"];
62
+ export declare const LIVE_STATUS_KEYS: readonly ["status", "activity", "preview", "tokPerSec", "awaitingReason", "question", "todos", "tail", "lastActivityMs", "lastEventMs", "hostLink", "presence", "pidAlive", "tmuxClients", "windowHeartbeatMs", "provenance", "rateLimited", "plan"];
63
63
  /**
64
64
  * How long after the last reader heartbeat the daemon considers the journal
65
65
  * to have no active consumer and skips the expensive `ps`+`lsof` gather.
@@ -88,6 +88,7 @@ export const LIVE_STATUS_KEYS = [
88
88
  'todos',
89
89
  'tail',
90
90
  'lastActivityMs',
91
+ 'lastEventMs',
91
92
  'hostLink',
92
93
  'presence',
93
94
  'pidAlive',
@@ -18,6 +18,16 @@ import type { SessionAttachment, SessionEvent, TodoItem, TodoProgress } from './
18
18
  import { type ProducedArtifact } from './highlights.js';
19
19
  export type { TodoItem, TodoProgress };
20
20
  export type SessionActivity = 'working' | 'waiting_input' | 'idle';
21
+ /**
22
+ * Why a session is `waiting_input`. The state engine produces `question` and
23
+ * `plan_review` from transcript structure. `permission` is NOT inferred from a
24
+ * transcript any more (PHNX-3999): a tool call with no result looks the same
25
+ * whether the tool is still running or a permission dialog is up, and only the
26
+ * harness's own `permission_prompt` hook event (the feed block) can tell them
27
+ * apart. The value stays in the union because a row from a peer running an older
28
+ * CLI can still carry it; the attention reconciler treats such a row as an
29
+ * unverified claim, never as an approvable request.
30
+ */
21
31
  export type AwaitingReason = 'question' | 'plan_review' | 'permission';
22
32
  /** One discrete choice the agent offered the user. */
23
33
  export interface QuestionOption {
@@ -103,6 +113,15 @@ export interface SessionState {
103
113
  /** Last few assistant turns (most-recent last), one line each — panel context. */
104
114
  tail?: string[];
105
115
  lastActivityMs?: number;
116
+ /**
117
+ * Timestamp (ms) of the last meaningful transcript event — a message, tool
118
+ * call, tool result, thinking block, or error — when the harness stamped one
119
+ * (PHNX-3999). This is the transcript's own cursor: unlike the file mtime it does
120
+ * not move when the harness appends a hook-firing record or other bookkeeping
121
+ * line, so it is the evidence that the agent did (or did not) work past a given
122
+ * moment — what the attention reconciler compares a hook-raised prompt against.
123
+ */
124
+ lastEventMs?: number;
106
125
  pr?: DetectedPr;
107
126
  worktree?: DetectedWorktree;
108
127
  ticket?: DetectedTicket;
@@ -122,6 +141,13 @@ export interface StateContext {
122
141
  pidAlive?: boolean;
123
142
  /** Override the running window (defaults to 2 min, matching active.ts). */
124
143
  activeWindowMs?: number;
144
+ /**
145
+ * The clock every elapsed-time signal is measured against (default `Date.now()`).
146
+ * Injected so a cached parse can be re-classified against the CURRENT time — a
147
+ * time-based verdict (the prose-question decay) must expire even when the
148
+ * transcript bytes have not changed (PHNX-3999).
149
+ */
150
+ nowMs?: number;
125
151
  }
126
152
  /**
127
153
  * Derive live plan progress from a checklist tool call's args. Accepts Claude's