@phnx-labs/agents-cli 1.22.51 → 1.22.53
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 +238 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.js +118 -56
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +16 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +65 -6
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +69 -39
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +102 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +57 -17
- package/dist/lib/browser/profiles.js +77 -53
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +66 -28
- package/dist/lib/exec.js +71 -26
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- package/dist/lib/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +104 -12
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/traces/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +1 -1
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* an agent's per-PR watcher is running state, not config, and syncing would push
|
|
17
17
|
* every one of them onto every box — the accumulation this guard exists to stop.
|
|
18
18
|
*/
|
|
19
|
+
import { type GatherRemoteAgentsJsonDeps } from '../remote-agents-json.js';
|
|
19
20
|
import type { MonitorConfig } from './config.js';
|
|
20
21
|
/** Recursion guard: a peer answering the fan-out must not fan out again. */
|
|
21
22
|
export declare const NO_MONITOR_FANOUT_ENV = "AGENTS_MONITORS_LOCAL";
|
|
@@ -39,9 +40,25 @@ export interface FleetMonitorsResult {
|
|
|
39
40
|
* silently treating "we could not ask" as "there is no duplicate". */
|
|
40
41
|
skipped: string[];
|
|
41
42
|
}
|
|
43
|
+
export interface GatherFleetMonitorsOptions {
|
|
44
|
+
/** When supplied, the fan-out aborts as soon as any peer returns a monitor with
|
|
45
|
+
* this behavioral fingerprint. The miss path still waits for every peer so the
|
|
46
|
+
* guard can prove absence fleet-wide. */
|
|
47
|
+
againstFingerprint?: string;
|
|
48
|
+
/** Optional test seam for the SSH boundary; production uses the real capture. */
|
|
49
|
+
deps?: GatherRemoteAgentsJsonDeps;
|
|
50
|
+
/** Optional explicit host list; production omits it and asks the device registry. */
|
|
51
|
+
hosts?: string[];
|
|
52
|
+
}
|
|
42
53
|
/**
|
|
43
54
|
* Every monitor on every other registered device. Never throws: an unreachable
|
|
44
55
|
* fleet degrades to an empty list plus the names we could not consult, and the
|
|
45
56
|
* caller decides what to say about them.
|
|
57
|
+
*
|
|
58
|
+
* When {@link GatherFleetMonitorsOptions.againstFingerprint} is provided, a peer
|
|
59
|
+
* returning that fingerprint is a definitive clash: the remaining peers are
|
|
60
|
+
* SIGTERM'd immediately rather than burning the rest of the timeout budget.
|
|
61
|
+
* Absence of a clash still waits for the full fleet, because uniqueness is only
|
|
62
|
+
* knowable once every peer has answered.
|
|
46
63
|
*/
|
|
47
|
-
export declare function gatherFleetMonitors(): Promise<FleetMonitorsResult>;
|
|
64
|
+
export declare function gatherFleetMonitors(options?: GatherFleetMonitorsOptions): Promise<FleetMonitorsResult>;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* every one of them onto every box — the accumulation this guard exists to stop.
|
|
18
18
|
*/
|
|
19
19
|
import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
|
|
20
|
+
import { monitorFingerprint } from './fingerprint.js';
|
|
20
21
|
/** Recursion guard: a peer answering the fan-out must not fan out again. */
|
|
21
22
|
export const NO_MONITOR_FANOUT_ENV = 'AGENTS_MONITORS_LOCAL';
|
|
22
23
|
/**
|
|
@@ -58,15 +59,27 @@ export function parseRemoteMonitors(stdout, machine) {
|
|
|
58
59
|
* Every monitor on every other registered device. Never throws: an unreachable
|
|
59
60
|
* fleet degrades to an empty list plus the names we could not consult, and the
|
|
60
61
|
* caller decides what to say about them.
|
|
62
|
+
*
|
|
63
|
+
* When {@link GatherFleetMonitorsOptions.againstFingerprint} is provided, a peer
|
|
64
|
+
* returning that fingerprint is a definitive clash: the remaining peers are
|
|
65
|
+
* SIGTERM'd immediately rather than burning the rest of the timeout budget.
|
|
66
|
+
* Absence of a clash still waits for the full fleet, because uniqueness is only
|
|
67
|
+
* knowable once every peer has answered.
|
|
61
68
|
*/
|
|
62
|
-
export async function gatherFleetMonitors() {
|
|
69
|
+
export async function gatherFleetMonitors(options = {}) {
|
|
63
70
|
try {
|
|
64
71
|
const result = await gatherRemoteAgentsJson({
|
|
65
72
|
args: ['monitors', 'list', '--json'],
|
|
66
73
|
noFanoutEnv: NO_MONITOR_FANOUT_ENV,
|
|
74
|
+
hosts: options.hosts,
|
|
67
75
|
parse: parseRemoteMonitors,
|
|
68
76
|
quiet: true,
|
|
69
|
-
|
|
77
|
+
earlyExit: options.againstFingerprint
|
|
78
|
+
? {
|
|
79
|
+
isDefinitive: (item) => monitorFingerprint(item.monitor) === options.againstFingerprint,
|
|
80
|
+
}
|
|
81
|
+
: undefined,
|
|
82
|
+
}, options.deps);
|
|
70
83
|
return {
|
|
71
84
|
monitors: result.items,
|
|
72
85
|
skipped: [...result.skipped, ...result.parseFailed],
|
package/dist/lib/notify.d.ts
CHANGED
|
@@ -48,6 +48,13 @@ export declare function buildOpenClawNotifyArgs(text: string, opts: {
|
|
|
48
48
|
* selects the provider per host. A missing owner config or a delivery failure
|
|
49
49
|
* (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
|
|
50
50
|
* ENOENT — so callers surface a consistent, best-effort failure.
|
|
51
|
+
*
|
|
52
|
+
* When local delivery fails because THIS box structurally cannot reach the owner
|
|
53
|
+
* — the rush-backed owner channel is macOS-only, so a headless Linux worker can
|
|
54
|
+
* never ring the phone (PHNX-3303) — the notify is forwarded over SSH to a
|
|
55
|
+
* capable fleet peer that DOES have the provider, mirroring the reroute
|
|
56
|
+
* `agents message` already uses. A successful forward is returned as the result;
|
|
57
|
+
* if no capable peer is reachable, the original clean local error stands.
|
|
51
58
|
*/
|
|
52
59
|
export declare function sendToOwner(text: string, options?: OwnerNotifyOptions): Promise<SendResult>;
|
|
53
60
|
export declare function notifyUrgentBlock(block: OpenBlock, options?: OwnerNotifyOptions): Promise<NotifyResult>;
|
package/dist/lib/notify.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readMeta } from './state.js';
|
|
|
2
2
|
import { getOwnerNotifyFromHumans } from './humans.js';
|
|
3
3
|
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
4
4
|
import { lookupTransport } from './channels/resolve.js';
|
|
5
|
+
import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
|
|
5
6
|
export function formatUrgentBlockMessage(block) {
|
|
6
7
|
const q = block.questions[0];
|
|
7
8
|
const header = q?.header ? `[${q.header}] ` : '';
|
|
@@ -38,6 +39,13 @@ export function buildOpenClawNotifyArgs(text, opts) {
|
|
|
38
39
|
* selects the provider per host. A missing owner config or a delivery failure
|
|
39
40
|
* (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
|
|
40
41
|
* ENOENT — so callers surface a consistent, best-effort failure.
|
|
42
|
+
*
|
|
43
|
+
* When local delivery fails because THIS box structurally cannot reach the owner
|
|
44
|
+
* — the rush-backed owner channel is macOS-only, so a headless Linux worker can
|
|
45
|
+
* never ring the phone (PHNX-3303) — the notify is forwarded over SSH to a
|
|
46
|
+
* capable fleet peer that DOES have the provider, mirroring the reroute
|
|
47
|
+
* `agents message` already uses. A successful forward is returned as the result;
|
|
48
|
+
* if no capable peer is reachable, the original clean local error stands.
|
|
41
49
|
*/
|
|
42
50
|
export async function sendToOwner(text, options = {}) {
|
|
43
51
|
const meta = options.meta ?? readMeta();
|
|
@@ -57,11 +65,17 @@ export async function sendToOwner(text, options = {}) {
|
|
|
57
65
|
if (!provider) {
|
|
58
66
|
return { ok: false, channel, id: target, error };
|
|
59
67
|
}
|
|
60
|
-
|
|
68
|
+
const local = await provider.send(text, {
|
|
61
69
|
target,
|
|
62
70
|
ownerScoped: options.target === undefined,
|
|
63
71
|
dryRun: options.dryRun,
|
|
64
72
|
});
|
|
73
|
+
// A dry-run never delivers, and an override target is an explicit recipient
|
|
74
|
+
// (not the fleet-wide owner) — neither should hop to a peer.
|
|
75
|
+
if (local.ok || options.dryRun || options.target !== undefined)
|
|
76
|
+
return local;
|
|
77
|
+
const forwarded = await forwardOwnerNotifyToPeer(text, channel, meta);
|
|
78
|
+
return forwarded ?? local;
|
|
65
79
|
}
|
|
66
80
|
export async function notifyUrgentBlock(block, options = {}) {
|
|
67
81
|
if (block.notifiedAt) {
|
|
@@ -62,7 +62,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
|
|
|
62
62
|
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
|
|
63
63
|
type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
64
64
|
/** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
|
|
65
|
-
export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName'>;
|
|
65
|
+
export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
|
|
66
66
|
export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
|
|
67
67
|
export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
|
|
68
68
|
export declare function isRunningLiveSession(s: ActiveSession): boolean;
|
|
@@ -112,6 +112,13 @@ export type RecapSource = 'label' | 'last' | 'prompt';
|
|
|
112
112
|
export interface ActiveSession {
|
|
113
113
|
context: ActiveContext;
|
|
114
114
|
kind: string;
|
|
115
|
+
/**
|
|
116
|
+
* Custom harness / profile name when this live process was launched via
|
|
117
|
+
* `agents run <profile>` (e.g. `deepseek`). `kind` stays the HOST process
|
|
118
|
+
* (claude) so transcript lookup and live-signal parsers keep working.
|
|
119
|
+
* `sessions --active` displays this when set (PHNX-2935).
|
|
120
|
+
*/
|
|
121
|
+
harness?: string;
|
|
115
122
|
/** Specific host app — 'code', 'cursor', 'codium', 'iterm', 'terminal', 'warp', 'tmux', etc. */
|
|
116
123
|
host?: string;
|
|
117
124
|
pid?: number;
|
|
@@ -652,6 +659,8 @@ export declare function listUnattributedActive(attributed: Set<number>): Promise
|
|
|
652
659
|
/** One tmux pane's resolved agent identity for the authoritative source. */
|
|
653
660
|
interface PaneIdentity {
|
|
654
661
|
agent: string;
|
|
662
|
+
/** Custom harness/profile name from the launch registry, when set. */
|
|
663
|
+
harness?: string;
|
|
655
664
|
/** Exact session id when resolvable (launch registry, or the hook join). */
|
|
656
665
|
sessionId?: string;
|
|
657
666
|
/** The agent's OS pid from the launch registry (may differ from `pane_pid`). */
|
|
@@ -166,6 +166,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
166
166
|
s.origin = m.origin;
|
|
167
167
|
if (!s.routineName && m.routineName)
|
|
168
168
|
s.routineName = m.routineName;
|
|
169
|
+
if (!s.harness && m.harness)
|
|
170
|
+
s.harness = m.harness;
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
function loadBackfillMetaFor(sessions) {
|
|
@@ -1010,6 +1012,7 @@ export async function listTeamsActive(opts = {}) {
|
|
|
1010
1012
|
return applyState({
|
|
1011
1013
|
context: 'teams',
|
|
1012
1014
|
kind: a.agentType,
|
|
1015
|
+
harness: a.profileName ?? undefined,
|
|
1013
1016
|
pid: a.pid ?? undefined,
|
|
1014
1017
|
sessionId: resolvedId,
|
|
1015
1018
|
machine: offloaded ? execHost : undefined,
|
|
@@ -1071,6 +1074,7 @@ export async function listTerminalsActive() {
|
|
|
1071
1074
|
return applyState({
|
|
1072
1075
|
context: 'terminal',
|
|
1073
1076
|
kind: t.kind,
|
|
1077
|
+
harness: pidEntry?.harness,
|
|
1074
1078
|
host: detectHost(t.pid, procByPid),
|
|
1075
1079
|
tty: procByPid.get(t.pid)?.tty,
|
|
1076
1080
|
pid: t.pid,
|
|
@@ -1557,6 +1561,7 @@ async function listUnattributedActiveLive(attributed) {
|
|
|
1557
1561
|
out.push(applyState({
|
|
1558
1562
|
context,
|
|
1559
1563
|
kind,
|
|
1564
|
+
harness: entry?.harness,
|
|
1560
1565
|
host,
|
|
1561
1566
|
tty: procByPid.get(pid)?.tty,
|
|
1562
1567
|
pid,
|
|
@@ -1621,7 +1626,7 @@ export function resolvePaneIdentity(pane, sessName, meta, liveEntry, getHookInde
|
|
|
1621
1626
|
terminalId: liveEntry.terminalId,
|
|
1622
1627
|
})?.session_id
|
|
1623
1628
|
?? nameSessionId;
|
|
1624
|
-
return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
|
|
1629
|
+
return { agent: liveEntry.agent, harness: liveEntry.harness, sessionId, pid: liveEntry.pid };
|
|
1625
1630
|
}
|
|
1626
1631
|
// No live-registry entry. Session-meta labels are the wrapped-origin fallback;
|
|
1627
1632
|
// prefer them, then fall back to the name so a pane with neither a registry
|
|
@@ -1771,6 +1776,7 @@ export async function listTmuxAgentSessions() {
|
|
|
1771
1776
|
out.push(applyState({
|
|
1772
1777
|
context: 'terminal',
|
|
1773
1778
|
kind: id.agent,
|
|
1779
|
+
harness: id.harness,
|
|
1774
1780
|
host: 'tmux',
|
|
1775
1781
|
pid,
|
|
1776
1782
|
sessionId: id.sessionId ?? sessionIdFromFile(sessionFile),
|
|
@@ -7,6 +7,13 @@ export interface SessionActorRecord {
|
|
|
7
7
|
initiatedBy?: 'human' | 'agent';
|
|
8
8
|
/** Effective permissions mode used by the launcher. */
|
|
9
9
|
mode?: SessionRunMode;
|
|
10
|
+
/**
|
|
11
|
+
* Custom harness / profile name when launched via `agents run <profile>`
|
|
12
|
+
* (e.g. `deepseek`). Joined onto the session index at scan time so a
|
|
13
|
+
* durable listing can distinguish the profile from its host agent
|
|
14
|
+
* (PHNX-2935).
|
|
15
|
+
*/
|
|
16
|
+
harness?: string;
|
|
10
17
|
/** Stable wrapper names that resolve to this native session id. */
|
|
11
18
|
aliases?: string[];
|
|
12
19
|
startedAtMs: number;
|
|
@@ -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.harness === 'string'
|
|
43
44
|
|| (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
|
|
44
45
|
}
|
|
45
46
|
function normalizedAliases(aliases) {
|
|
@@ -83,6 +84,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
|
|
|
83
84
|
actor: previous?.actor,
|
|
84
85
|
initiatedBy: previous?.initiatedBy,
|
|
85
86
|
mode: previous?.mode,
|
|
87
|
+
harness: previous?.harness,
|
|
86
88
|
aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
|
|
87
89
|
startedAtMs: previous?.startedAtMs ?? Date.now(),
|
|
88
90
|
});
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
|
|
|
12
12
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
13
13
|
* assert against the constant instead of hardcoding a number that every bump
|
|
14
14
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
15
|
-
export declare const SCHEMA_VERSION =
|
|
15
|
+
export declare const SCHEMA_VERSION = 41;
|
|
16
16
|
/**
|
|
17
17
|
* Bumping this invalidates every cached facet row without touching the schema
|
|
18
18
|
* version, so a change to the extraction logic (a new metric, a corrected bucket)
|
package/dist/lib/session/db.js
CHANGED
|
@@ -27,7 +27,7 @@ const DB_PATH = getSessionsDbPath();
|
|
|
27
27
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
28
28
|
* assert against the constant instead of hardcoding a number that every bump
|
|
29
29
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
30
|
-
export const SCHEMA_VERSION =
|
|
30
|
+
export const SCHEMA_VERSION = 41;
|
|
31
31
|
/**
|
|
32
32
|
* Bump to force `agents sessions backfill resources` to re-derive every
|
|
33
33
|
* session's skill/slash-command tallies on its next run (resource_scan_ledger
|
|
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
64
64
|
id TEXT PRIMARY KEY,
|
|
65
65
|
short_id TEXT NOT NULL,
|
|
66
66
|
agent TEXT NOT NULL,
|
|
67
|
+
harness TEXT,
|
|
67
68
|
origin TEXT DEFAULT 'cli',
|
|
68
69
|
routine_name TEXT,
|
|
69
70
|
routine_run_id TEXT,
|
|
@@ -1072,6 +1073,18 @@ function migrateSchema(db, fromVersion) {
|
|
|
1072
1073
|
db.exec(`ALTER TABLE sessions ADD COLUMN background_shell_count INTEGER`);
|
|
1073
1074
|
}
|
|
1074
1075
|
}
|
|
1076
|
+
if (fromVersion < 41) {
|
|
1077
|
+
// v40 -> v41: persist the custom-harness / profile name a run was launched
|
|
1078
|
+
// as (PHNX-2935). Transcript discovery still keys `agent` on the HOST
|
|
1079
|
+
// (the file lives under the host's session dir), so without this column
|
|
1080
|
+
// `agents sessions` cannot tell `agents run deepseek` from a native claude
|
|
1081
|
+
// run. Additive, no ledger flush — the name is launch metadata joined from
|
|
1082
|
+
// the actor sidecar, not parsed from the transcript. Pre-upgrade rows stay
|
|
1083
|
+
// NULL (native / unknown) until a sidecar-backed rescan fills them.
|
|
1084
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
|
|
1085
|
+
if (!cols.has('harness'))
|
|
1086
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
|
|
1087
|
+
}
|
|
1075
1088
|
}
|
|
1076
1089
|
/**
|
|
1077
1090
|
* Stamp `account_key` / `account_org` / `account` on every Claude row from its
|
|
@@ -1194,6 +1207,21 @@ export function getDB() {
|
|
|
1194
1207
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
|
|
1195
1208
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
|
|
1196
1209
|
}
|
|
1210
|
+
// harness column: only after the column is guaranteed present.
|
|
1211
|
+
// Fresh SCHEMA (v41) includes the column; older DBs get it from migrate v41.
|
|
1212
|
+
// schema_version can be stamped at SCHEMA_VERSION without the column existing
|
|
1213
|
+
// — getDB writes the marker for any DB whose meta has no row (a hand-built
|
|
1214
|
+
// or partially-created index), and migrateSchema never runs in that path
|
|
1215
|
+
// (`currentVersion === undefined`). If a partial upgrade left schema_version
|
|
1216
|
+
// ahead of the column, repair here so the next upsertSession INSERT naming
|
|
1217
|
+
// `harness` does not throw (PHNX-2935). Same shape as the `machine` repair
|
|
1218
|
+
// above, for the same reason.
|
|
1219
|
+
{
|
|
1220
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
1221
|
+
if (!cols.some((c) => c.name === 'harness')) {
|
|
1222
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1197
1225
|
// One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
|
|
1198
1226
|
// them anymore. Guarded by a meta flag so we only try once.
|
|
1199
1227
|
const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
|
|
@@ -1583,7 +1611,7 @@ export function recordDirScans(entries) {
|
|
|
1583
1611
|
}
|
|
1584
1612
|
const upsertSessionStmt = (db) => db.prepare(`
|
|
1585
1613
|
INSERT INTO sessions (
|
|
1586
|
-
id, short_id, agent, origin, routine_name, routine_run_id,
|
|
1614
|
+
id, short_id, agent, harness, origin, routine_name, routine_run_id,
|
|
1587
1615
|
version, account, account_key, account_org, mode, timestamp, last_activity,
|
|
1588
1616
|
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
1589
1617
|
output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
|
|
@@ -1594,7 +1622,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1594
1622
|
recent_directories_touched, linear_project, linear_project_url, machine,
|
|
1595
1623
|
actor, initiated_by, used_browser, used_computer
|
|
1596
1624
|
) VALUES (
|
|
1597
|
-
@id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
|
|
1625
|
+
@id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
|
|
1598
1626
|
@version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
|
|
1599
1627
|
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
1600
1628
|
@output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
|
|
@@ -1608,6 +1636,11 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1608
1636
|
ON CONFLICT(id) DO UPDATE SET
|
|
1609
1637
|
short_id = excluded.short_id,
|
|
1610
1638
|
agent = excluded.agent,
|
|
1639
|
+
-- Custom harness/profile name is launch metadata, not transcript-derived.
|
|
1640
|
+
-- COALESCE(existing, incoming) keeps a stored stamp on rescan (the scanner
|
|
1641
|
+
-- carries none) and backfills a NULL-first row once the sidecar lands —
|
|
1642
|
+
-- the same write-once pattern as actor/initiated_by (PHNX-2935).
|
|
1643
|
+
harness = COALESCE(sessions.harness, excluded.harness),
|
|
1611
1644
|
origin = excluded.origin,
|
|
1612
1645
|
routine_name = excluded.routine_name,
|
|
1613
1646
|
routine_run_id = excluded.routine_run_id,
|
|
@@ -1886,6 +1919,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
1886
1919
|
id: meta.id,
|
|
1887
1920
|
short_id: meta.shortId,
|
|
1888
1921
|
agent: meta.agent,
|
|
1922
|
+
harness: meta.harness ?? actorRec?.harness ?? null,
|
|
1889
1923
|
origin: meta.origin ?? 'cli',
|
|
1890
1924
|
routine_name: meta.routineName ?? null,
|
|
1891
1925
|
routine_run_id: meta.routineRunId ?? null,
|
|
@@ -2081,6 +2115,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
2081
2115
|
id: meta.id,
|
|
2082
2116
|
short_id: meta.shortId,
|
|
2083
2117
|
agent: meta.agent,
|
|
2118
|
+
harness: meta.harness ?? actorIndex.get(meta.id)?.harness ?? null,
|
|
2084
2119
|
origin: meta.origin ?? 'cli',
|
|
2085
2120
|
routine_name: meta.routineName ?? null,
|
|
2086
2121
|
routine_run_id: meta.routineRunId ?? null,
|
|
@@ -2309,6 +2344,7 @@ function rowToMeta(row) {
|
|
|
2309
2344
|
id: row.id,
|
|
2310
2345
|
shortId: row.short_id,
|
|
2311
2346
|
agent: row.agent,
|
|
2347
|
+
harness: row.harness ?? undefined,
|
|
2312
2348
|
origin: (row.origin === 'routine' ? 'routine' : 'cli'),
|
|
2313
2349
|
routineName: row.routine_name ?? undefined,
|
|
2314
2350
|
routineRunId: row.routine_run_id ?? undefined,
|
|
@@ -26,7 +26,7 @@ import { getConfigSymlinkVersion } from '../installations/shims.js';
|
|
|
26
26
|
import { SESSION_AGENTS } from './types.js';
|
|
27
27
|
import { deriveShortId } from './short-id.js';
|
|
28
28
|
import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
|
|
29
|
-
import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput,
|
|
29
|
+
import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput, cleanGeneratedSessionLabel } from './prompt.js';
|
|
30
30
|
import { isBackgroundShellStart, isSkillInvocation, extractSkills, extractSlashCommands, isSubAgentTool } from './highlights.js';
|
|
31
31
|
import { parseAntigravity, parseCursor, splitSessionFilePath } from './parse.js';
|
|
32
32
|
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
@@ -3268,15 +3268,10 @@ export function finalizeClaudeScan(state) {
|
|
|
3268
3268
|
// A topic is the first meaningful prompt. Harness-owned names travel in the
|
|
3269
3269
|
// separate label field so consumers can replace an early topic once Claude's
|
|
3270
3270
|
// generated title (or a later `/rename`) arrives.
|
|
3271
|
-
// Claude
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
|
|
3275
|
-
// only for that injected line, so collapse it to `/<skill>`. A `/rename`
|
|
3276
|
-
// (`custom-title`) is the user's own words and is never rewritten.
|
|
3277
|
-
const generated = classifyUserPrompt(state.aiTitle ?? '');
|
|
3278
|
-
const label = state.customTitle
|
|
3279
|
-
|| (generated.kind === 'skill' ? generated.clean : state.aiTitle);
|
|
3271
|
+
// Generated titles (Claude `ai-title`, Cursor `chatMeta.title`) go through
|
|
3272
|
+
// one shared cleaner so a skill-preamble echo collapses to `/<skill>`. A
|
|
3273
|
+
// `/rename` (`custom-title`) is the user's own words and is never rewritten.
|
|
3274
|
+
const label = state.customTitle || cleanGeneratedSessionLabel(state.aiTitle);
|
|
3280
3275
|
const worktree = detectWorktree(state.cwd, state.gitBranch);
|
|
3281
3276
|
const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
|
|
3282
3277
|
return {
|
|
@@ -4391,8 +4386,8 @@ export function readCursorMeta(filePath, currentVersion) {
|
|
|
4391
4386
|
.filter((event) => event.type === 'message' && event.role === 'user' && event.content)
|
|
4392
4387
|
.map((event) => event.content);
|
|
4393
4388
|
const firstUserText = userTexts[0];
|
|
4394
|
-
const title = typeof chatMeta?.title === 'string'
|
|
4395
|
-
? chatMeta.title
|
|
4389
|
+
const title = typeof chatMeta?.title === 'string'
|
|
4390
|
+
? cleanGeneratedSessionLabel(chatMeta.title)
|
|
4396
4391
|
: undefined;
|
|
4397
4392
|
const meta = {
|
|
4398
4393
|
id: sessionId,
|
|
@@ -37,6 +37,7 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
|
|
|
37
37
|
id,
|
|
38
38
|
shortId: deriveShortId(id),
|
|
39
39
|
agent: active.kind,
|
|
40
|
+
harness: active.harness,
|
|
40
41
|
timestamp: new Date(startedMs).toISOString(),
|
|
41
42
|
lastActivity: new Date(lastMs).toISOString(),
|
|
42
43
|
// An empty file path is honest — a just-created session may have no
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type TmuxAliasState = 'not-an-alias' | 'no-server' | 'absent' | 'dead' | 'live';
|
|
2
|
+
/** Shape of the tmux alias the CLI mints for an agent session: `ag-<agent>-<shortid>`.
|
|
3
|
+
* Delegates to the one canonical matcher beside the name parsers in active.ts. */
|
|
4
|
+
export declare function looksLikeTmuxAlias(selector: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Classify a selector against the live tmux server. Split from the attach so the
|
|
7
|
+
* decision is testable against a real tmux server without replacing the caller's
|
|
8
|
+
* shell (attaching is not something a test can undo).
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveTmuxAliasState(selector: string, socket?: string): Promise<TmuxAliasState>;
|
|
11
|
+
/**
|
|
12
|
+
* A local `ag-<agent>-<8hex>` alias names a pane on THIS box. Attach it
|
|
13
|
+
* without any fleet SSH. `--device` keeps the sweep: the caller scoped
|
|
14
|
+
* identity to another machine.
|
|
15
|
+
*/
|
|
16
|
+
export declare function shouldAttachLocalTmuxAliasBeforeFleet(selector: string | undefined, hosts: string[]): selector is string;
|
|
17
|
+
/**
|
|
18
|
+
* Attach a live tmux session named exactly as the selector, without needing the
|
|
19
|
+
* session index to know anything about it.
|
|
20
|
+
*
|
|
21
|
+
* SES-41 requires a `ag-<agent>-<8hex>` tmux alias to resolve, but the alias's
|
|
22
|
+
* hex is the LAUNCH id, not the harness session id, and for a harness that
|
|
23
|
+
* writes no `state/sessions/<pid>.json` record there is no mapping back to a
|
|
24
|
+
* SessionMeta at all. The pane is the thing the user asked for, and its NAME is
|
|
25
|
+
* sufficient to attach it — so an unattributable session is still reachable
|
|
26
|
+
* instead of being a dead end that forces raw `tmux -S … attach`.
|
|
27
|
+
*
|
|
28
|
+
* Returns false when this is not an alias, the server/session is absent, or
|
|
29
|
+
* every pane is dead — the caller then continues to normal id resolution.
|
|
30
|
+
* SES-39's "re-read `pane_dead` immediately before attach" is honoured here:
|
|
31
|
+
* liveness is queried at attach time, not read from a roster.
|
|
32
|
+
*/
|
|
33
|
+
export declare function attachLiveTmuxAlias(selector: string): Promise<boolean>;
|
|
34
|
+
export type LocalAliasBySuffix = {
|
|
35
|
+
kind: 'alias';
|
|
36
|
+
alias: string;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'collision';
|
|
39
|
+
aliases: string[];
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'none';
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a bare 8-hex selector (`agents tmux ls`'s hex column, typed without
|
|
45
|
+
* the `ag-<agent>-` prefix) against LIVE local panes only. Dead panes matching
|
|
46
|
+
* the suffix are excluded before the uniqueness check — a retained, exited pane
|
|
47
|
+
* must never compete with (or block) a live one for the same short id.
|
|
48
|
+
*
|
|
49
|
+
* Exported for direct testing: `attachLocalLiveSelector` composes this with a
|
|
50
|
+
* real `attachTmux()` call, which a unit test cannot safely invoke (it takes
|
|
51
|
+
* over the terminal).
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveUniqueLocalLiveAliasBySuffix(shortId: string, socket?: string): Promise<LocalAliasBySuffix>;
|
|
54
|
+
/**
|
|
55
|
+
* The full local gate (PHNX-3292 rule 1): a selector that is either a live
|
|
56
|
+
* local tmux alias, or a bare 8-hex short id that names exactly one live local
|
|
57
|
+
* pane, attaches immediately — zero SSH. `--device`/`hosts` scopes identity to
|
|
58
|
+
* another machine and disables this gate entirely (rule 4).
|
|
59
|
+
*
|
|
60
|
+
* Two LIVE local panes matching the same 8-hex suffix fail closed (rule 5): a
|
|
61
|
+
* collision is reported with both names rather than guessing, and the caller
|
|
62
|
+
* must not fall through to fleet resolution for a selector that is genuinely
|
|
63
|
+
* ambiguous ON THIS BOX.
|
|
64
|
+
*
|
|
65
|
+
* Returns `false` (never attached, never reported) when the selector does not
|
|
66
|
+
* name a live local pane at all, so the caller can continue to session-id
|
|
67
|
+
* resolution / the fleet race.
|
|
68
|
+
*/
|
|
69
|
+
export declare function attachLocalLiveSelector(selector: string | undefined, hosts: string[]): Promise<boolean>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach a LIVE tmux pane on THIS box, with zero SSH — the local half of the
|
|
3
|
+
* PHNX-3292 product rule: "first unique match wins, local before fleet."
|
|
4
|
+
*
|
|
5
|
+
* `agents tmux ls` prints `ag-<agent>-<8hex>` names. A selector that already
|
|
6
|
+
* names one of those panes (the full alias, or its bare 8-hex suffix when it
|
|
7
|
+
* is unique among LIVE local panes) is attached directly — no session index
|
|
8
|
+
* lookup, no fleet SSH fan-out. `sessions resume`, the deprecated `sessions
|
|
9
|
+
* attach`, and `sessions focus` all take this gate before anything else.
|
|
10
|
+
*
|
|
11
|
+
* Lives outside `commands/` (not in focus.ts) so `sessions-resume.ts` and
|
|
12
|
+
* `attach.ts` can both import it without a focus.ts <-> sessions-resume.ts
|
|
13
|
+
* import cycle (focus.ts already imports from sessions-resume.ts).
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { attachTmux, ensureSessionHookRepaired, getDefaultSocketPath, hasSession, listSessions, runTmux, teardownIfAgentExited } from '../tmux/index.js';
|
|
18
|
+
import { isAgentTmuxAlias } from './types.js';
|
|
19
|
+
/** Width of `SessionMeta.shortId` / the hex an `ag-<agent>-<8hex>` alias embeds. */
|
|
20
|
+
const SHORT_SESSION_ID_RE = /^[0-9a-f]{8}$/i;
|
|
21
|
+
/** Shape of the tmux alias the CLI mints for an agent session: `ag-<agent>-<shortid>`.
|
|
22
|
+
* Delegates to the one canonical matcher beside the name parsers in active.ts. */
|
|
23
|
+
export function looksLikeTmuxAlias(selector) {
|
|
24
|
+
return isAgentTmuxAlias(selector);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Classify a selector against the live tmux server. Split from the attach so the
|
|
28
|
+
* decision is testable against a real tmux server without replacing the caller's
|
|
29
|
+
* shell (attaching is not something a test can undo).
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveTmuxAliasState(selector, socket) {
|
|
32
|
+
if (!looksLikeTmuxAlias(selector))
|
|
33
|
+
return 'not-an-alias';
|
|
34
|
+
const sock = socket ?? getDefaultSocketPath();
|
|
35
|
+
if (!fs.existsSync(sock))
|
|
36
|
+
return 'no-server';
|
|
37
|
+
if (!(await hasSession(selector, sock).catch(() => false)))
|
|
38
|
+
return 'absent';
|
|
39
|
+
const panes = await runTmux({
|
|
40
|
+
socket: sock,
|
|
41
|
+
args: ['list-panes', '-t', `=${selector}`, '-F', '#{pane_dead}'],
|
|
42
|
+
}).catch(() => undefined);
|
|
43
|
+
const states = (panes?.stdout ?? '').split('\n').map(s => s.trim()).filter(Boolean);
|
|
44
|
+
return states.some(d => d === '0') ? 'live' : 'dead';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A local `ag-<agent>-<8hex>` alias names a pane on THIS box. Attach it
|
|
48
|
+
* without any fleet SSH. `--device` keeps the sweep: the caller scoped
|
|
49
|
+
* identity to another machine.
|
|
50
|
+
*/
|
|
51
|
+
export function shouldAttachLocalTmuxAliasBeforeFleet(selector, hosts) {
|
|
52
|
+
return !!selector && hosts.length === 0 && isAgentTmuxAlias(selector);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Attach a live tmux session named exactly as the selector, without needing the
|
|
56
|
+
* session index to know anything about it.
|
|
57
|
+
*
|
|
58
|
+
* SES-41 requires a `ag-<agent>-<8hex>` tmux alias to resolve, but the alias's
|
|
59
|
+
* hex is the LAUNCH id, not the harness session id, and for a harness that
|
|
60
|
+
* writes no `state/sessions/<pid>.json` record there is no mapping back to a
|
|
61
|
+
* SessionMeta at all. The pane is the thing the user asked for, and its NAME is
|
|
62
|
+
* sufficient to attach it — so an unattributable session is still reachable
|
|
63
|
+
* instead of being a dead end that forces raw `tmux -S … attach`.
|
|
64
|
+
*
|
|
65
|
+
* Returns false when this is not an alias, the server/session is absent, or
|
|
66
|
+
* every pane is dead — the caller then continues to normal id resolution.
|
|
67
|
+
* SES-39's "re-read `pane_dead` immediately before attach" is honoured here:
|
|
68
|
+
* liveness is queried at attach time, not read from a roster.
|
|
69
|
+
*/
|
|
70
|
+
export async function attachLiveTmuxAlias(selector) {
|
|
71
|
+
const socket = getDefaultSocketPath();
|
|
72
|
+
if (await resolveTmuxAliasState(selector, socket) !== 'live')
|
|
73
|
+
return false;
|
|
74
|
+
if (!process.stdout.isTTY) {
|
|
75
|
+
console.error(chalk.red(`"${selector}" is a live tmux session, but attaching needs a TTY.`));
|
|
76
|
+
console.error(chalk.gray(` Run it from a terminal, or: agents tmux attach ${selector}`));
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
// Already inside a tmux client on this socket — which is the normal state for
|
|
81
|
+
// an interactive agent session here — MOVE that client instead of nesting a
|
|
82
|
+
// second one. jumpTo (go.ts) does the same; attaching from inside tmux
|
|
83
|
+
// otherwise stacks clients rather than taking you to the session.
|
|
84
|
+
if (process.env.TMUX) {
|
|
85
|
+
await runTmux({ socket, args: ['switch-client', '-t', `=${selector}`], throwOnError: false }).catch(() => { });
|
|
86
|
+
console.log(chalk.gray(`Switched this tmux client to ${selector}.`));
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
console.log(chalk.gray(`Attaching ${selector} — Ctrl-b d to detach.`));
|
|
90
|
+
// Repair a legacy/stale pane-died hook before handing the session to an
|
|
91
|
+
// attach client — the 5-min daemon reconcile that used to cover this was
|
|
92
|
+
// deleted; attach-time repair is what closes the gap now (RUSH-2435).
|
|
93
|
+
await ensureSessionHookRepaired(selector, socket);
|
|
94
|
+
const code = await attachTmux({ socket, args: ['attach-session', '-t', `=${selector}`] });
|
|
95
|
+
await teardownIfAgentExited(selector, socket);
|
|
96
|
+
process.exitCode = code;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a bare 8-hex selector (`agents tmux ls`'s hex column, typed without
|
|
101
|
+
* the `ag-<agent>-` prefix) against LIVE local panes only. Dead panes matching
|
|
102
|
+
* the suffix are excluded before the uniqueness check — a retained, exited pane
|
|
103
|
+
* must never compete with (or block) a live one for the same short id.
|
|
104
|
+
*
|
|
105
|
+
* Exported for direct testing: `attachLocalLiveSelector` composes this with a
|
|
106
|
+
* real `attachTmux()` call, which a unit test cannot safely invoke (it takes
|
|
107
|
+
* over the terminal).
|
|
108
|
+
*/
|
|
109
|
+
export async function resolveUniqueLocalLiveAliasBySuffix(shortId, socket) {
|
|
110
|
+
const sock = socket ?? getDefaultSocketPath();
|
|
111
|
+
if (!fs.existsSync(sock))
|
|
112
|
+
return { kind: 'none' };
|
|
113
|
+
let sessions;
|
|
114
|
+
try {
|
|
115
|
+
sessions = await listSessions({ socket: sock });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { kind: 'none' };
|
|
119
|
+
}
|
|
120
|
+
const named = sessions
|
|
121
|
+
.map((session) => session.name)
|
|
122
|
+
.filter((name) => isAgentTmuxAlias(name) && name.toLowerCase().endsWith(shortId.toLowerCase()));
|
|
123
|
+
if (named.length === 0)
|
|
124
|
+
return { kind: 'none' };
|
|
125
|
+
const states = await Promise.all(named.map(async (name) => ({ name, state: await resolveTmuxAliasState(name, sock) })));
|
|
126
|
+
const live = states.filter((entry) => entry.state === 'live').map((entry) => entry.name);
|
|
127
|
+
if (live.length === 0)
|
|
128
|
+
return { kind: 'none' };
|
|
129
|
+
if (live.length > 1)
|
|
130
|
+
return { kind: 'collision', aliases: live };
|
|
131
|
+
return { kind: 'alias', alias: live[0] };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The full local gate (PHNX-3292 rule 1): a selector that is either a live
|
|
135
|
+
* local tmux alias, or a bare 8-hex short id that names exactly one live local
|
|
136
|
+
* pane, attaches immediately — zero SSH. `--device`/`hosts` scopes identity to
|
|
137
|
+
* another machine and disables this gate entirely (rule 4).
|
|
138
|
+
*
|
|
139
|
+
* Two LIVE local panes matching the same 8-hex suffix fail closed (rule 5): a
|
|
140
|
+
* collision is reported with both names rather than guessing, and the caller
|
|
141
|
+
* must not fall through to fleet resolution for a selector that is genuinely
|
|
142
|
+
* ambiguous ON THIS BOX.
|
|
143
|
+
*
|
|
144
|
+
* Returns `false` (never attached, never reported) when the selector does not
|
|
145
|
+
* name a live local pane at all, so the caller can continue to session-id
|
|
146
|
+
* resolution / the fleet race.
|
|
147
|
+
*/
|
|
148
|
+
export async function attachLocalLiveSelector(selector, hosts) {
|
|
149
|
+
if (shouldAttachLocalTmuxAliasBeforeFleet(selector, hosts)) {
|
|
150
|
+
return attachLiveTmuxAlias(selector);
|
|
151
|
+
}
|
|
152
|
+
if (!selector || hosts.length > 0 || !SHORT_SESSION_ID_RE.test(selector))
|
|
153
|
+
return false;
|
|
154
|
+
const found = await resolveUniqueLocalLiveAliasBySuffix(selector);
|
|
155
|
+
if (found.kind === 'none')
|
|
156
|
+
return false;
|
|
157
|
+
if (found.kind === 'collision') {
|
|
158
|
+
console.error(chalk.red(`"${selector}" matches ${found.aliases.length} live local panes: ${found.aliases.join(', ')}`));
|
|
159
|
+
console.error(chalk.gray(' Pass the full alias to disambiguate — see: agents tmux ls'));
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return attachLiveTmuxAlias(found.alias);
|
|
164
|
+
}
|