@phnx-labs/agents-cli 1.22.66 → 1.22.68
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 +26 -0
- package/README.md +7 -2
- package/dist/bootstrap.js +3 -3
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +40 -0
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +33 -9
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +11 -3
- package/dist/lib/accounting/usage.js +23 -10
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/exec.js +11 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +9 -2
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +13 -0
- package/dist/lib/run-trace-sync.js +77 -0
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +119 -101
- package/dist/lib/session/active.d.ts +3 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +5 -2
- package/dist/lib/session/db.js +54 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +106 -25
- package/dist/lib/session/prompt.d.ts +11 -0
- package/dist/lib/session/prompt.js +25 -0
- package/dist/lib/session/recovery.d.ts +30 -2
- package/dist/lib/session/recovery.js +56 -4
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +185 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/publish.d.ts +55 -9
- package/dist/lib/share/publish.js +87 -9
- package/dist/lib/share/worker-template.js +100 -8
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/package.json +1 -1
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
|
@@ -1,129 +1,147 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fleet sync of the reserved file-backed `auth` bundle (PHNX-2371).
|
|
2
|
+
* Fleet sync of the reserved file-backed `auth` bundle (PHNX-2371/PHNX-3609).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Double-fire: each device's daemon only writes that device's own store (the
|
|
11
|
-
* destination). Two boxes that both have `auth` skip each other (already
|
|
12
|
-
* present). A box without `auth` is a pull target for a provisioned peer.
|
|
13
|
-
*
|
|
14
|
-
* The planner is pure so tests cover every skip/push branch with no SSH.
|
|
4
|
+
* Every daemon publishes only a safe auth readiness verdict into its owned
|
|
5
|
+
* fleet-shared state file. A deterministic ready publisher reads those verdicts
|
|
6
|
+
* and transfers the actual bundle only to a peer reporting `missing`. Secret
|
|
7
|
+
* values never enter Git; the exceptional provisioning transfer remains SSH,
|
|
8
|
+
* now async and kill-bounded instead of blocking the daemon event loop.
|
|
15
9
|
*/
|
|
16
10
|
import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle } from './bundles.js';
|
|
17
|
-
import {
|
|
18
|
-
import { loadDevicesSync } from '../devices/registry.js';
|
|
11
|
+
import { pushBundleToHostAsync } from './push.js';
|
|
12
|
+
import { isDialableDevice, loadDevicesSync } from '../devices/registry.js';
|
|
19
13
|
import { sshTargetFor } from '../devices/connect.js';
|
|
20
14
|
import { isHostPinned, isDevicePinned, managedKnownHostsPath } from '../devices/known-hosts.js';
|
|
21
15
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
22
|
-
import {
|
|
23
|
-
|
|
16
|
+
import { readFleetSharedDeviceStates, updateFleetSharedDeviceState, } from '../fleet-shared-state.js';
|
|
17
|
+
import { getUserAgentsDir } from '../state.js';
|
|
18
|
+
/** Each import/read-back SSH operation gets this deadline plus the SSH hard-kill grace. */
|
|
19
|
+
export const AUTH_SYNC_PUSH_DEADLINE_MS = 20_000;
|
|
20
|
+
export function planAuthBundlePush(localAuthOk, localIsPublisher, devices) {
|
|
24
21
|
if (!localAuthOk) {
|
|
25
|
-
return devices.map((
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}));
|
|
22
|
+
return devices.map((device) => ({ action: 'skip', device: device.name, reason: 'no local file-backed auth bundle' }));
|
|
23
|
+
}
|
|
24
|
+
if (!localIsPublisher) {
|
|
25
|
+
return devices.map((device) => ({ action: 'skip', device: device.name, reason: 'another ready device is the elected auth publisher' }));
|
|
30
26
|
}
|
|
31
|
-
return devices.map((
|
|
32
|
-
if (!
|
|
33
|
-
return { action: 'skip', device:
|
|
34
|
-
if (!
|
|
35
|
-
return {
|
|
36
|
-
action: 'skip',
|
|
37
|
-
device: d.name,
|
|
38
|
-
reason: `host key not pinned; run \`agents ssh ${d.name}\` once`,
|
|
39
|
-
};
|
|
27
|
+
return devices.map((device) => {
|
|
28
|
+
if (!device.reachable)
|
|
29
|
+
return { action: 'skip', device: device.name, reason: 'unreachable' };
|
|
30
|
+
if (!device.pinned) {
|
|
31
|
+
return { action: 'skip', device: device.name, reason: `host key not pinned; run \`agents ssh ${device.name}\` once` };
|
|
40
32
|
}
|
|
41
|
-
if (
|
|
42
|
-
return { action: 'skip', device:
|
|
43
|
-
|
|
33
|
+
if (device.remoteAuth === 'ready')
|
|
34
|
+
return { action: 'skip', device: device.name, reason: 'already present' };
|
|
35
|
+
if (device.remoteAuth === 'invalid')
|
|
36
|
+
return { action: 'skip', device: device.name, reason: 'remote auth bundle uses the wrong backend' };
|
|
37
|
+
if (device.remoteAuth === 'unknown') {
|
|
38
|
+
return { action: 'skip', device: device.name, reason: 'no shared auth verdict has arrived from this peer' };
|
|
39
|
+
}
|
|
40
|
+
return { action: 'push', device: device.name };
|
|
44
41
|
});
|
|
45
42
|
}
|
|
46
|
-
/**
|
|
47
|
-
* Map a `fleet apply` device probe onto the auth-sync skip/push inputs.
|
|
48
|
-
*
|
|
49
|
-
* Presence is an own-property check on `remoteBundles` (same gate as
|
|
50
|
-
* `decideSecretPush`): a missing listing, a parse failure, or a prototype
|
|
51
|
-
* name must all mean "not present, so push" — never "present, so skip".
|
|
52
|
-
*/
|
|
53
|
-
export function authPresenceFromProbe(probe) {
|
|
54
|
-
return {
|
|
55
|
-
reachable: probe.reachable,
|
|
56
|
-
remoteHasAuth: !!(probe.remoteBundles &&
|
|
57
|
-
Object.prototype.hasOwnProperty.call(probe.remoteBundles, AUTH_BUNDLE_NAME)),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/** Production default: live SSH probe of reachability + `secrets list --json`. */
|
|
61
|
-
export function defaultAuthSyncProbe(device) {
|
|
62
|
-
return authPresenceFromProbe(probeDevice(device, { withSecrets: true }));
|
|
63
|
-
}
|
|
64
43
|
function defaultDevices() {
|
|
65
44
|
return Object.values(loadDevicesSync());
|
|
66
45
|
}
|
|
46
|
+
function authStatus(local) {
|
|
47
|
+
if (!local.exists)
|
|
48
|
+
return 'missing';
|
|
49
|
+
return local.ok ? 'ready' : 'invalid';
|
|
50
|
+
}
|
|
51
|
+
/** Publish only safe readiness metadata; useful immediately before a repo push. */
|
|
52
|
+
export function publishReservedAuthVerdict(options = {}) {
|
|
53
|
+
const local = (options.inspectLocal ?? inspectReservedAuthBundle)();
|
|
54
|
+
const status = authStatus(local);
|
|
55
|
+
const device = options.localName ?? machineId();
|
|
56
|
+
try {
|
|
57
|
+
const write = updateFleetSharedDeviceState(device, { auth: { status } }, options.userAgentsDir ?? getUserAgentsDir());
|
|
58
|
+
return { device, status, changed: write.changed, error: null };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
return { device, status, changed: false, error: err.message };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
67
64
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* the wrong backend (doctor flags that separately).
|
|
65
|
+
* Publish local readiness, elect one ready source, then asynchronously provision
|
|
66
|
+
* only peers whose shared verdict says the bundle is missing.
|
|
71
67
|
*/
|
|
72
|
-
export function syncReservedAuthBundle(deps = {}) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
export async function syncReservedAuthBundle(deps = {}) {
|
|
69
|
+
const result = {
|
|
70
|
+
publisher: null,
|
|
71
|
+
stateChanged: false,
|
|
72
|
+
pushed: [],
|
|
73
|
+
skipped: [],
|
|
74
|
+
errors: [],
|
|
75
|
+
};
|
|
76
|
+
const published = publishReservedAuthVerdict(deps);
|
|
77
|
+
const localStatus = published.status;
|
|
78
|
+
const localName = published.device;
|
|
79
|
+
const localNorm = normalizeHost(localName);
|
|
80
|
+
const root = deps.userAgentsDir ?? getUserAgentsDir();
|
|
81
|
+
const devices = (deps.listDevices ?? defaultDevices)().filter((device) => normalizeHost(device.name) !== localNorm);
|
|
82
|
+
result.stateChanged = published.changed;
|
|
83
|
+
if (published.error)
|
|
84
|
+
result.errors.push({ device: localName, message: `could not publish auth verdict: ${published.error}` });
|
|
85
|
+
const read = readFleetSharedDeviceStates(root);
|
|
86
|
+
result.errors.push(...read.errors);
|
|
87
|
+
const stateByDevice = new Map(read.states.map((state) => [normalizeHost(state.device), state]));
|
|
88
|
+
const registered = new Map(devices.map((device) => [normalizeHost(device.name), device]));
|
|
89
|
+
const readyPublishers = read.states
|
|
90
|
+
.filter((state) => {
|
|
91
|
+
if (state.auth?.status !== 'ready')
|
|
92
|
+
return false;
|
|
93
|
+
if (normalizeHost(state.device) === localNorm)
|
|
94
|
+
return localStatus === 'ready';
|
|
95
|
+
const device = registered.get(normalizeHost(state.device));
|
|
96
|
+
// Removed and known-offline device files are stale observations, not live
|
|
97
|
+
// executors. Letting one win election would suppress provisioning forever.
|
|
98
|
+
return !!device && isDialableDevice(device);
|
|
99
|
+
})
|
|
100
|
+
.map((state) => state.device);
|
|
101
|
+
if (localStatus === 'ready' && !readyPublishers.some((name) => normalizeHost(name) === localNorm)) {
|
|
102
|
+
readyPublishers.push(localName);
|
|
103
|
+
}
|
|
104
|
+
readyPublishers.sort((a, b) => normalizeHost(a).localeCompare(normalizeHost(b)));
|
|
105
|
+
result.publisher = readyPublishers[0] ?? null;
|
|
106
|
+
const localIsPublisher = result.publisher !== null && normalizeHost(result.publisher) === localNorm;
|
|
78
107
|
const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (!isPinnedDev) {
|
|
88
|
-
return { name: d.name, reachable: true, pinned: false, remoteHasAuth: false };
|
|
89
|
-
}
|
|
90
|
-
const p = probe(d);
|
|
91
|
-
return {
|
|
92
|
-
name: d.name,
|
|
93
|
-
reachable: p.reachable,
|
|
94
|
-
pinned: true,
|
|
95
|
-
remoteHasAuth: p.remoteHasAuth,
|
|
96
|
-
};
|
|
97
|
-
}));
|
|
98
|
-
const pushed = [];
|
|
99
|
-
const skipped = [];
|
|
100
|
-
const errors = [];
|
|
101
|
-
const push = deps.push ?? ((bundle, host) => pushBundleToHost(bundle, host, {
|
|
108
|
+
const plan = planAuthBundlePush(localStatus === 'ready', localIsPublisher, devices.map((device) => ({
|
|
109
|
+
name: device.name,
|
|
110
|
+
reachable: isDialableDevice(device),
|
|
111
|
+
pinned: isDevicePinned(device, pinned),
|
|
112
|
+
remoteAuth: stateByDevice.get(normalizeHost(device.name))?.auth?.status ?? 'unknown',
|
|
113
|
+
})));
|
|
114
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
115
|
+
const push = deps.push ?? ((bundle, host) => pushBundleToHostAsync(bundle, host, {
|
|
102
116
|
remoteBackend: 'file',
|
|
103
117
|
operation: 'auth-sync',
|
|
118
|
+
agentOnly: true,
|
|
119
|
+
timeoutMs: AUTH_SYNC_PUSH_DEADLINE_MS,
|
|
104
120
|
}));
|
|
105
121
|
const targetOf = deps.sshTarget ?? sshTargetFor;
|
|
106
|
-
|
|
107
|
-
if (item.action === 'skip')
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (!profile) {
|
|
113
|
-
skipped.push({ device: item.device, reason: 'not in registry' });
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
122
|
+
const outcomes = await Promise.all(plan.map(async (item) => {
|
|
123
|
+
if (item.action === 'skip')
|
|
124
|
+
return { kind: 'skip', device: item.device, message: item.reason };
|
|
125
|
+
const profile = byName.get(item.device);
|
|
126
|
+
if (!profile)
|
|
127
|
+
return { kind: 'skip', device: item.device, message: 'not in registry' };
|
|
116
128
|
try {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
else
|
|
122
|
-
errors.push({ device: item.device, message: out.message });
|
|
129
|
+
const out = await push(AUTH_BUNDLE_NAME, targetOf(profile));
|
|
130
|
+
return out.ok
|
|
131
|
+
? { kind: 'pushed', device: item.device, message: out.message }
|
|
132
|
+
: { kind: 'error', device: item.device, message: out.message };
|
|
123
133
|
}
|
|
124
134
|
catch (err) {
|
|
125
|
-
|
|
135
|
+
return { kind: 'error', device: item.device, message: err.message };
|
|
126
136
|
}
|
|
137
|
+
}));
|
|
138
|
+
for (const outcome of outcomes) {
|
|
139
|
+
if (outcome.kind === 'pushed')
|
|
140
|
+
result.pushed.push(outcome.device);
|
|
141
|
+
else if (outcome.kind === 'skip')
|
|
142
|
+
result.skipped.push({ device: outcome.device, reason: outcome.message });
|
|
143
|
+
else
|
|
144
|
+
result.errors.push({ device: outcome.device, message: outcome.message });
|
|
127
145
|
}
|
|
128
|
-
return
|
|
146
|
+
return result;
|
|
129
147
|
}
|
|
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
|
|
|
55
55
|
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): 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' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
|
|
58
|
+
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
|
|
59
59
|
export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
|
|
60
60
|
export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
|
|
61
61
|
export declare function isRunningLiveSession(s: ActiveSession): boolean;
|
|
@@ -138,6 +138,8 @@ export interface ActiveSession {
|
|
|
138
138
|
name?: string;
|
|
139
139
|
/** First meaningful line of the initial prompt (extracted topic). */
|
|
140
140
|
topic?: string;
|
|
141
|
+
/** Full, cleaned first genuine user turn, backfilled from the session index. */
|
|
142
|
+
firstUserMessage?: string;
|
|
141
143
|
/**
|
|
142
144
|
* The row's shown title — WHAT the session is, best-source-wins (RUSH-3011).
|
|
143
145
|
* The ladder ({@link deriveSessionRecap}): a `/rename` or harness `label` →
|
|
@@ -149,6 +149,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
149
149
|
s.account = m.account;
|
|
150
150
|
if (!s.label && m.label)
|
|
151
151
|
s.label = m.label;
|
|
152
|
+
if (!s.firstUserMessage && m.firstUserMessage)
|
|
153
|
+
s.firstUserMessage = m.firstUserMessage;
|
|
152
154
|
if (!s.ticket && m.ticketId)
|
|
153
155
|
s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
|
|
154
156
|
if (!s.pr && m.prUrl)
|
|
@@ -7,6 +7,18 @@ export interface SessionActorRecord {
|
|
|
7
7
|
initiatedBy?: 'human' | 'agent';
|
|
8
8
|
/** Effective permissions mode used by the launcher. */
|
|
9
9
|
mode?: SessionRunMode;
|
|
10
|
+
/**
|
|
11
|
+
* The agents-cli version-home id this session launched under (e.g. `2.1.207`,
|
|
12
|
+
* codex `0.146.0`) — the same namespace `listInstalledVersions` /
|
|
13
|
+
* `collectRunCandidates` use, so a native resume can pin the exact origin
|
|
14
|
+
* version. Recorded at launch by the SessionStart hook (from `AGENTS_RUN_VERSION`),
|
|
15
|
+
* because a harness coins its real session id only AFTER spawn — the same reason
|
|
16
|
+
* `mode` rides the hook rather than a spawn-time `writeSessionActorRecord`. Joined
|
|
17
|
+
* onto the session index at scan time so a session whose transcript carries no
|
|
18
|
+
* embedded/derivable version (codex's `.codex-homes/<version>/` layout) no longer
|
|
19
|
+
* degrades native resume to `/continue` for lack of a recorded origin (PHNX-3626).
|
|
20
|
+
*/
|
|
21
|
+
version?: string;
|
|
10
22
|
/**
|
|
11
23
|
* Custom harness / profile name when launched via `agents run <profile>`
|
|
12
24
|
* (e.g. `deepseek`). Joined onto the session index at scan time so a
|
|
@@ -40,6 +40,7 @@ function isSafeAlias(alias) {
|
|
|
40
40
|
function hasRecordData(record) {
|
|
41
41
|
return typeof record.actor === 'string'
|
|
42
42
|
|| typeof record.mode === 'string'
|
|
43
|
+
|| typeof record.version === 'string'
|
|
43
44
|
|| typeof record.harness === 'string'
|
|
44
45
|
|| (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
|
|
45
46
|
}
|
|
@@ -84,6 +85,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
|
|
|
84
85
|
actor: previous?.actor,
|
|
85
86
|
initiatedBy: previous?.initiatedBy,
|
|
86
87
|
mode: previous?.mode,
|
|
88
|
+
version: previous?.version,
|
|
87
89
|
harness: previous?.harness,
|
|
88
90
|
aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
|
|
89
91
|
startedAtMs: previous?.startedAtMs ?? Date.now(),
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { type ToolScanResumePoint } from './tool-store.js';
|
|
|
13
13
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
14
14
|
* assert against the constant instead of hardcoding a number that every bump
|
|
15
15
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
16
|
-
export declare const SCHEMA_VERSION =
|
|
16
|
+
export declare const SCHEMA_VERSION = 45;
|
|
17
17
|
/**
|
|
18
18
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
19
19
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -24,8 +24,11 @@ export declare const SCHEMA_VERSION = 44;
|
|
|
24
24
|
* here backfills every existing session's assistant text on its next scan
|
|
25
25
|
* without a `DELETE FROM scan_ledger` (which would also throw away the
|
|
26
26
|
* resumable parser_state/content_text for Claude/Codex).
|
|
27
|
+
*
|
|
28
|
+
* v3 (PHNX-3621) invalidates OpenCode rows that merged v2 stamped before its
|
|
29
|
+
* genuine first-user-turn extractor was added.
|
|
27
30
|
*/
|
|
28
|
-
export declare const CONTENT_INDEX_VERSION =
|
|
31
|
+
export declare const CONTENT_INDEX_VERSION = 3;
|
|
29
32
|
/**
|
|
30
33
|
* Bumping this invalidates every cached facet row without touching the schema
|
|
31
34
|
* version, so a change to the extraction logic (a new metric, a corrected bucket)
|
package/dist/lib/session/db.js
CHANGED
|
@@ -22,12 +22,13 @@ import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTr
|
|
|
22
22
|
import { resolveResource } from '../resources.js';
|
|
23
23
|
import { discoverPlugins } from '../plugins/plugins.js';
|
|
24
24
|
import { machineId } from '../machine-id.js';
|
|
25
|
+
import { firstUserMessageFromEvents } from './prompt.js';
|
|
25
26
|
const SESSIONS_DIR = getSessionsDir();
|
|
26
27
|
const DB_PATH = getSessionsDbPath();
|
|
27
28
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
28
29
|
* assert against the constant instead of hardcoding a number that every bump
|
|
29
30
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
30
|
-
export const SCHEMA_VERSION =
|
|
31
|
+
export const SCHEMA_VERSION = 45;
|
|
31
32
|
/**
|
|
32
33
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
33
34
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -38,8 +39,11 @@ export const SCHEMA_VERSION = 44;
|
|
|
38
39
|
* here backfills every existing session's assistant text on its next scan
|
|
39
40
|
* without a `DELETE FROM scan_ledger` (which would also throw away the
|
|
40
41
|
* resumable parser_state/content_text for Claude/Codex).
|
|
42
|
+
*
|
|
43
|
+
* v3 (PHNX-3621) invalidates OpenCode rows that merged v2 stamped before its
|
|
44
|
+
* genuine first-user-turn extractor was added.
|
|
41
45
|
*/
|
|
42
|
-
export const CONTENT_INDEX_VERSION =
|
|
46
|
+
export const CONTENT_INDEX_VERSION = 3;
|
|
43
47
|
/**
|
|
44
48
|
* Bump to force `agents sessions backfill resources` to re-derive every
|
|
45
49
|
* session's skill/slash-command tallies on its next run (resource_scan_ledger
|
|
@@ -96,6 +100,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
96
100
|
cwd TEXT,
|
|
97
101
|
git_branch TEXT,
|
|
98
102
|
topic TEXT,
|
|
103
|
+
first_user_message TEXT,
|
|
99
104
|
label TEXT,
|
|
100
105
|
message_count INTEGER,
|
|
101
106
|
token_count INTEGER,
|
|
@@ -1231,6 +1236,15 @@ function migrateSchema(db, fromVersion) {
|
|
|
1231
1236
|
}
|
|
1232
1237
|
}
|
|
1233
1238
|
}
|
|
1239
|
+
if (fromVersion < 45) {
|
|
1240
|
+
// v44 -> v45: retain the full first genuine user turn separately from the
|
|
1241
|
+
// one-line topic. The CONTENT_INDEX_VERSION bump re-parses every transcript
|
|
1242
|
+
// through its normal incremental scan path so existing rows backfill without
|
|
1243
|
+
// a second consumer-side transcript reader.
|
|
1244
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
|
|
1245
|
+
if (!cols.has('first_user_message'))
|
|
1246
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN first_user_message TEXT`);
|
|
1247
|
+
}
|
|
1234
1248
|
}
|
|
1235
1249
|
/**
|
|
1236
1250
|
* Stamp `account_key` / `account_org` / `account` on every Claude row from its
|
|
@@ -1567,9 +1581,11 @@ export function getDBPath() {
|
|
|
1567
1581
|
export function getScanStampByPath(filePath) {
|
|
1568
1582
|
const db = getDB();
|
|
1569
1583
|
const row = db
|
|
1570
|
-
.prepare(`SELECT file_mtime_ms, file_size, scanned_at FROM scan_ledger WHERE file_path = ? LIMIT 1`)
|
|
1584
|
+
.prepare(`SELECT file_mtime_ms, file_size, scanned_at, extractor_version FROM scan_ledger WHERE file_path = ? LIMIT 1`)
|
|
1571
1585
|
.get(canonicalLedgerKey(filePath));
|
|
1572
|
-
return row
|
|
1586
|
+
return row
|
|
1587
|
+
? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at, extractorVersion: row.extractor_version }
|
|
1588
|
+
: null;
|
|
1573
1589
|
}
|
|
1574
1590
|
/**
|
|
1575
1591
|
* Bulk-load the stamp ledger for a set of file paths in a single SQL query.
|
|
@@ -1771,7 +1787,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1771
1787
|
INSERT INTO sessions (
|
|
1772
1788
|
id, short_id, agent, harness, origin, routine_name, routine_run_id,
|
|
1773
1789
|
version, account, account_key, account_org, mode, timestamp, last_activity,
|
|
1774
|
-
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
1790
|
+
project, cwd, git_branch, topic, first_user_message, label, message_count, token_count,
|
|
1775
1791
|
output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
|
|
1776
1792
|
cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
|
|
1777
1793
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
@@ -1782,7 +1798,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1782
1798
|
) VALUES (
|
|
1783
1799
|
@id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
|
|
1784
1800
|
@version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
|
|
1785
|
-
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
1801
|
+
@project, @cwd, @git_branch, @topic, @first_user_message, @label, @message_count, @token_count,
|
|
1786
1802
|
@output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
|
|
1787
1803
|
@cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
|
|
1788
1804
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
@@ -1802,7 +1818,13 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1802
1818
|
origin = excluded.origin,
|
|
1803
1819
|
routine_name = excluded.routine_name,
|
|
1804
1820
|
routine_run_id = excluded.routine_run_id,
|
|
1805
|
-
version
|
|
1821
|
+
-- Origin version is write-once launch metadata, like mode/harness/actor:
|
|
1822
|
+
-- backfill a NULL-first row once the launch sidecar lands, and keep the
|
|
1823
|
+
-- recorded version across a rescan that cannot re-derive it (a codex
|
|
1824
|
+
-- transcript outside a versions/agent/ path, or the durable sidecar aged
|
|
1825
|
+
-- out) — the same COALESCE the launch-metadata columns use, so native resume
|
|
1826
|
+
-- stops falling back for a missing recorded origin (PHNX-3626).
|
|
1827
|
+
version = COALESCE(excluded.version, sessions.version),
|
|
1806
1828
|
account = excluded.account,
|
|
1807
1829
|
account_key = excluded.account_key,
|
|
1808
1830
|
account_org = excluded.account_org,
|
|
@@ -1813,6 +1835,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1813
1835
|
cwd = excluded.cwd,
|
|
1814
1836
|
git_branch = excluded.git_branch,
|
|
1815
1837
|
topic = excluded.topic,
|
|
1838
|
+
first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
|
|
1816
1839
|
-- Never let an empty/placeholder incoming label clobber a good stored one.
|
|
1817
1840
|
-- A real incoming label (non-empty after trim) still wins; a blank one keeps
|
|
1818
1841
|
-- the label seeded by --name (seedLabelsFromNames) or refined by an agent
|
|
@@ -2006,18 +2029,23 @@ function fanOutCounts(events, agent) {
|
|
|
2006
2029
|
: undefined,
|
|
2007
2030
|
};
|
|
2008
2031
|
}
|
|
2032
|
+
/** Fold transcript-derived metadata that every parser can supply uniformly. */
|
|
2033
|
+
function enrichMetaFromEvents(meta, events) {
|
|
2034
|
+
return {
|
|
2035
|
+
...meta,
|
|
2036
|
+
firstUserMessage: meta.firstUserMessage ?? firstUserMessageFromEvents(events),
|
|
2037
|
+
todos: extractTodoProgressFromEvents(events),
|
|
2038
|
+
recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
|
|
2039
|
+
...fanOutCounts(events, meta.agent),
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2009
2042
|
function enrichCachedSessionMeta(meta) {
|
|
2010
2043
|
if (!meta.filePath)
|
|
2011
2044
|
return meta;
|
|
2012
2045
|
try {
|
|
2013
2046
|
const events = parseSession(meta.filePath, meta.agent);
|
|
2014
2047
|
writeResourceUsage(meta.id, events, meta.cwd);
|
|
2015
|
-
return
|
|
2016
|
-
...meta,
|
|
2017
|
-
todos: extractTodoProgressFromEvents(events),
|
|
2018
|
-
recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
|
|
2019
|
-
...fanOutCounts(events, meta.agent),
|
|
2020
|
-
};
|
|
2048
|
+
return enrichMetaFromEvents(meta, events);
|
|
2021
2049
|
}
|
|
2022
2050
|
catch {
|
|
2023
2051
|
// Synthetic/cloud rows can intentionally name a transcript that is not local.
|
|
@@ -2081,7 +2109,11 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
|
|
|
2081
2109
|
origin: meta.origin ?? 'cli',
|
|
2082
2110
|
routine_name: meta.routineName ?? null,
|
|
2083
2111
|
routine_run_id: meta.routineRunId ?? null,
|
|
2084
|
-
|
|
2112
|
+
// Backfill the origin version from the launch-time sidecar when the transcript
|
|
2113
|
+
// scan couldn't derive one (codex's `.codex-homes/<version>/` home) — the same
|
|
2114
|
+
// write-once join as mode/harness, so native resume stops falling back to
|
|
2115
|
+
// `/continue` for a missing recorded version (PHNX-3626).
|
|
2116
|
+
version: meta.version ?? actorRec?.version ?? null,
|
|
2085
2117
|
account: meta.account ?? null,
|
|
2086
2118
|
account_key: meta.accountKey ?? null,
|
|
2087
2119
|
account_org: meta.accountOrg ?? null,
|
|
@@ -2092,6 +2124,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
|
|
|
2092
2124
|
cwd: meta.cwd ?? null,
|
|
2093
2125
|
git_branch: meta.gitBranch ?? null,
|
|
2094
2126
|
topic: meta.topic ?? null,
|
|
2127
|
+
first_user_message: meta.firstUserMessage ?? null,
|
|
2095
2128
|
label: meta.label ?? null,
|
|
2096
2129
|
message_count: meta.messageCount ?? null,
|
|
2097
2130
|
token_count: meta.tokenCount ?? null,
|
|
@@ -2186,7 +2219,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
2186
2219
|
// and means "not computed yet", and the next scan that carries events fills
|
|
2187
2220
|
// it. Never collapse that to 0: see fanOutCounts.
|
|
2188
2221
|
return entry.events
|
|
2189
|
-
? { ...entry, meta:
|
|
2222
|
+
? { ...entry, meta: enrichMetaFromEvents(entry.meta, entry.events) }
|
|
2190
2223
|
: entry;
|
|
2191
2224
|
}
|
|
2192
2225
|
// Harnesses whose scanners produce no events AND whose parseSession reads a
|
|
@@ -2233,12 +2266,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
2233
2266
|
const scanned = scanEventToolCalls(events, prior ?? undefined);
|
|
2234
2267
|
return {
|
|
2235
2268
|
...entry,
|
|
2236
|
-
meta:
|
|
2237
|
-
...entry.meta,
|
|
2238
|
-
todos: extractTodoProgressFromEvents(events),
|
|
2239
|
-
recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
|
|
2240
|
-
...fanOutCounts(events, entry.meta.agent),
|
|
2241
|
-
},
|
|
2269
|
+
meta: enrichMetaFromEvents(entry.meta, events),
|
|
2242
2270
|
// The CHANGED calls only. On a resume these are the newly appended tail
|
|
2243
2271
|
// (append-safe upsert); on a full scan they are the whole history.
|
|
2244
2272
|
toolCalls: scanned.calls,
|
|
@@ -2322,7 +2350,9 @@ export function upsertSessionsBatch(entries) {
|
|
|
2322
2350
|
origin: meta.origin ?? 'cli',
|
|
2323
2351
|
routine_name: meta.routineName ?? null,
|
|
2324
2352
|
routine_run_id: meta.routineRunId ?? null,
|
|
2325
|
-
|
|
2353
|
+
// Backfill the origin version from the launch-time sidecar when the scan
|
|
2354
|
+
// couldn't derive one — see the single-row upsert above (PHNX-3626).
|
|
2355
|
+
version: meta.version ?? actorIndex.get(meta.id)?.version ?? null,
|
|
2326
2356
|
account: meta.account ?? null,
|
|
2327
2357
|
account_key: meta.accountKey ?? null,
|
|
2328
2358
|
account_org: meta.accountOrg ?? null,
|
|
@@ -2333,6 +2363,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
2333
2363
|
cwd: meta.cwd ?? null,
|
|
2334
2364
|
git_branch: meta.gitBranch ?? null,
|
|
2335
2365
|
topic: meta.topic ?? null,
|
|
2366
|
+
first_user_message: meta.firstUserMessage ?? null,
|
|
2336
2367
|
label: meta.label ?? null,
|
|
2337
2368
|
message_count: meta.messageCount ?? null,
|
|
2338
2369
|
token_count: meta.tokenCount ?? null,
|
|
@@ -2580,6 +2611,7 @@ function rowToMeta(row) {
|
|
|
2580
2611
|
accountOrg: row.account_org ?? undefined,
|
|
2581
2612
|
mode: isSessionRunMode(row.mode) ? row.mode : undefined,
|
|
2582
2613
|
topic: row.topic ?? undefined,
|
|
2614
|
+
firstUserMessage: row.first_user_message ?? undefined,
|
|
2583
2615
|
label: row.label ?? undefined,
|
|
2584
2616
|
isTeamOrigin: row.is_team_origin === 1,
|
|
2585
2617
|
prUrl: row.pr_url ?? undefined,
|
|
@@ -71,6 +71,7 @@ interface ClaudeSessionScan {
|
|
|
71
71
|
version?: string;
|
|
72
72
|
model?: string;
|
|
73
73
|
topic?: string;
|
|
74
|
+
firstUserMessage?: string;
|
|
74
75
|
/** Harness-owned session name (`/rename` or Claude's generated `ai-title`). */
|
|
75
76
|
label?: string;
|
|
76
77
|
messageCount: number;
|
|
@@ -135,6 +136,7 @@ interface CodexSessionScan {
|
|
|
135
136
|
version?: string;
|
|
136
137
|
model?: string;
|
|
137
138
|
topic?: string;
|
|
139
|
+
firstUserMessage?: string;
|
|
138
140
|
messageCount: number;
|
|
139
141
|
tokenCount?: number;
|
|
140
142
|
/** Real generated (output) tokens, excluding cache-read/-write context. */
|
|
@@ -418,6 +420,7 @@ interface ClaudeParseState {
|
|
|
418
420
|
version?: string;
|
|
419
421
|
model?: string;
|
|
420
422
|
topic?: string;
|
|
423
|
+
firstUserMessage?: string;
|
|
421
424
|
customTitle?: string;
|
|
422
425
|
aiTitle?: string;
|
|
423
426
|
entrypoint?: string;
|
|
@@ -492,7 +495,7 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
|
|
|
492
495
|
* exact even when the recent window is smaller than the true count.
|
|
493
496
|
*/
|
|
494
497
|
export interface ClaudeParserState {
|
|
495
|
-
v:
|
|
498
|
+
v: 6;
|
|
496
499
|
/**
|
|
497
500
|
* Fan-out tallies carried across a RESUMED parse (RUSH-3091/3095). They must
|
|
498
501
|
* live in the durable blob: the resumable parser reads only new bytes, so
|
|
@@ -512,6 +515,7 @@ export interface ClaudeParserState {
|
|
|
512
515
|
entrypoint?: string;
|
|
513
516
|
firstTsMs?: number;
|
|
514
517
|
topic?: string;
|
|
518
|
+
firstUserMessage?: string;
|
|
515
519
|
customTitle?: string;
|
|
516
520
|
aiTitle?: string;
|
|
517
521
|
plan?: string;
|
|
@@ -629,6 +633,7 @@ interface CodexParseState {
|
|
|
629
633
|
version?: string;
|
|
630
634
|
model?: string;
|
|
631
635
|
topic?: string;
|
|
636
|
+
firstUserMessage?: string;
|
|
632
637
|
messageCount: number;
|
|
633
638
|
tokenCount?: number;
|
|
634
639
|
lastTotalTokenUsage?: any;
|
|
@@ -667,7 +672,7 @@ export declare function initCodexParseState(): CodexParseState;
|
|
|
667
672
|
* finalize is identical after a resume.
|
|
668
673
|
*/
|
|
669
674
|
export interface CodexParserState {
|
|
670
|
-
v:
|
|
675
|
+
v: 4;
|
|
671
676
|
offset: number;
|
|
672
677
|
jsonlDroppingOversizedLine?: boolean;
|
|
673
678
|
sessionId?: string;
|
|
@@ -677,6 +682,7 @@ export interface CodexParserState {
|
|
|
677
682
|
version?: string;
|
|
678
683
|
model?: string;
|
|
679
684
|
topic?: string;
|
|
685
|
+
firstUserMessage?: string;
|
|
680
686
|
messageCount: number;
|
|
681
687
|
tokenCount?: number;
|
|
682
688
|
lastTotalTokenUsage?: any;
|
|
@@ -743,6 +749,8 @@ export declare function __codexScanBranchCountsForTest(): {
|
|
|
743
749
|
};
|
|
744
750
|
/** Test seam: reset the Codex parse-branch counters to observe a scan from a clean slate. */
|
|
745
751
|
export declare function __resetCodexScanBranchCountsForTest(): void;
|
|
752
|
+
/** Extract the version number from a managed versions/<agent>/<version>/... path under either repo. */
|
|
753
|
+
export declare function extractVersionFromManagedPath(agent: SessionAgentId, sourcePath?: string): string | undefined;
|
|
746
754
|
/** Parse one Cursor transcript and enrich it with the matching chat meta.json. */
|
|
747
755
|
export declare function readCursorMeta(filePath: string, currentVersion?: string): {
|
|
748
756
|
meta: SessionMeta;
|
|
@@ -765,11 +773,12 @@ export declare function readKimiMeta(filePath: string, priorRow?: {
|
|
|
765
773
|
* from `offset` + adding the appended tail's deltas equals a full parse.
|
|
766
774
|
*/
|
|
767
775
|
export interface KimiParserState {
|
|
768
|
-
v:
|
|
776
|
+
v: 2;
|
|
769
777
|
offset: number;
|
|
770
778
|
messageCount: number;
|
|
771
779
|
tokenCount: number;
|
|
772
780
|
outputTokens: number;
|
|
781
|
+
firstUserMessage?: string;
|
|
773
782
|
}
|
|
774
783
|
/**
|
|
775
784
|
* Incrementally parse Kimi's wire.jsonl for message-count and token counters,
|