@phnx-labs/agents-cli 1.22.66 → 1.22.69
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 +56 -0
- package/README.md +21 -8
- package/dist/bootstrap.js +6 -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 +50 -0
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +5 -2
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- 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/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +73 -20
- package/dist/commands/ssh.js +205 -2
- 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 +18 -5
- package/dist/lib/accounting/usage.js +165 -20
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- 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/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- 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/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +15 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- 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 +27 -2
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +28 -0
- package/dist/lib/run-trace-sync.js +99 -0
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- 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 +13 -1
- package/dist/lib/session/active.js +5 -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 +16 -2
- package/dist/lib/session/db.js +73 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +187 -26
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +17 -0
- package/dist/lib/session/prompt.js +35 -0
- package/dist/lib/session/recovery.d.ts +43 -6
- package/dist/lib/session/recovery.js +80 -10
- 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 +188 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +67 -11
- package/dist/lib/share/publish.js +98 -16
- package/dist/lib/share/worker-template.js +105 -9
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- 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/backend.js +13 -2
- 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/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- package/package.json +2 -2
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
|
@@ -16,6 +16,50 @@ import os from 'node:os';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
/** A Linear tracker key: `TEAM-N`, e.g. `RUSH-1234`. Mirrors the detector's shape. */
|
|
18
18
|
const LINEAR_KEY_RE = /^[A-Z]{2,6}-\d{1,6}$/;
|
|
19
|
+
/**
|
|
20
|
+
* The same key, matched inside free text on word boundaries. Uppercase-only team
|
|
21
|
+
* key (2–6 letters) so a lowercase `utf-8` can't masquerade as a ticket. Global
|
|
22
|
+
* so {@link linearIssueKeys} can pull every mention out of a body.
|
|
23
|
+
*/
|
|
24
|
+
const LINEAR_KEY_IN_TEXT_RE = /\b([A-Z]{2,6}-\d{1,6})\b/g;
|
|
25
|
+
/**
|
|
26
|
+
* Team prefixes that match the key shape but are not trackers — unit strings and
|
|
27
|
+
* common acronyms. Canonical here (the Linear module) so both the URL linkifier
|
|
28
|
+
* and the session-transcript ticket detector (`detectTicket` in `state.ts`) agree
|
|
29
|
+
* on what is a real key rather than each keeping its own copy.
|
|
30
|
+
*/
|
|
31
|
+
export const LINEAR_KEY_DENYLIST = new Set([
|
|
32
|
+
'UTF',
|
|
33
|
+
'SHA',
|
|
34
|
+
'ISO',
|
|
35
|
+
'RFC',
|
|
36
|
+
'IPV',
|
|
37
|
+
'X86',
|
|
38
|
+
'ARM',
|
|
39
|
+
'MP',
|
|
40
|
+
'H',
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* Every distinct real Linear key mentioned in free text, in first-seen order.
|
|
44
|
+
* Honours {@link LINEAR_KEY_DENYLIST} so `UTF-8` / `X86-64` never count. Used to
|
|
45
|
+
* linkify ticket ids an owner ping only names in prose (no `session.ticketId`).
|
|
46
|
+
*/
|
|
47
|
+
export function linearIssueKeys(text) {
|
|
48
|
+
if (!text)
|
|
49
|
+
return [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const keys = [];
|
|
52
|
+
for (const m of text.matchAll(LINEAR_KEY_IN_TEXT_RE)) {
|
|
53
|
+
const key = m[1];
|
|
54
|
+
if (LINEAR_KEY_DENYLIST.has(key.split('-')[0]))
|
|
55
|
+
continue;
|
|
56
|
+
if (seen.has(key))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
keys.push(key);
|
|
60
|
+
}
|
|
61
|
+
return keys;
|
|
62
|
+
}
|
|
19
63
|
// `undefined` = not yet resolved; `null` = resolved-but-unknown (skip re-reading).
|
|
20
64
|
let workspaceCache;
|
|
21
65
|
/** The configured Linear workspace slug, or undefined when unknown. Cached per process. */
|
|
@@ -49,6 +49,7 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
|
|
|
49
49
|
project: active.project ?? undefined,
|
|
50
50
|
label: active.label,
|
|
51
51
|
topic: active.topic,
|
|
52
|
+
firstUserMessage: active.firstUserMessage,
|
|
52
53
|
version: active.version,
|
|
53
54
|
messageCount: undefined,
|
|
54
55
|
machine: active.machine ?? self,
|
|
@@ -10,7 +10,7 @@ import { truncate } from '../format.js';
|
|
|
10
10
|
import { sanitizeForTerminal } from '../redact.js';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import Database from '../sqlite.js';
|
|
13
|
-
import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
|
|
13
|
+
import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput, unwrapUserQuery } from './prompt.js';
|
|
14
14
|
import { structuredToolResult, commandsFromCodexExec } from './tool-calls.js';
|
|
15
15
|
/**
|
|
16
16
|
* Largest session file we will load into memory. Above this we throw a clean
|
|
@@ -1989,9 +1989,8 @@ export function parseCursor(filePath) {
|
|
|
1989
1989
|
* differs from the one that wrote the transcript.
|
|
1990
1990
|
*/
|
|
1991
1991
|
function parseCursorUserText(text) {
|
|
1992
|
-
const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
|
|
1993
1992
|
const stamp = text.match(/<timestamp>\s*([\s\S]*?)\s*<\/timestamp>/)?.[1]?.trim();
|
|
1994
|
-
return { text: (
|
|
1993
|
+
return { text: unwrapUserQuery(text), timestamp: parseCursorTimestamp(stamp) };
|
|
1995
1994
|
}
|
|
1996
1995
|
function parseCursorTimestamp(stamp) {
|
|
1997
1996
|
if (!stamp)
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* wrapper tags, team-spawn boilerplate) so that only the real user intent
|
|
6
6
|
* remains. Used by the session picker to show a human-readable topic line.
|
|
7
7
|
*/
|
|
8
|
+
import type { SessionEvent } from './types.js';
|
|
8
9
|
/**
|
|
9
10
|
* True when a `role=user` message is harness-injected scaffolding, not a genuine
|
|
10
11
|
* user turn. Text-based so it is cross-harness by construction (the markers are
|
|
@@ -12,6 +13,22 @@
|
|
|
12
13
|
* own scaffolding at parse time simply never reach here.
|
|
13
14
|
*/
|
|
14
15
|
export declare function isSyntheticUserMessage(raw: string | undefined): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Inner text of a `<user_query>` wrapper, or the original string if none.
|
|
18
|
+
* Same extraction Cursor already applies in `parseCursorUserText`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function unwrapUserQuery(text: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Return one genuine user turn in full.
|
|
23
|
+
* Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
|
|
24
|
+
* preamble can never become the session's displayed request. Unlike the topic
|
|
25
|
+
* cleaner, this intentionally preserves internal whitespace and every line of
|
|
26
|
+
* the user's actual request. A Grok/Cursor `<user_query>` wrapper is unwrapped
|
|
27
|
+
* so the stored turn is the originating request, not the harness tags.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cleanFirstUserMessage(raw: string | undefined): string | undefined;
|
|
30
|
+
/** First genuine user message from an already-normalized event stream. */
|
|
31
|
+
export declare function firstUserMessageFromEvents(events: SessionEvent[]): string | undefined;
|
|
15
32
|
/**
|
|
16
33
|
* Extract the invoked slash-command name from a raw `role=user` message, or
|
|
17
34
|
* undefined when the message carries no `<command-name>` wrapper. Used by
|
|
@@ -47,7 +47,9 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
|
|
|
47
47
|
/^\s*<\/?persisted-output>/i,
|
|
48
48
|
/^\s*<permissions instructions>/i,
|
|
49
49
|
/^\s*<(?:apps|plugins|skills)_instructions>/i,
|
|
50
|
+
/^\s*<recommended_plugins>/i,
|
|
50
51
|
/^\s*<(?:multi_agent_mode|environment_context)>/i,
|
|
52
|
+
/^\s*<user_info>/i,
|
|
51
53
|
/^\s*## (?:In-flight in this repo|Host & Fleet)/i,
|
|
52
54
|
/^\s*Your current session id is\b/i,
|
|
53
55
|
/^\s*Linear context skipped:/i,
|
|
@@ -68,6 +70,39 @@ export function isSyntheticUserMessage(raw) {
|
|
|
68
70
|
return false;
|
|
69
71
|
return SYNTHETIC_USER_MESSAGE_PATTERNS.some(pattern => pattern.test(raw));
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Inner text of a `<user_query>` wrapper, or the original string if none.
|
|
75
|
+
* Same extraction Cursor already applies in `parseCursorUserText`.
|
|
76
|
+
*/
|
|
77
|
+
export function unwrapUserQuery(text) {
|
|
78
|
+
const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
|
|
79
|
+
return (query?.[1] ?? text).trim();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Return one genuine user turn in full.
|
|
83
|
+
* Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
|
|
84
|
+
* preamble can never become the session's displayed request. Unlike the topic
|
|
85
|
+
* cleaner, this intentionally preserves internal whitespace and every line of
|
|
86
|
+
* the user's actual request. A Grok/Cursor `<user_query>` wrapper is unwrapped
|
|
87
|
+
* so the stored turn is the originating request, not the harness tags.
|
|
88
|
+
*/
|
|
89
|
+
export function cleanFirstUserMessage(raw) {
|
|
90
|
+
if (!raw || isSyntheticUserMessage(raw))
|
|
91
|
+
return undefined;
|
|
92
|
+
const clean = unwrapUserQuery(raw);
|
|
93
|
+
return clean || undefined;
|
|
94
|
+
}
|
|
95
|
+
/** First genuine user message from an already-normalized event stream. */
|
|
96
|
+
export function firstUserMessageFromEvents(events) {
|
|
97
|
+
for (const event of events) {
|
|
98
|
+
if (event.type !== 'message' || event.role !== 'user' || event._synthetic)
|
|
99
|
+
continue;
|
|
100
|
+
const first = cleanFirstUserMessage(event.content);
|
|
101
|
+
if (first)
|
|
102
|
+
return first;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
71
106
|
/**
|
|
72
107
|
* The `<command-name>` wrapper Claude injects into a `role=user` message when
|
|
73
108
|
* a typed slash command is invoked, e.g.:
|
|
@@ -1,16 +1,39 @@
|
|
|
1
1
|
import { type RotateCandidate } from '../accounting/rotate.js';
|
|
2
2
|
import type { AgentId } from '../types.js';
|
|
3
|
-
import type { SessionMeta } from './types.js';
|
|
3
|
+
import type { SessionAgentId, SessionMeta } from './types.js';
|
|
4
|
+
/** One capability boundary for every surface that advertises faithful Resume. */
|
|
5
|
+
export declare function sessionAgentSupportsResume(agent: SessionAgentId): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* The account a recovery should authenticate as. Present when resume rotates
|
|
8
|
+
* AWAY from the session's original login (an account limit) to a healthy
|
|
9
|
+
* sibling of the SAME harness. A `providerAccount` is a durable setup-token /
|
|
10
|
+
* API-key account (RUSH-3182) injected via the `--account` spawn path
|
|
11
|
+
* (`resolveSpawnAccount` → `accountEnv`); it is the only kind that can
|
|
12
|
+
* authenticate a NATIVE resume in the origin version home, because a native
|
|
13
|
+
* login lives in its own isolated home and cannot be forwarded. It is also
|
|
14
|
+
* required on `/continue` when the balanced pick is a provider: exec only
|
|
15
|
+
* injects from this field, so omitting it would launch the version home's
|
|
16
|
+
* native login — the exhausted origin in the PHNX-3674 fixture. Absent means
|
|
17
|
+
* "use the launched version home's own native login" (the healthy-origin happy
|
|
18
|
+
* path, or `/continue` on a healthy native sibling).
|
|
19
|
+
*/
|
|
20
|
+
export interface RecoveryAccount {
|
|
21
|
+
providerAccount: string;
|
|
22
|
+
label: string;
|
|
23
|
+
email: string | null;
|
|
24
|
+
}
|
|
4
25
|
export type SessionRecoveryTarget = {
|
|
5
26
|
mode: 'native';
|
|
6
27
|
agent: AgentId;
|
|
7
28
|
version: string;
|
|
8
29
|
cwd?: string;
|
|
30
|
+
account?: RecoveryAccount;
|
|
9
31
|
reason: string;
|
|
10
32
|
} | {
|
|
11
33
|
mode: 'continue';
|
|
12
34
|
agent: AgentId;
|
|
13
35
|
version: string;
|
|
36
|
+
account?: RecoveryAccount;
|
|
14
37
|
reason: string;
|
|
15
38
|
};
|
|
16
39
|
export type NativeResumeInspection = {
|
|
@@ -40,13 +63,27 @@ export declare function inspectNativeResumeSession(session: SessionMeta, version
|
|
|
40
63
|
/**
|
|
41
64
|
* Decide how a durable session resumes on the device that owns it.
|
|
42
65
|
*
|
|
43
|
-
* Native resume is legal only in the exact origin version's isolated home
|
|
44
|
-
* only while that
|
|
45
|
-
*
|
|
46
|
-
*
|
|
66
|
+
* Native resume is legal only in the exact origin version's isolated home,
|
|
67
|
+
* and only while that home owns the indexed transcript AND some injectable
|
|
68
|
+
* credential for this harness is healthy: the origin login itself, or a
|
|
69
|
+
* provider account rotated in when the origin is usage-limited (PHNX-3626).
|
|
70
|
+
* Every other successful path stays on the same harness and uses `/continue`,
|
|
71
|
+
* whose indexed transcript reader can reach retained version trash. A
|
|
72
|
+
* `/continue` pick of a provider account carries RecoveryAccount so exec
|
|
73
|
+
* injects it instead of launching the version home's native login
|
|
74
|
+
* (PHNX-3674). No healthy same-harness account is a loud failure.
|
|
47
75
|
*/
|
|
48
76
|
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
|
|
49
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Resolve recovery for a durable session, reading the live account pool.
|
|
79
|
+
*
|
|
80
|
+
* Uses {@link collectRunCandidatesForRun} (native version-home logins PLUS
|
|
81
|
+
* durable provider accounts, RUSH-3182) rather than the native-only
|
|
82
|
+
* {@link collectRunCandidates}, so an origin-account limit can rotate to a
|
|
83
|
+
* healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
|
|
84
|
+
* for tests and for callers that must stay native-only.
|
|
85
|
+
*/
|
|
86
|
+
export declare function resolveSessionRecovery(session: SessionMeta, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<SessionRecoveryTarget>;
|
|
50
87
|
/** Stable self-command used by focus, resume, and attach. The owning device runs
|
|
51
88
|
* the recovery resolver above; callers must not native-resume another version's
|
|
52
89
|
* isolated home themselves. */
|
|
@@ -4,8 +4,14 @@ import { AGENTS, agentConfigDirName } from '../agents.js';
|
|
|
4
4
|
import { isSelfHost } from '../devices/self-host.js';
|
|
5
5
|
import { nativeResume } from '../exec.js';
|
|
6
6
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
7
|
-
import {
|
|
7
|
+
import { formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../accounting/rotate.js';
|
|
8
|
+
import { collectRunCandidatesForRun } from '../accounting/account-pool-collect.js';
|
|
8
9
|
import { getVersionHomePath } from '../installations/store.js';
|
|
10
|
+
const RESUMABLE_SESSION_AGENTS = new Set(['claude', 'codex', 'muse', 'opencode']);
|
|
11
|
+
/** One capability boundary for every surface that advertises faithful Resume. */
|
|
12
|
+
export function sessionAgentSupportsResume(agent) {
|
|
13
|
+
return RESUMABLE_SESSION_AGENTS.has(agent);
|
|
14
|
+
}
|
|
9
15
|
export class SessionRecoveryError extends Error {
|
|
10
16
|
constructor(message) {
|
|
11
17
|
super(message);
|
|
@@ -44,6 +50,16 @@ function sourceReason(session, candidates) {
|
|
|
44
50
|
? `origin ${session.agent}@${session.version} has no native resume form`
|
|
45
51
|
: `origin ${session.agent}@${session.version} is ${readiness.reason}`;
|
|
46
52
|
}
|
|
53
|
+
function recoveryAccountFromCandidate(candidate) {
|
|
54
|
+
const providerAccount = candidate.providerAccount;
|
|
55
|
+
if (!providerAccount)
|
|
56
|
+
return undefined;
|
|
57
|
+
return {
|
|
58
|
+
providerAccount,
|
|
59
|
+
label: candidate.accountLabel || providerAccount,
|
|
60
|
+
email: candidate.email,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
47
63
|
function isPathInside(candidate, dir) {
|
|
48
64
|
const rel = path.relative(dir, candidate);
|
|
49
65
|
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
@@ -140,10 +156,15 @@ export function inspectNativeResumeSession(session, versionHome) {
|
|
|
140
156
|
/**
|
|
141
157
|
* Decide how a durable session resumes on the device that owns it.
|
|
142
158
|
*
|
|
143
|
-
* Native resume is legal only in the exact origin version's isolated home
|
|
144
|
-
* only while that
|
|
145
|
-
*
|
|
146
|
-
*
|
|
159
|
+
* Native resume is legal only in the exact origin version's isolated home,
|
|
160
|
+
* and only while that home owns the indexed transcript AND some injectable
|
|
161
|
+
* credential for this harness is healthy: the origin login itself, or a
|
|
162
|
+
* provider account rotated in when the origin is usage-limited (PHNX-3626).
|
|
163
|
+
* Every other successful path stays on the same harness and uses `/continue`,
|
|
164
|
+
* whose indexed transcript reader can reach retained version trash. A
|
|
165
|
+
* `/continue` pick of a provider account carries RecoveryAccount so exec
|
|
166
|
+
* injects it instead of launching the version home's native login
|
|
167
|
+
* (PHNX-3674). No healthy same-harness account is a loud failure.
|
|
147
168
|
*/
|
|
148
169
|
export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
|
|
149
170
|
const agent = runnableSessionAgent(session);
|
|
@@ -152,6 +173,38 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
152
173
|
? candidates.find((candidate) => candidate.version === session.version)
|
|
153
174
|
: undefined;
|
|
154
175
|
const sourceReady = source ? readinessFromCandidate(source).ready : false;
|
|
176
|
+
// Native-first with account rotation (PHNX-3626). When the origin login is
|
|
177
|
+
// usage/rate/session-LIMITED (not signed-out or revoked — those need a login,
|
|
178
|
+
// not a rotation, so they keep going to /continue per SES-39) but its version
|
|
179
|
+
// home is installed, native-resume-capable, and still owns the indexed
|
|
180
|
+
// transcript, keep resume NATIVE by rotating to a healthy INJECTABLE (provider)
|
|
181
|
+
// account in that SAME home — rather than dropping to /continue on a different
|
|
182
|
+
// version. Only a provider token/key qualifies: a native login lives in its own
|
|
183
|
+
// isolated home and cannot be forwarded, so it could never authenticate a
|
|
184
|
+
// resume that must read the origin home's transcript (see §11).
|
|
185
|
+
const originReadiness = source ? readinessFromCandidate(source) : null;
|
|
186
|
+
const originLimited = !!originReadiness && !originReadiness.ready
|
|
187
|
+
&& (originReadiness.reason === 'rate_limited' || originReadiness.reason === 'out_of_credits');
|
|
188
|
+
if (originLimited && source && session.version && supportsNative(agent, session.version)) {
|
|
189
|
+
const inspection = nativeInspection
|
|
190
|
+
?? inspectNativeResumeSession(session, getVersionHomePath(agent, session.version));
|
|
191
|
+
if (inspection.available) {
|
|
192
|
+
const rotated = pickBalancedCandidate(candidates.filter((c) => c.providerAccount && c.accountKey !== source.accountKey));
|
|
193
|
+
const account = rotated ? recoveryAccountFromCandidate(rotated.picked) : undefined;
|
|
194
|
+
if (account) {
|
|
195
|
+
// `originLimited` guarantees the origin is unhealthy with a limit reason.
|
|
196
|
+
const why = originReadiness.ready ? 'limited' : originReadiness.reason;
|
|
197
|
+
return {
|
|
198
|
+
mode: 'native',
|
|
199
|
+
agent,
|
|
200
|
+
version: session.version,
|
|
201
|
+
cwd: inspection.cwd,
|
|
202
|
+
account,
|
|
203
|
+
reason: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${account.label} and resuming natively in the same home`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
155
208
|
// An exact healthy origin is deterministic: preserve its isolated home. If
|
|
156
209
|
// native resume is unavailable for that harness, /continue still launches in
|
|
157
210
|
// that same healthy home. Only an unusable/missing origin enters balanced
|
|
@@ -164,7 +217,13 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
164
217
|
throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device}; origin ${agent}@${session.version ?? 'unknown'}. ${detail}`);
|
|
165
218
|
}
|
|
166
219
|
const version = selection.picked.version;
|
|
167
|
-
|
|
220
|
+
const account = recoveryAccountFromCandidate(selection.picked);
|
|
221
|
+
const continueWith = account ? `healthy ${account.label}` : `healthy ${agent}@${version}`;
|
|
222
|
+
// Native resume without an injected RecoveryAccount is valid only for the
|
|
223
|
+
// exact healthy origin login. A balanced same-version provider selected for
|
|
224
|
+
// a signed-out/revoked origin must stay on /continue; otherwise we would open
|
|
225
|
+
// the origin home with no usable credential and fail (or fork state).
|
|
226
|
+
if (sourceReady && session.version === version && supportsNative(agent, version)) {
|
|
168
227
|
const inspection = nativeInspection
|
|
169
228
|
?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
|
|
170
229
|
if (inspection.available) {
|
|
@@ -180,19 +239,30 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
180
239
|
mode: 'continue',
|
|
181
240
|
agent,
|
|
182
241
|
version,
|
|
183
|
-
|
|
242
|
+
...(account ? { account } : {}),
|
|
243
|
+
reason: `${inspection.reason}; continuing with ${continueWith}`,
|
|
184
244
|
};
|
|
185
245
|
}
|
|
186
246
|
return {
|
|
187
247
|
mode: 'continue',
|
|
188
248
|
agent,
|
|
189
249
|
version,
|
|
190
|
-
|
|
250
|
+
...(account ? { account } : {}),
|
|
251
|
+
reason: `${sourceReason(session, candidates)}; continuing with ${continueWith}`,
|
|
191
252
|
};
|
|
192
253
|
}
|
|
193
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Resolve recovery for a durable session, reading the live account pool.
|
|
256
|
+
*
|
|
257
|
+
* Uses {@link collectRunCandidatesForRun} (native version-home logins PLUS
|
|
258
|
+
* durable provider accounts, RUSH-3182) rather than the native-only
|
|
259
|
+
* {@link collectRunCandidates}, so an origin-account limit can rotate to a
|
|
260
|
+
* healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
|
|
261
|
+
* for tests and for callers that must stay native-only.
|
|
262
|
+
*/
|
|
263
|
+
export async function resolveSessionRecovery(session, collect = collectRunCandidatesForRun) {
|
|
194
264
|
const agent = runnableSessionAgent(session);
|
|
195
|
-
return resolveSessionRecoveryFromCandidates(session, await
|
|
265
|
+
return resolveSessionRecoveryFromCandidates(session, await collect(agent));
|
|
196
266
|
}
|
|
197
267
|
/** Stable self-command used by focus, resume, and attach. The owning device runs
|
|
198
268
|
* the recovery resolver above; callers must not native-resume another version's
|
|
@@ -145,7 +145,23 @@ export declare function fetchPeerPreviewDigest(sessionId: string, machine: strin
|
|
|
145
145
|
* `opts.sessionId` (with `tty`) prints the session id and resume command when
|
|
146
146
|
* the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
|
|
147
147
|
* thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
|
|
148
|
+
*
|
|
149
|
+
* Resolves `'unreachable'` when the SSH connection itself failed — ssh could not
|
|
150
|
+
* launch (spawn error) or exited with {@link SSH_CONN_FAILURE_CODE} (255, its
|
|
151
|
+
* connect-failure convention) — as opposed to `'ok'` for a hop that actually
|
|
152
|
+
* reached the peer (whatever the remote command's own exit code). This lets a
|
|
153
|
+
* caller prefer the recorded device yet fall back locally when it is genuinely
|
|
154
|
+
* offline (PHNX-3626); callers that ignore `'unreachable'` behave exactly as
|
|
155
|
+
* before (it was `'ok'`).
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* Classify a finished SSH hop by its exit code: `'unreachable'` when the
|
|
159
|
+
* connection itself failed (ssh's {@link SSH_CONN_FAILURE_CODE} = 255, or a null
|
|
160
|
+
* code from a killed/never-launched child), else `'ok'` — the remote command ran,
|
|
161
|
+
* whatever its own exit status. Pure so the offline-device fallback is unit-tested
|
|
162
|
+
* without a live SSH hop (PHNX-3626).
|
|
148
163
|
*/
|
|
164
|
+
export declare function peerHopOutcome(code: number | null): 'ok' | 'unreachable';
|
|
149
165
|
export declare function peerHopCloseNotice(opts: {
|
|
150
166
|
tty?: boolean;
|
|
151
167
|
sessionId?: string;
|
|
@@ -154,4 +170,4 @@ export declare function runOnPeer(args: string[], machine: string, opts?: {
|
|
|
154
170
|
tty?: boolean;
|
|
155
171
|
env?: Record<string, string>;
|
|
156
172
|
sessionId?: string;
|
|
157
|
-
}): Promise<'ok' | 'no-target'>;
|
|
173
|
+
}): Promise<'ok' | 'no-target' | 'unreachable'>;
|
|
@@ -582,7 +582,25 @@ export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEE
|
|
|
582
582
|
* `opts.sessionId` (with `tty`) prints the session id and resume command when
|
|
583
583
|
* the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
|
|
584
584
|
* thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
|
|
585
|
+
*
|
|
586
|
+
* Resolves `'unreachable'` when the SSH connection itself failed — ssh could not
|
|
587
|
+
* launch (spawn error) or exited with {@link SSH_CONN_FAILURE_CODE} (255, its
|
|
588
|
+
* connect-failure convention) — as opposed to `'ok'` for a hop that actually
|
|
589
|
+
* reached the peer (whatever the remote command's own exit code). This lets a
|
|
590
|
+
* caller prefer the recorded device yet fall back locally when it is genuinely
|
|
591
|
+
* offline (PHNX-3626); callers that ignore `'unreachable'` behave exactly as
|
|
592
|
+
* before (it was `'ok'`).
|
|
593
|
+
*/
|
|
594
|
+
/**
|
|
595
|
+
* Classify a finished SSH hop by its exit code: `'unreachable'` when the
|
|
596
|
+
* connection itself failed (ssh's {@link SSH_CONN_FAILURE_CODE} = 255, or a null
|
|
597
|
+
* code from a killed/never-launched child), else `'ok'` — the remote command ran,
|
|
598
|
+
* whatever its own exit status. Pure so the offline-device fallback is unit-tested
|
|
599
|
+
* without a live SSH hop (PHNX-3626).
|
|
585
600
|
*/
|
|
601
|
+
export function peerHopOutcome(code) {
|
|
602
|
+
return code === SSH_CONN_FAILURE_CODE || code === null ? 'unreachable' : 'ok';
|
|
603
|
+
}
|
|
586
604
|
export function peerHopCloseNotice(opts, machine, code) {
|
|
587
605
|
if (!opts.tty || !opts.sessionId)
|
|
588
606
|
return undefined;
|
|
@@ -596,9 +614,13 @@ export async function runOnPeer(args, machine, opts = {}) {
|
|
|
596
614
|
const cols = terminalWidth();
|
|
597
615
|
const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
|
|
598
616
|
const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
|
|
617
|
+
const invocation = assignments.concat(['agents', ...args].map(shellQuote)).join(' ');
|
|
618
|
+
// OpenSSH uses 255 for transport failure, but also propagates a reached remote
|
|
619
|
+
// program's 255 verbatim. Remap only the remote program's 255 inside the peer
|
|
620
|
+
// shell so the outer SSH status remains an unambiguous connectivity signal.
|
|
599
621
|
const remoteCmd = remoteShellFor(peer.os) === 'powershell'
|
|
600
|
-
? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
|
|
601
|
-
: `bash -lc ${shellQuote(
|
|
622
|
+
? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined, remapExit255: true })
|
|
623
|
+
: `bash -lc ${shellQuote(`${invocation}; agents_rc=$?; if [ "$agents_rc" -eq 255 ]; then exit 254; fi; exit "$agents_rc"`)}`;
|
|
602
624
|
const sshArgs = [...SSH_OPTS, ...controlOpts()];
|
|
603
625
|
if (opts.tty)
|
|
604
626
|
sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
|
|
@@ -610,7 +632,7 @@ export async function runOnPeer(args, machine, opts = {}) {
|
|
|
610
632
|
// we resolve once it settles so the picker flow completes.
|
|
611
633
|
child.on('error', (err) => {
|
|
612
634
|
process.stderr.write(chalk.red(`Failed to reach ${machine}: ${err?.message ?? 'ssh failed to launch'}\n`));
|
|
613
|
-
resolve('
|
|
635
|
+
resolve('unreachable');
|
|
614
636
|
});
|
|
615
637
|
child.on('close', (code) => {
|
|
616
638
|
// Interactive TTY hop: OpenSSH prints "Shared connection … closed." and
|
|
@@ -618,7 +640,10 @@ export async function runOnPeer(args, machine, opts = {}) {
|
|
|
618
640
|
const notice = peerHopCloseNotice(opts, machine, code);
|
|
619
641
|
if (notice)
|
|
620
642
|
process.stderr.write(notice);
|
|
621
|
-
|
|
643
|
+
// ssh exits 255 only when the connection itself failed (host down/asleep),
|
|
644
|
+
// distinct from the remote command's own non-zero exit. Report that so a
|
|
645
|
+
// caller can fall back locally instead of silently completing.
|
|
646
|
+
resolve(peerHopOutcome(code));
|
|
622
647
|
});
|
|
623
648
|
});
|
|
624
649
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type ActiveSession } from '../active.js';
|
|
2
|
+
import type { SessionMeta } from '../types.js';
|
|
2
3
|
import { readActiveSessionsCache } from '../session-cache.js';
|
|
3
4
|
export declare const SESSION_WATCH_VERSION: 1;
|
|
4
5
|
export declare const SESSION_WATCH_HEARTBEAT_MS = 15000;
|
|
6
|
+
export declare const SESSION_WATCH_PREVIOUS_LIMIT = 50;
|
|
5
7
|
export type SessionWatchScopeStatus = 'available' | 'unavailable';
|
|
6
8
|
export type SessionWatchEnvelope = {
|
|
7
9
|
version: 1;
|
|
@@ -45,9 +47,15 @@ export type SessionWatchEnvelope = {
|
|
|
45
47
|
scope: string;
|
|
46
48
|
capturedAt: number;
|
|
47
49
|
};
|
|
48
|
-
export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn'> {
|
|
50
|
+
export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn' | 'context'> {
|
|
51
|
+
context: ActiveSession['context'] | 'recent';
|
|
52
|
+
/** Flat durable branch for history rows that are not currently in a worktree. */
|
|
53
|
+
branch?: string;
|
|
49
54
|
rowKey: string;
|
|
50
55
|
sourceDevice: string;
|
|
56
|
+
/** Durable index rows are kept on the stream under a distinct identity so a
|
|
57
|
+
* live row can replace/disappear without erasing its recoverable history. */
|
|
58
|
+
previous: boolean;
|
|
51
59
|
resumable: boolean;
|
|
52
60
|
unwatched: boolean;
|
|
53
61
|
viewingIn: string | null;
|
|
@@ -60,6 +68,13 @@ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn'> {
|
|
|
60
68
|
/** Stable, opaque identity for one row within one device scope. */
|
|
61
69
|
export declare function sessionWatchRowKey(scope: string, row: ActiveSession): string;
|
|
62
70
|
export declare function toSessionWatchRow(scope: string, row: ActiveSession): SessionWatchRow;
|
|
71
|
+
/** Stable identity for a durable Previous row. It is deliberately distinct
|
|
72
|
+
* from the live row key for the same session id: both may coexist on the one
|
|
73
|
+
* stream, and the presentation layer lets the live row win while it exists. */
|
|
74
|
+
export declare function previousSessionWatchRowKey(scope: string, sessionId: string): string;
|
|
75
|
+
/** Project one durable indexed session into the same canonical watch contract
|
|
76
|
+
* as live sessions. This is the only history backfill consumed by AGI EXT. */
|
|
77
|
+
export declare function toPreviousSessionWatchRow(scope: string, session: SessionMeta): SessionWatchRow;
|
|
63
78
|
/** Stream-local sequencer and convergent row diff. */
|
|
64
79
|
export declare class SessionWatchState {
|
|
65
80
|
readonly streamId: string;
|
|
@@ -67,7 +82,10 @@ export declare class SessionWatchState {
|
|
|
67
82
|
private readonly rows;
|
|
68
83
|
constructor(streamId?: string);
|
|
69
84
|
private base;
|
|
70
|
-
|
|
85
|
+
/** Keep one visible Previous row per session and the newest bounded window.
|
|
86
|
+
* Returns removed keys so delta callers can converge already-connected clients. */
|
|
87
|
+
private prunePrevious;
|
|
88
|
+
reset(scope: string, sourceRows: ActiveSession[], indexedRows?: SessionMeta[]): SessionWatchEnvelope;
|
|
71
89
|
update(scope: string, sourceRows: ActiveSession[]): SessionWatchEnvelope[];
|
|
72
90
|
patch(scope: string, upserts: ActiveSession[], removes: string[]): SessionWatchEnvelope[];
|
|
73
91
|
scope(scope: string, status: SessionWatchScopeStatus, reason?: string): SessionWatchEnvelope;
|
|
@@ -80,9 +98,14 @@ export interface WatchLocalOptions {
|
|
|
80
98
|
refreshMs?: number;
|
|
81
99
|
heartbeatMs?: number;
|
|
82
100
|
readCache?: typeof readActiveSessionsCache;
|
|
101
|
+
readPrevious?: (scope: string) => SessionMeta[];
|
|
83
102
|
journalPath?: string;
|
|
84
103
|
journalPollMs?: number;
|
|
85
104
|
}
|
|
105
|
+
/** One bounded index read when the watch starts. The stream then owns this
|
|
106
|
+
* projection: active rows update through the journal, and removals demote into
|
|
107
|
+
* Previous rows without another transcript/index poll. */
|
|
108
|
+
export declare function readPreviousSessionsForWatch(scope: string): SessionMeta[];
|
|
86
109
|
/**
|
|
87
110
|
* Keep one local subscription alive. Startup reads one canonical reset snapshot;
|
|
88
111
|
* steady state tails the canonical writer journal and never invokes a gather.
|