@phnx-labs/agents-cli 1.22.117 → 1.22.119
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.
- package/CHANGELOG.md +301 -0
- package/dist/bootstrap.js +4 -11
- package/dist/commands/accounts.js +7 -4
- package/dist/commands/browser.d.ts +52 -70
- package/dist/commands/browser.js +192 -3562
- package/dist/commands/daemon.d.ts +39 -2
- package/dist/commands/daemon.js +72 -73
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -1
- package/dist/commands/projects.js +92 -7
- package/dist/commands/repo.js +13 -49
- package/dist/commands/route.js +5 -5
- package/dist/commands/routines.js +39 -12
- package/dist/commands/sessions-render.d.ts +1 -1
- package/dist/commands/sessions-render.js +0 -1
- package/dist/commands/sessions.test-fixture.d.ts +0 -1
- package/dist/commands/sessions.test-fixture.js +0 -37
- package/dist/commands/setup-browser.d.ts +17 -12
- package/dist/commands/setup-browser.js +52 -87
- package/dist/commands/setup-preferences.d.ts +0 -17
- package/dist/commands/setup-preferences.js +18 -49
- package/dist/commands/setup.js +9 -9
- package/dist/commands/ssh.js +10 -1
- package/dist/commands/sync.js +6 -44
- package/dist/commands/teams.js +0 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +34 -6
- package/dist/commands/view.d.ts +12 -1
- package/dist/commands/view.js +47 -2
- package/dist/index.js +107 -26
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-catalog.d.ts +38 -0
- package/dist/lib/account-catalog.js +110 -26
- package/dist/lib/account-provider-registry.js +1 -1
- package/dist/lib/account-registry.d.ts +1 -1
- package/dist/lib/account-registry.js +36 -2
- package/dist/lib/accounting/rotate.js +22 -8
- package/dist/lib/accounting/usage-ingest.d.ts +6 -1
- package/dist/lib/accounting/usage-ingest.js +115 -45
- package/dist/lib/accounting/usage-sync.d.ts +134 -31
- package/dist/lib/accounting/usage-sync.js +227 -66
- package/dist/lib/accounting/usage.d.ts +16 -3
- package/dist/lib/accounting/usage.js +26 -8
- package/dist/lib/accounts/add.d.ts +3 -1
- package/dist/lib/accounts/add.js +24 -13
- package/dist/lib/accounts/slots.d.ts +8 -0
- package/dist/lib/accounts/slots.js +17 -0
- package/dist/lib/add-dir.js +0 -1
- package/dist/lib/agent-cli-commands.js +0 -1
- package/dist/lib/agent-spec/agents.d.ts +6 -7
- package/dist/lib/agent-spec/agents.js +7 -60
- package/dist/lib/auth-health.d.ts +69 -1
- package/dist/lib/auth-health.js +133 -4
- package/dist/lib/browser/context.d.ts +82 -0
- package/dist/lib/browser/context.js +88 -0
- package/dist/lib/browser/paths.d.ts +25 -0
- package/dist/lib/browser/paths.js +51 -0
- package/dist/lib/browser/record.d.ts +38 -0
- package/dist/lib/browser/record.js +57 -0
- package/dist/lib/browser/sessions-list.js +1 -2
- package/dist/lib/browser-client.d.ts +158 -0
- package/dist/lib/browser-client.js +234 -0
- package/dist/lib/channels/owner-sink.d.ts +1 -18
- package/dist/lib/channels/owner-sink.js +29 -82
- package/dist/lib/channels/providers/rush.d.ts +7 -7
- package/dist/lib/channels/providers/rush.js +127 -57
- package/dist/lib/claude-account-token.d.ts +7 -1
- package/dist/lib/claude-account-token.js +26 -4
- package/dist/lib/claude-statusline.d.ts +1 -1
- package/dist/lib/claude-statusline.js +15 -3
- package/dist/lib/commands.js +1 -1
- package/dist/lib/crabbox/lease.js +1 -1
- package/dist/lib/crabbox/runtimes.js +1 -3
- package/dist/lib/daemon/account-state-daemon-service.js +1 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
- package/dist/lib/daemon/auth-sync-service.js +37 -43
- package/dist/lib/daemon/daemon.d.ts +25 -14
- package/dist/lib/daemon/daemon.js +92 -154
- package/dist/lib/daemon/runner.d.ts +2 -2
- package/dist/lib/daemon/runner.js +32 -9
- package/dist/lib/daemon/self-update-service.d.ts +35 -34
- package/dist/lib/daemon/self-update-service.js +40 -36
- package/dist/lib/daemon/service.d.ts +12 -8
- package/dist/lib/daemon/supervisor.d.ts +65 -54
- package/dist/lib/daemon/supervisor.js +136 -171
- package/dist/lib/daemon/testdata/process-view-start.js +5 -2
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
- package/dist/lib/daemon/usage-sync-service.js +46 -78
- package/dist/lib/daemon-health.d.ts +22 -5
- package/dist/lib/daemon-health.js +47 -3
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +0 -10
- package/dist/lib/daemon-ticks.js +4 -2
- package/dist/lib/devices/doctor-findings.js +8 -11
- package/dist/lib/devices/harness-inventory.js +14 -5
- package/dist/lib/exec-account-home.js +5 -0
- package/dist/lib/exec.js +10 -13
- package/dist/lib/feed/events.js +0 -1
- package/dist/lib/feed/tool-activity.js +5 -2
- package/dist/lib/fleet-shared-state.d.ts +45 -0
- package/dist/lib/fleet-shared-state.js +78 -9
- package/dist/lib/fs-atomic.js +25 -0
- package/dist/lib/github/pr-mergeable.d.ts +4 -2
- package/dist/lib/github/pr-mergeable.js +5 -3
- package/dist/lib/github/project-prs.d.ts +58 -3
- package/dist/lib/github/project-prs.js +205 -7
- package/dist/lib/harness/adapters/cursor.js +14 -4
- package/dist/lib/harness-auth-capabilities.d.ts +4 -3
- package/dist/lib/harness-auth-capabilities.js +1 -2
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hooks/cache.d.ts +7 -0
- package/dist/lib/hooks/cache.js +27 -2
- package/dist/lib/hooks/install.js +7 -1
- package/dist/lib/hosts/ready.d.ts +38 -8
- package/dist/lib/hosts/ready.js +62 -14
- package/dist/lib/installations/active-check.d.ts +27 -0
- package/dist/lib/installations/active-check.js +61 -0
- package/dist/lib/installations/migrate.js +6 -3
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +32 -12
- package/dist/lib/installations/store.d.ts +9 -0
- package/dist/lib/installations/store.js +24 -0
- package/dist/lib/installations/update.js +21 -15
- package/dist/lib/installations/versions.js +15 -3
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/models.js +0 -2
- package/dist/lib/open-url.js +28 -54
- package/dist/lib/profiles.js +0 -2
- package/dist/lib/projects.d.ts +16 -0
- package/dist/lib/projects.js +31 -0
- package/dist/lib/refresh.js +1 -1
- package/dist/lib/routine-readiness.d.ts +35 -0
- package/dist/lib/routine-readiness.js +74 -16
- package/dist/lib/rules/compile.js +6 -0
- package/dist/lib/sandbox.js +0 -2
- package/dist/lib/secrets-policy.d.ts +89 -11
- package/dist/lib/secrets-policy.js +175 -28
- package/dist/lib/session/active.d.ts +17 -6
- package/dist/lib/session/active.js +105 -49
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +65 -18
- package/dist/lib/session/discover.js +1 -283
- package/dist/lib/session/mirror.d.ts +4 -3
- package/dist/lib/session/mirror.js +4 -3
- package/dist/lib/session/parse.d.ts +0 -2
- package/dist/lib/session/parse.js +1 -138
- package/dist/lib/session/pid-registry.d.ts +6 -3
- package/dist/lib/session/pid-registry.js +18 -8
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/throughput.d.ts +1 -3
- package/dist/lib/session/throughput.js +0 -20
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +2 -2
- package/dist/lib/sessions-client.d.ts +60 -1
- package/dist/lib/sessions-client.js +227 -4
- package/dist/lib/setup-tool-status.js +5 -10
- package/dist/lib/signin-badge.d.ts +3 -2
- package/dist/lib/signin-badge.js +6 -3
- package/dist/lib/smart-launch.js +4 -1
- package/dist/lib/staleness/detectors/commands.d.ts +1 -1
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
- package/dist/lib/staleness/detectors/rules.d.ts +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +1 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
- package/dist/lib/staleness/writers/commands.d.ts +1 -1
- package/dist/lib/staleness/writers/hooks.d.ts +1 -1
- package/dist/lib/staleness/writers/mcp.d.ts +1 -1
- package/dist/lib/staleness/writers/permissions.d.ts +1 -1
- package/dist/lib/staleness/writers/plugins.d.ts +1 -1
- package/dist/lib/staleness/writers/rules.d.ts +1 -1
- package/dist/lib/staleness/writers/skills.d.ts +1 -1
- package/dist/lib/staleness/writers/sources.js +5 -0
- package/dist/lib/staleness/writers/subagents.d.ts +1 -1
- package/dist/lib/staleness/writers/workflows.d.ts +1 -1
- package/dist/lib/state.d.ts +6 -4
- package/dist/lib/state.js +7 -17
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +1 -136
- package/dist/lib/teams/placement-probe.js +50 -15
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/types.d.ts +16 -1
- package/dist/lib/types.js +1 -1
- package/dist/lib/view-types.d.ts +32 -0
- package/dist/lib/watchdog/read.js +0 -1
- package/dist/lib/watchdog/rotate.js +1 -2
- package/dist/lib/watchdog/runner.js +2 -2
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdogTail.js +0 -31
- package/package.json +1 -1
- package/scripts/postinstall.js +8 -8
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -17
- package/dist/commands/browser-picker.d.ts +0 -18
- package/dist/commands/browser-picker.js +0 -97
- package/dist/lib/browser/arc-discovery.d.ts +0 -52
- package/dist/lib/browser/arc-discovery.js +0 -197
- package/dist/lib/browser/arc-dom.d.ts +0 -14
- package/dist/lib/browser/arc-dom.js +0 -121
- package/dist/lib/browser/caller-identity.d.ts +0 -40
- package/dist/lib/browser/caller-identity.js +0 -175
- package/dist/lib/browser/cdp.d.ts +0 -51
- package/dist/lib/browser/cdp.js +0 -254
- package/dist/lib/browser/chrome.d.ts +0 -141
- package/dist/lib/browser/chrome.js +0 -704
- package/dist/lib/browser/chromium-discovery.d.ts +0 -28
- package/dist/lib/browser/chromium-discovery.js +0 -96
- package/dist/lib/browser/devices.d.ts +0 -34
- package/dist/lib/browser/devices.js +0 -61
- package/dist/lib/browser/domain-skills.d.ts +0 -71
- package/dist/lib/browser/domain-skills.js +0 -195
- package/dist/lib/browser/drivers/arc.d.ts +0 -40
- package/dist/lib/browser/drivers/arc.js +0 -276
- package/dist/lib/browser/drivers/firefox.d.ts +0 -99
- package/dist/lib/browser/drivers/firefox.js +0 -377
- package/dist/lib/browser/drivers/local.d.ts +0 -55
- package/dist/lib/browser/drivers/local.js +0 -266
- package/dist/lib/browser/drivers/ssh.d.ts +0 -120
- package/dist/lib/browser/drivers/ssh.js +0 -467
- package/dist/lib/browser/editor.d.ts +0 -3
- package/dist/lib/browser/editor.js +0 -50
- package/dist/lib/browser/ffmpeg.d.ts +0 -12
- package/dist/lib/browser/ffmpeg.js +0 -184
- package/dist/lib/browser/firefox-discovery.d.ts +0 -69
- package/dist/lib/browser/firefox-discovery.js +0 -162
- package/dist/lib/browser/har.d.ts +0 -85
- package/dist/lib/browser/har.js +0 -77
- package/dist/lib/browser/hygiene.d.ts +0 -97
- package/dist/lib/browser/hygiene.js +0 -153
- package/dist/lib/browser/index.d.ts +0 -5
- package/dist/lib/browser/index.js +0 -5
- package/dist/lib/browser/input.d.ts +0 -7
- package/dist/lib/browser/input.js +0 -92
- package/dist/lib/browser/ipc.d.ts +0 -204
- package/dist/lib/browser/ipc.js +0 -1453
- package/dist/lib/browser/login-detection.d.ts +0 -87
- package/dist/lib/browser/login-detection.js +0 -274
- package/dist/lib/browser/profiles.d.ts +0 -422
- package/dist/lib/browser/profiles.js +0 -1157
- package/dist/lib/browser/refs.d.ts +0 -89
- package/dist/lib/browser/refs.js +0 -191
- package/dist/lib/browser/registry.d.ts +0 -70
- package/dist/lib/browser/registry.js +0 -252
- package/dist/lib/browser/remote-control.d.ts +0 -59
- package/dist/lib/browser/remote-control.js +0 -85
- package/dist/lib/browser/resolve-target.d.ts +0 -66
- package/dist/lib/browser/resolve-target.js +0 -264
- package/dist/lib/browser/runtime-state.d.ts +0 -291
- package/dist/lib/browser/runtime-state.js +0 -584
- package/dist/lib/browser/secret-ref.d.ts +0 -10
- package/dist/lib/browser/secret-ref.js +0 -14
- package/dist/lib/browser/service.d.ts +0 -812
- package/dist/lib/browser/service.js +0 -4693
- package/dist/lib/browser/stream.d.ts +0 -17
- package/dist/lib/browser/stream.js +0 -72
- package/dist/lib/browser/task-index.d.ts +0 -78
- package/dist/lib/browser/task-index.js +0 -199
- package/dist/lib/browser/types.d.ts +0 -647
- package/dist/lib/browser/types.js +0 -61
- package/dist/lib/browser/upload.d.ts +0 -29
- package/dist/lib/browser/upload.js +0 -298
- package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
- package/dist/lib/daemon/browser-ipc-service.js +0 -46
- package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
- package/dist/lib/daemon/browser-task-reap-service.js +0 -26
- package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
- 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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
350
|
-
* (
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
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(
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
if (
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
|
@@ -52,10 +52,10 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
|
|
|
52
52
|
* process table. `alive` receives the row's `startedAtMs` so callers can apply
|
|
53
53
|
* the same pid-reuse guard {@link isPidAlive} uses elsewhere.
|
|
54
54
|
*/
|
|
55
|
-
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
|
|
55
|
+
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean, ppidMap?: Map<number, number>): ActiveSession[];
|
|
56
56
|
type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
57
57
|
/** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
|
|
58
|
-
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
|
|
58
|
+
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'accountId' | 'accountKey' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
|
|
59
59
|
/**
|
|
60
60
|
* Fold index-only enrichment onto live rows, then RE-DERIVE the recap for every
|
|
61
61
|
* row it touched. The re-derive is load-bearing: `foldRecap` runs inside
|
|
@@ -65,6 +65,8 @@ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp'
|
|
|
65
65
|
* backfill call sites is what keeps them from drifting apart.
|
|
66
66
|
*/
|
|
67
67
|
export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
|
|
68
|
+
/** Resolve a display name only when the indexed identity names one registered slot. */
|
|
69
|
+
export declare function sessionAccountLabel(kind: string, session: Pick<SessionMeta, 'accountId' | 'accountKey' | 'account'>, accounts?: import("../account-registry.js").NativeAccount[]): string | undefined;
|
|
68
70
|
export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
|
|
69
71
|
export declare function isRunningLiveSession(s: ActiveSession): boolean;
|
|
70
72
|
export declare function activeSessionProjectKey(s: Pick<ActiveSession, 'cwd' | 'context'>): string;
|
|
@@ -319,6 +321,13 @@ export interface ActiveSession {
|
|
|
319
321
|
* `accountKey`.
|
|
320
322
|
*/
|
|
321
323
|
account?: string;
|
|
324
|
+
/**
|
|
325
|
+
* Human account-slot name (e.g. `gmail` in `claude#gmail`), display-only.
|
|
326
|
+
* Backfilled alongside {@link account} from indexed accountId/accountKey/email
|
|
327
|
+
* and the account registry. Unknown or ambiguous slots stay undefined.
|
|
328
|
+
* Never group on this label; use the index's `accountKey`.
|
|
329
|
+
*/
|
|
330
|
+
accountLabel?: string;
|
|
322
331
|
/**
|
|
323
332
|
* Last-activity epoch — the transcript's last write (mtime). Distinct from
|
|
324
333
|
* {@link startedAtMs} (session START): a session begun 3h ago but last touched
|
|
@@ -486,7 +495,7 @@ export interface ActiveSession {
|
|
|
486
495
|
};
|
|
487
496
|
/**
|
|
488
497
|
* The editor tab that launched this agent (`AGENT_TERMINAL_ID`), from the pid
|
|
489
|
-
* registry. This is the one identifier that survives an SSH hop AND a session
|
|
498
|
+
* registry or the published terminal entry. This is the one identifier that survives an SSH hop AND a session
|
|
490
499
|
* rotation: a Factory tab offloaded to a device has no local process to inspect,
|
|
491
500
|
* and its spawn-time session id goes stale the moment the agent moves to another
|
|
492
501
|
* session (`/clear`, exit-and-rerun), so `--active --device <device>` joined on
|
|
@@ -494,6 +503,8 @@ export interface ActiveSession {
|
|
|
494
503
|
* did not inherit a terminal id.
|
|
495
504
|
*/
|
|
496
505
|
terminalId?: string;
|
|
506
|
+
/** Published editor tab index, retained across agent restarts. */
|
|
507
|
+
tabIndex?: number;
|
|
497
508
|
/**
|
|
498
509
|
* The launch id (`AGENT_LAUNCH_ID`) the CLI stamps on every agent at spawn — a
|
|
499
510
|
* stable UUID that is identical locally and across an SSH hop and survives a
|
|
@@ -501,9 +512,8 @@ export interface ActiveSession {
|
|
|
501
512
|
* non-Claude harnesses only mint after boot) it exists from the first tick, so
|
|
502
513
|
* it is the join key a client uses to re-identify a session on the watch stream.
|
|
503
514
|
* Populated wherever the by-pid launch registry resolves — reliably for
|
|
504
|
-
* `agents run`-launched processes
|
|
505
|
-
*
|
|
506
|
-
* limitation the `readPidSessionEntry` call in `listTerminalsActive` documents).
|
|
515
|
+
* `agents run`-launched processes and editor terminals with a recorded live
|
|
516
|
+
* agent descendant.
|
|
507
517
|
*/
|
|
508
518
|
launchId?: string;
|
|
509
519
|
/**
|
|
@@ -750,6 +760,7 @@ interface ProcRow {
|
|
|
750
760
|
tty?: string;
|
|
751
761
|
comm: string;
|
|
752
762
|
kind?: string;
|
|
763
|
+
startTime?: string;
|
|
753
764
|
}
|
|
754
765
|
/** Parse `ConvertTo-Csv` output of Win32_Process rows. Exported for tests. */
|
|
755
766
|
export declare function parseWin32ProcessCsv(out: string): ProcRow[];
|
|
@@ -24,7 +24,9 @@ import { execFile, execFileSync } from 'child_process';
|
|
|
24
24
|
import { promisify } from 'util';
|
|
25
25
|
import { listActiveTasks } from '../cloud/store.js';
|
|
26
26
|
import { AgentManager } from '../teams/agents.js';
|
|
27
|
-
import { getTerminalsDir } from '../state.js';
|
|
27
|
+
import { getTerminalsDir, readMeta } from '../state.js';
|
|
28
|
+
import { listNativeAccounts } from '../account-registry.js';
|
|
29
|
+
import { parseNativeIdentityKey } from '../native-accounts.js';
|
|
28
30
|
import { readLivePidSessionEntry as readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry, sessionIdFromLivePid, isSessionIdShape, } from './pid-registry.js';
|
|
29
31
|
import { readSessionActorRecord, writeSessionAliasRecord } from './actor-sidecar.js';
|
|
30
32
|
import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
|
|
@@ -129,11 +131,11 @@ export function attributedSetLostPids(prev, next) {
|
|
|
129
131
|
* process table. `alive` receives the row's `startedAtMs` so callers can apply
|
|
130
132
|
* the same pid-reuse guard {@link isPidAlive} uses elsewhere.
|
|
131
133
|
*/
|
|
132
|
-
export function filterCachedUnattributed(sessions, attributed, alive) {
|
|
134
|
+
export function filterCachedUnattributed(sessions, attributed, alive, ppidMap) {
|
|
133
135
|
return sessions.filter((s) => {
|
|
134
136
|
if (s.pid == null)
|
|
135
137
|
return false;
|
|
136
|
-
if (attributed.has(s.pid))
|
|
138
|
+
if (attributed.has(s.pid) || (ppidMap && hasAttributedAncestor(s.pid, ppidMap, attributed)))
|
|
137
139
|
return false;
|
|
138
140
|
return alive(s.pid, s.startedAtMs);
|
|
139
141
|
});
|
|
@@ -147,6 +149,7 @@ export function filterCachedUnattributed(sessions, attributed, alive) {
|
|
|
147
149
|
* backfill call sites is what keeps them from drifting apart.
|
|
148
150
|
*/
|
|
149
151
|
export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
152
|
+
const accounts = listNativeAccounts(readMeta());
|
|
150
153
|
for (const s of sessions) {
|
|
151
154
|
if (!s.sessionId)
|
|
152
155
|
continue;
|
|
@@ -157,6 +160,7 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
157
160
|
s.version = m.version;
|
|
158
161
|
if (!s.account && m.account)
|
|
159
162
|
s.account = m.account;
|
|
163
|
+
s.accountLabel ??= sessionAccountLabel(s.kind, m, accounts);
|
|
160
164
|
if (!s.label && m.label)
|
|
161
165
|
s.label = m.label;
|
|
162
166
|
if (!s.firstUserMessage && m.firstUserMessage)
|
|
@@ -196,6 +200,30 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
196
200
|
applyRecap(s);
|
|
197
201
|
}
|
|
198
202
|
}
|
|
203
|
+
/** Resolve a display name only when the indexed identity names one registered slot. */
|
|
204
|
+
export function sessionAccountLabel(kind, session, accounts = listNativeAccounts(readMeta())) {
|
|
205
|
+
const matches = accounts.filter(account => {
|
|
206
|
+
if (account.agent !== kind)
|
|
207
|
+
return false;
|
|
208
|
+
if (session.accountId)
|
|
209
|
+
return account.id === session.accountId;
|
|
210
|
+
if (session.accountKey && !session.accountKey.startsWith('unattributed:')) {
|
|
211
|
+
// Claude's index groups quota by org; the registry names a login within it.
|
|
212
|
+
const indexed = parseNativeIdentityKey(account.agent, session.accountKey);
|
|
213
|
+
const emailMatches = !!session.account && account.identityLabel?.toLowerCase() === session.account.toLowerCase();
|
|
214
|
+
if (!indexed || (!indexed.account && !emailMatches))
|
|
215
|
+
return false;
|
|
216
|
+
if (account.identityKey === session.accountKey)
|
|
217
|
+
return true;
|
|
218
|
+
const registered = parseNativeIdentityKey(account.agent, account.identityKey);
|
|
219
|
+
return !!registered
|
|
220
|
+
&& Object.entries(indexed).every(([key, value]) => registered[key] === value)
|
|
221
|
+
&& (!session.account || emailMatches);
|
|
222
|
+
}
|
|
223
|
+
return !!session.account && account.identityLabel?.toLowerCase() === session.account.toLowerCase();
|
|
224
|
+
});
|
|
225
|
+
return matches.length === 1 ? matches[0].name : undefined;
|
|
226
|
+
}
|
|
199
227
|
function loadBackfillMetaFor(sessions) {
|
|
200
228
|
const byId = new Map();
|
|
201
229
|
try {
|
|
@@ -1062,49 +1090,48 @@ export async function listTerminalsActive() {
|
|
|
1062
1090
|
const procByPid = new Map();
|
|
1063
1091
|
for (const r of await readProcessTable())
|
|
1064
1092
|
procByPid.set(r.pid, r);
|
|
1093
|
+
const children = childrenByParent(new Map([...procByPid.values()].map(r => [r.pid, r.ppid])));
|
|
1065
1094
|
// Build label map from Claude's sessions/*.json for /rename support
|
|
1066
1095
|
const labelMap = buildClaudeLabelMap();
|
|
1067
1096
|
// Run-name handles (`agents run --name`) keyed by session id, for the same
|
|
1068
1097
|
// sessionId → handle resolution as labels.
|
|
1069
1098
|
const runNameMap = buildRunNameMap();
|
|
1070
1099
|
return entries.map((t) => {
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
// back to the stale cached id — the duplicate-card fix comes from
|
|
1078
|
-
// pickSessionFile no longer borrowing a sibling, not from this lookup. Kept as
|
|
1079
|
-
// a forward-looking hook for the cases where the pid does line up.
|
|
1080
|
-
const pidEntry = readPidSessionEntry(t.pid);
|
|
1100
|
+
// Keep the shell pid for tab ownership and headless suppression; the live
|
|
1101
|
+
// agent below it owns the session after an exit-and-rerun in the same tab.
|
|
1102
|
+
const directEntry = readPidSessionEntry(t.pid, procByPid.get(t.pid)?.startTime);
|
|
1103
|
+
const candidate = directEntry?.sessionId ? directEntry
|
|
1104
|
+
: (!t.pidDead ? terminalDescendantEntry(t.pid, procByPid, children) : undefined) ?? directEntry;
|
|
1105
|
+
const pidEntry = candidate && (!isSessionTrackedAgent(t.kind) || candidate.agent === t.kind) ? candidate : undefined;
|
|
1081
1106
|
const resolvedId = pidEntry?.sessionId ?? t.sessionId;
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1107
|
+
const cwd = pidEntry?.cwd ?? t.cwd ?? undefined;
|
|
1108
|
+
const sessionKind = pidEntry?.agent ?? t.kind;
|
|
1109
|
+
const sessionFile = findSessionFileForKind(sessionKind, cwd, resolvedId);
|
|
1110
|
+
const label = t.label ?? (resolvedId ? labelMap.get(resolvedId) : undefined) ?? undefined;
|
|
1085
1111
|
// Durable run name from `agents run --name`, resolved by the run's session id.
|
|
1086
1112
|
const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
|
|
1087
1113
|
// Extract topic from session file (first meaningful user message)
|
|
1088
1114
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
1089
1115
|
const pidAlive = isPidAlive(t.pid, t.startedAtMs);
|
|
1090
|
-
const { state, tokPerSec } = computeLiveSignals(
|
|
1116
|
+
const { state, tokPerSec } = computeLiveSignals(sessionKind, sessionFile, cwd, pidAlive);
|
|
1091
1117
|
return applyState({
|
|
1092
1118
|
context: 'terminal',
|
|
1093
|
-
kind:
|
|
1119
|
+
kind: sessionKind,
|
|
1094
1120
|
harness: pidEntry?.harness,
|
|
1095
1121
|
host: detectHost(t.pid, procByPid),
|
|
1096
1122
|
tty: procByPid.get(t.pid)?.tty,
|
|
1097
1123
|
pid: t.pid,
|
|
1098
|
-
sessionId:
|
|
1124
|
+
sessionId: resolvedId ?? sessionIdFromFile(sessionFile),
|
|
1099
1125
|
launchId: pidEntry?.launchId,
|
|
1100
|
-
terminalId: pidEntry?.terminalId,
|
|
1101
|
-
|
|
1126
|
+
terminalId: pidEntry?.terminalId ?? t.terminalId,
|
|
1127
|
+
tabIndex: t.tabIndex,
|
|
1128
|
+
cwd,
|
|
1102
1129
|
label,
|
|
1103
1130
|
name,
|
|
1104
1131
|
topic,
|
|
1105
1132
|
tokPerSec,
|
|
1106
1133
|
sessionFile,
|
|
1107
|
-
startedAtMs: t.startedAtMs,
|
|
1134
|
+
startedAtMs: pidEntry?.startedAtMs ?? t.startedAtMs,
|
|
1108
1135
|
lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
|
|
1109
1136
|
windowId: t.windowId,
|
|
1110
1137
|
windowHeartbeatMs: t.windowHeartbeatMs,
|
|
@@ -1183,16 +1210,16 @@ async function readProcessTableLive() {
|
|
|
1183
1210
|
return readProcessTableWin32();
|
|
1184
1211
|
let out;
|
|
1185
1212
|
try {
|
|
1186
|
-
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
|
|
1213
|
+
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,lstart=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
|
|
1187
1214
|
}
|
|
1188
1215
|
catch {
|
|
1189
1216
|
return [];
|
|
1190
1217
|
}
|
|
1191
1218
|
const rows = [];
|
|
1192
1219
|
for (const line of out.split('\n')) {
|
|
1193
|
-
// pid ppid tty comm — tty is a single token ('ttys003', 's003', or '??'/'?'
|
|
1220
|
+
// pid ppid tty lstart comm — tty is a single token ('ttys003', 's003', or '??'/'?'
|
|
1194
1221
|
// for none); comm stays last so it may contain spaces.
|
|
1195
|
-
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
1222
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s+(.+)$/);
|
|
1196
1223
|
if (!m)
|
|
1197
1224
|
continue;
|
|
1198
1225
|
const pid = parseInt(m[1], 10);
|
|
@@ -1201,8 +1228,8 @@ async function readProcessTableLive() {
|
|
|
1201
1228
|
continue;
|
|
1202
1229
|
const ttyRaw = m[3];
|
|
1203
1230
|
const tty = ttyRaw === '??' || ttyRaw === '?' || ttyRaw === '-' ? undefined : ttyRaw;
|
|
1204
|
-
const commRaw = m[
|
|
1205
|
-
rows.push({ pid, ppid, tty, comm: commRaw, kind: agentKindFromComm(commRaw) });
|
|
1231
|
+
const commRaw = m[5].trim();
|
|
1232
|
+
rows.push({ pid, ppid, tty, startTime: m[4], comm: commRaw, kind: agentKindFromComm(commRaw) });
|
|
1206
1233
|
}
|
|
1207
1234
|
return rows;
|
|
1208
1235
|
}
|
|
@@ -1240,6 +1267,50 @@ export function parseWin32ProcessCsv(out) {
|
|
|
1240
1267
|
}
|
|
1241
1268
|
return rows;
|
|
1242
1269
|
}
|
|
1270
|
+
function childrenByParent(ppidMap) {
|
|
1271
|
+
const children = new Map();
|
|
1272
|
+
for (const [pid, ppid] of ppidMap) {
|
|
1273
|
+
const siblings = children.get(ppid);
|
|
1274
|
+
if (siblings)
|
|
1275
|
+
siblings.push(pid);
|
|
1276
|
+
else
|
|
1277
|
+
children.set(ppid, [pid]);
|
|
1278
|
+
}
|
|
1279
|
+
return children;
|
|
1280
|
+
}
|
|
1281
|
+
function terminalDescendantEntry(pid, processes, children) {
|
|
1282
|
+
// Windows only proves pidExists: a recycled PID can be a live shell descendant.
|
|
1283
|
+
// Retain the published identity until that platform can prove process starts.
|
|
1284
|
+
if (process.platform === 'win32')
|
|
1285
|
+
return undefined;
|
|
1286
|
+
let pending = children.get(pid) ?? [];
|
|
1287
|
+
const seen = new Set([pid]);
|
|
1288
|
+
while (pending.length) {
|
|
1289
|
+
const next = [];
|
|
1290
|
+
let recordedAgent = false;
|
|
1291
|
+
let newest;
|
|
1292
|
+
for (const child of pending) {
|
|
1293
|
+
if (seen.has(child))
|
|
1294
|
+
continue;
|
|
1295
|
+
seen.add(child);
|
|
1296
|
+
const entry = readPidSessionEntry(child, processes.get(child)?.startTime);
|
|
1297
|
+
if (!entry || !isSessionTrackedAgent(entry.agent)) {
|
|
1298
|
+
next.push(...(children.get(child) ?? []));
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
// A recorded agent is a boundary even before its session ID is known:
|
|
1302
|
+
// its tools may launch agents of their own, which never own this tab.
|
|
1303
|
+
recordedAgent = true;
|
|
1304
|
+
if (entry.sessionId && (!newest || entry.startedAtMs > newest.startedAtMs
|
|
1305
|
+
|| (entry.startedAtMs === newest.startedAtMs && entry.pid > newest.pid)))
|
|
1306
|
+
newest = entry;
|
|
1307
|
+
}
|
|
1308
|
+
if (recordedAgent)
|
|
1309
|
+
return newest;
|
|
1310
|
+
pending = next;
|
|
1311
|
+
}
|
|
1312
|
+
return undefined;
|
|
1313
|
+
}
|
|
1243
1314
|
/**
|
|
1244
1315
|
* True when any ancestor in pid's parent chain is a known attributed PID.
|
|
1245
1316
|
* VS Code / Cursor terminals store the *shell* PID in live-terminals.json,
|
|
@@ -1456,11 +1527,11 @@ export async function listUnattributedActive(attributed) {
|
|
|
1456
1527
|
if (unattributedCache &&
|
|
1457
1528
|
now - unattributedCache.at < UNATTRIBUTED_RESCAN_MS &&
|
|
1458
1529
|
!attributedSetLostPids(unattributedCache.attributed, attributed)) {
|
|
1459
|
-
return filterCachedUnattributed(unattributedCache.sessions, attributed, isPidAlive);
|
|
1530
|
+
return filterCachedUnattributed(unattributedCache.sessions, attributed, isPidAlive, unattributedCache.ppidMap);
|
|
1460
1531
|
}
|
|
1461
|
-
const
|
|
1462
|
-
unattributedCache = { at: now, attributed: new Set(attributed),
|
|
1463
|
-
return
|
|
1532
|
+
const result = await listUnattributedActiveLive(attributed);
|
|
1533
|
+
unattributedCache = { at: now, attributed: new Set(attributed), ...result };
|
|
1534
|
+
return result.sessions;
|
|
1464
1535
|
}
|
|
1465
1536
|
/** Unthrottled headless scan (live process table + cwd probes). */
|
|
1466
1537
|
async function listUnattributedActiveLive(attributed) {
|
|
@@ -1495,28 +1566,13 @@ async function listUnattributedActiveLive(attributed) {
|
|
|
1495
1566
|
// lacks an exact launch-time id, so an all-Claude set does neither. The ~3s
|
|
1496
1567
|
// poll must not re-read the dir (or re-invert the map) per candidate.
|
|
1497
1568
|
let hookIndex;
|
|
1498
|
-
let
|
|
1569
|
+
let children;
|
|
1499
1570
|
// Durable `agents run --name` handles keyed by session id — the same source the
|
|
1500
1571
|
// terminal path uses to name a row. Headless agents have no live-terminals
|
|
1501
1572
|
// label and no /rename, so without this a `--name`d headless run would surface
|
|
1502
1573
|
// with only a topic and no tab title. Built once per scan.
|
|
1503
1574
|
const runNameMap = buildRunNameMap();
|
|
1504
|
-
const ensureChildren = () =>
|
|
1505
|
-
if (childrenByParent)
|
|
1506
|
-
return childrenByParent;
|
|
1507
|
-
const m = new Map();
|
|
1508
|
-
// The hook records under the agent pid; a wrapper/shell pid we recorded has
|
|
1509
|
-
// the agent as a child, so we resolve via a recorded pid's immediate children.
|
|
1510
|
-
for (const [childPid, parentPid] of ppidMap) {
|
|
1511
|
-
const arr = m.get(parentPid);
|
|
1512
|
-
if (arr)
|
|
1513
|
-
arr.push(childPid);
|
|
1514
|
-
else
|
|
1515
|
-
m.set(parentPid, [childPid]);
|
|
1516
|
-
}
|
|
1517
|
-
childrenByParent = m;
|
|
1518
|
-
return m;
|
|
1519
|
-
};
|
|
1575
|
+
const ensureChildren = () => children ??= childrenByParent(ppidMap);
|
|
1520
1576
|
const out = [];
|
|
1521
1577
|
for (let i = 0; i < kept.length; i++) {
|
|
1522
1578
|
const { pid, kind } = kept[i];
|
|
@@ -1603,7 +1659,7 @@ async function listUnattributedActiveLive(attributed) {
|
|
|
1603
1659
|
}
|
|
1604
1660
|
// Housekeeping: drop registry files for pids that have since died.
|
|
1605
1661
|
prunePidSessionRegistry(isPidAlive);
|
|
1606
|
-
return out;
|
|
1662
|
+
return { sessions: out, ppidMap };
|
|
1607
1663
|
}
|
|
1608
1664
|
/**
|
|
1609
1665
|
* Attribute a single tmux pane to the agent actually running in it.
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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 =
|
|
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
|