@phnx-labs/agents-cli 1.22.52 → 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 +146 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.js +2 -0
- package/dist/commands/go.js +2 -1
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions.js +30 -20
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/sync.js +44 -0
- 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/profiles.d.ts +18 -0
- package/dist/lib/browser/profiles.js +26 -1
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- 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 +60 -30
- package/dist/lib/exec.js +65 -27
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- 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/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/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/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/worker-template.js +12 -7
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- 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
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
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export interface PidSessionEntry {
|
|
2
2
|
pid: number;
|
|
3
3
|
agent: string;
|
|
4
|
+
/**
|
|
5
|
+
* Custom harness / profile name when this pid was launched via
|
|
6
|
+
* `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST
|
|
7
|
+
* CLI (`claude`) so process matching and transcript discovery keep
|
|
8
|
+
* working. `sessions --active` displays this when set (PHNX-2935).
|
|
9
|
+
*/
|
|
10
|
+
harness?: string;
|
|
4
11
|
/** The launch session id. Present for agents launched with a known id (Claude). */
|
|
5
12
|
sessionId?: string;
|
|
6
13
|
cwd?: string;
|
|
@@ -69,5 +69,20 @@ export interface ClassifiedPrompt {
|
|
|
69
69
|
export declare function classifyUserPrompt(raw: string | undefined, opts?: {
|
|
70
70
|
hasImageAttachment?: boolean;
|
|
71
71
|
}): ClassifiedPrompt;
|
|
72
|
+
/**
|
|
73
|
+
* Collapse a harness-generated session title when it is injected skill
|
|
74
|
+
* scaffolding; otherwise leave the title as the harness wrote it.
|
|
75
|
+
*
|
|
76
|
+
* Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
|
|
77
|
+
* first turn, so a session opened with a skill gets named after the injected
|
|
78
|
+
* "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
|
|
79
|
+
* `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
|
|
80
|
+
* whitespace-only input yields `undefined` so the caller falls through to the
|
|
81
|
+
* first-prompt topic.
|
|
82
|
+
*
|
|
83
|
+
* A user-authored title (Claude `/rename` / `custom-title`) is never passed
|
|
84
|
+
* here — the caller keeps it verbatim.
|
|
85
|
+
*/
|
|
86
|
+
export declare function cleanGeneratedSessionLabel(title: string | undefined): string | undefined;
|
|
72
87
|
/** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
|
|
73
88
|
export declare function extractSessionTopic(raw: string): string | undefined;
|
|
@@ -201,6 +201,27 @@ export function classifyUserPrompt(raw, opts = {}) {
|
|
|
201
201
|
const display = (cleanedFirst || firstLine).replace(IMAGE_PATH_RE, '[image]').trim();
|
|
202
202
|
return { clean: display, kind: 'text' };
|
|
203
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Collapse a harness-generated session title when it is injected skill
|
|
206
|
+
* scaffolding; otherwise leave the title as the harness wrote it.
|
|
207
|
+
*
|
|
208
|
+
* Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
|
|
209
|
+
* first turn, so a session opened with a skill gets named after the injected
|
|
210
|
+
* "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
|
|
211
|
+
* `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
|
|
212
|
+
* whitespace-only input yields `undefined` so the caller falls through to the
|
|
213
|
+
* first-prompt topic.
|
|
214
|
+
*
|
|
215
|
+
* A user-authored title (Claude `/rename` / `custom-title`) is never passed
|
|
216
|
+
* here — the caller keeps it verbatim.
|
|
217
|
+
*/
|
|
218
|
+
export function cleanGeneratedSessionLabel(title) {
|
|
219
|
+
const trimmed = title?.trim();
|
|
220
|
+
if (!trimmed)
|
|
221
|
+
return undefined;
|
|
222
|
+
const classified = classifyUserPrompt(trimmed);
|
|
223
|
+
return classified.kind === 'skill' ? classified.clean : trimmed;
|
|
224
|
+
}
|
|
204
225
|
/** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
|
|
205
226
|
export function extractSessionTopic(raw) {
|
|
206
227
|
if (!raw.trim())
|
|
@@ -31,6 +31,16 @@ export declare const AG_TMUX_NAME_RE: RegExp;
|
|
|
31
31
|
/** Whether `name` is an `ag-<agent>-<shortid>` tmux session name. */
|
|
32
32
|
export declare function isAgentTmuxAlias(name: string): boolean;
|
|
33
33
|
export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
|
|
34
|
+
/**
|
|
35
|
+
* The name `agents sessions` shows for a run. A custom harness launched via a
|
|
36
|
+
* profile (`agents run deepseek`) keeps its host agent for transcript discovery
|
|
37
|
+
* and parsing (`claude`) and stamps the profile name on `harness`. Display
|
|
38
|
+
* prefers that stamp so a deepseek run is not listed as claude (PHNX-2935).
|
|
39
|
+
*/
|
|
40
|
+
export declare function sessionDisplayAgent(session: {
|
|
41
|
+
agent: string;
|
|
42
|
+
harness?: string | null;
|
|
43
|
+
}): string;
|
|
34
44
|
/** A single normalized event within a session (message, tool call, thinking, etc.). */
|
|
35
45
|
export interface SessionEvent {
|
|
36
46
|
type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook' | 'interrupt';
|
|
@@ -154,6 +164,13 @@ export interface SessionMeta {
|
|
|
154
164
|
id: string;
|
|
155
165
|
shortId: string;
|
|
156
166
|
agent: SessionAgentId;
|
|
167
|
+
/**
|
|
168
|
+
* Custom harness / profile name when this session was launched via
|
|
169
|
+
* `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST
|
|
170
|
+
* harness that produced the transcript (`claude`, …) so discovery and
|
|
171
|
+
* parsing keep working. Display surfaces this when set (PHNX-2935).
|
|
172
|
+
*/
|
|
173
|
+
harness?: string;
|
|
157
174
|
/** Where the indexed transcript came from. Routine rows are archived from a run directory. */
|
|
158
175
|
origin?: 'cli' | 'routine';
|
|
159
176
|
/** Routine name for transcripts archived from ~/.agents/.history/runs/<name>/<runId>/. */
|
|
@@ -31,3 +31,13 @@ export function isAgentTmuxAlias(name) {
|
|
|
31
31
|
export function isSessionTrackedAgent(agent) {
|
|
32
32
|
return SESSION_AGENTS.includes(agent);
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The name `agents sessions` shows for a run. A custom harness launched via a
|
|
36
|
+
* profile (`agents run deepseek`) keeps its host agent for transcript discovery
|
|
37
|
+
* and parsing (`claude`) and stamps the profile name on `harness`. Display
|
|
38
|
+
* prefers that stamp so a deepseek run is not listed as claude (PHNX-2935).
|
|
39
|
+
*/
|
|
40
|
+
export function sessionDisplayAgent(session) {
|
|
41
|
+
const harness = session.harness?.trim();
|
|
42
|
+
return harness || session.agent;
|
|
43
|
+
}
|
|
@@ -518,20 +518,25 @@ function renderAttributionBar(meta, handle) {
|
|
|
518
518
|
// page's own CSS can never wash the bar out (inline beats a page stylesheet);
|
|
519
519
|
// the rest is a namespaced style block with !important on every colour so a
|
|
520
520
|
// broad page rule (span selector to black, etc.) cannot make the text vanish.
|
|
521
|
+
// FIXED, full-viewport-width chrome (not sticky-inside-body — that inherited the
|
|
522
|
+
// page's max-width/margins and rendered as a floating box). html padding-top
|
|
523
|
+
// pushes the whole page down by the bar's height; flex-wrap:nowrap + ellipsis on
|
|
524
|
+
// the left keeps the bar exactly one line tall so the push height stays correct.
|
|
521
525
|
return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
|
|
522
526
|
'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
|
|
523
527
|
'<style>' +
|
|
524
|
-
'
|
|
525
|
-
'
|
|
526
|
-
'
|
|
528
|
+
'html{padding-top:38px !important}' +
|
|
529
|
+
'.agents-share-bar{all:initial;position:fixed !important;top:0 !important;left:0 !important;right:0 !important;width:100% !important;z-index:2147483647 !important;box-sizing:border-box;display:flex !important;align-items:center;gap:12px;flex-wrap:nowrap;' +
|
|
530
|
+
'padding:8px 16px !important;background:#0b0b0c !important;color:#e8e8e8 !important;border-bottom:1px solid #23232a !important;box-shadow:0 1px 3px rgba(0,0,0,.35) !important;' +
|
|
531
|
+
'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
|
|
527
532
|
'.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
|
|
528
|
-
'.agents-share-bar .ash-left{
|
|
533
|
+
'.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
|
|
529
534
|
'.agents-share-bar strong{color:#fff !important;font-weight:600}' +
|
|
530
|
-
'.agents-share-bar .ash-dot{opacity:.4;margin:0
|
|
531
|
-
'.agents-share-bar .ash-right{
|
|
535
|
+
'.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
|
|
536
|
+
'.agents-share-bar .ash-right{flex:none;display:flex;align-items:center;gap:12px}' +
|
|
532
537
|
'.agents-share-bar .ash-date{color:#9096a0 !important}' +
|
|
533
538
|
'.agents-share-bar .ash-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;border:1px solid;font-weight:600;font-size:12px;white-space:nowrap}' +
|
|
534
|
-
'.agents-share-bar .ash-chip svg{flex:none}' +
|
|
539
|
+
'.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
|
|
535
540
|
'</style>' +
|
|
536
541
|
'<span class="ash-left">' + left + '</span>' +
|
|
537
542
|
'<span class="ash-right">' + right + '</span>' +
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -391,6 +391,14 @@ export declare function getDeviceMetaPath(): string;
|
|
|
391
391
|
*/
|
|
392
392
|
export declare function getVersionResourcesPath(): string;
|
|
393
393
|
export declare function withMetaLock<T>(fn: () => T): T;
|
|
394
|
+
/**
|
|
395
|
+
* Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
|
|
396
|
+
* lock — the caller must already hold it via {@link withMetaLock}. Exported so a
|
|
397
|
+
* writer that needs to read fresh state, decide, and commit within a SINGLE lock
|
|
398
|
+
* acquisition (e.g. browser tombstone eviction) can do so without the
|
|
399
|
+
* read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
|
|
400
|
+
*/
|
|
401
|
+
export declare function writeMetaUnlocked(meta: Meta): void;
|
|
394
402
|
/**
|
|
395
403
|
* Read and cache ~/.agents/agents.yaml, migrating from legacy locations if needed.
|
|
396
404
|
*
|
package/dist/lib/state.js
CHANGED
|
@@ -876,6 +876,9 @@ const META_KEY_SCOPE = {
|
|
|
876
876
|
deviceRoutines: 'device',
|
|
877
877
|
deviceConfig: 'device',
|
|
878
878
|
deviceBrowser: 'device',
|
|
879
|
+
deviceFleet: 'device',
|
|
880
|
+
deviceHosts: 'device',
|
|
881
|
+
deviceAccounts: 'device',
|
|
879
882
|
// Central — synced via agents.yaml.
|
|
880
883
|
accounts: 'central',
|
|
881
884
|
run: 'central',
|
|
@@ -915,6 +918,35 @@ const KNOWN_META_KEYS = new Set([
|
|
|
915
918
|
// first registry read can migrate it into this device's file and delete it.
|
|
916
919
|
'browser',
|
|
917
920
|
]);
|
|
921
|
+
/**
|
|
922
|
+
* Rewrite a frozen `agents.yaml` header to the current {@link META_HEADER}.
|
|
923
|
+
*
|
|
924
|
+
* `serializeCentral` parses the existing file to preserve its hand-written body
|
|
925
|
+
* comments, but that also preserves the leading metadata header verbatim, so a
|
|
926
|
+
* top-level file written before the agi-cli rename (or before the `$schema` line
|
|
927
|
+
* existed) keeps its stale header forever — every freshly-written device doc gets
|
|
928
|
+
* the current header while the shared file is left behind (PHNX-3315).
|
|
929
|
+
*
|
|
930
|
+
* The header is healed TEXTUALLY, on the already-serialized string, rather than
|
|
931
|
+
* via `doc.commentBefore`: the `yaml` library folds the whole leading comment
|
|
932
|
+
* block onto the FIRST key's `commentBefore` when that key already carries a
|
|
933
|
+
* hand-written comment, so the header is not reliably the document comment — but
|
|
934
|
+
* it is always the top block of the output. Strip a leading `agents-cli metadata`
|
|
935
|
+
* header (any pre-rename variant, with or without the `$schema` line — the URL
|
|
936
|
+
* and schema lines are matched specifically so a hand-written body comment is
|
|
937
|
+
* never mistaken for a header line) and prepend the canonical header. A file with
|
|
938
|
+
* no recognizable header simply gains one. Body comments, which sit below the
|
|
939
|
+
* blank line that terminates the header, are untouched.
|
|
940
|
+
*/
|
|
941
|
+
function healMetaHeader(serialized) {
|
|
942
|
+
const headerBlock = /^# agents-cli metadata\n# Auto-generated - do not edit manually\n(?:# (?:https:\/\/github\.com\/phnx-labs\/[^\n]*|yaml-language-server: \$schema=[^\n]*)\n)*\n?/;
|
|
943
|
+
const stripped = serialized.replace(headerBlock, '');
|
|
944
|
+
// No metadata header present (replace was a no-op) → leave the file exactly as
|
|
945
|
+
// it is. We heal a STALE header; we never prepend one to a file that never had
|
|
946
|
+
// it (that would rewrite a hand-authored, headerless central file on the first
|
|
947
|
+
// real central change). A current header round-trips to the identical bytes.
|
|
948
|
+
return stripped === serialized ? serialized : META_HEADER + stripped;
|
|
949
|
+
}
|
|
918
950
|
/**
|
|
919
951
|
* Serialize the central (synced) meta to `agents.yaml` WITHOUT destroying the
|
|
920
952
|
* hand-written comments in the committed file.
|
|
@@ -973,20 +1005,37 @@ function serializeCentral(central) {
|
|
|
973
1005
|
}
|
|
974
1006
|
}
|
|
975
1007
|
// No central field changed → keep the file byte-identical (comments intact), so
|
|
976
|
-
// writeIfChanged skips it and the churn loop never starts.
|
|
1008
|
+
// writeIfChanged skips it and the churn loop never starts. A device-only write
|
|
1009
|
+
// (pins/routines/etc. routed elsewhere) reaches here with changed=false and
|
|
1010
|
+
// MUST NOT rewrite the shared file — header healing waits for a genuine central
|
|
1011
|
+
// change below rather than dirtying agents.yaml on an unrelated write, which is
|
|
1012
|
+
// the very churn that wedges `agents sync` and blocks fleet pulls.
|
|
977
1013
|
if (!changed)
|
|
978
1014
|
return existing;
|
|
979
|
-
// Everything cleared → header only (never leave a flow `{}` behind).
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
// (
|
|
986
|
-
|
|
1015
|
+
// Everything cleared → header only (never leave a flow `{}` behind). Byte-stable
|
|
1016
|
+
// when the file is already exactly the current header.
|
|
1017
|
+
if (isEmpty)
|
|
1018
|
+
return existing === META_HEADER ? existing : META_HEADER;
|
|
1019
|
+
// A central key changed: serialize the edited doc and heal a frozen header on
|
|
1020
|
+
// the result. stringifyDoc still normalizes a legacy flow root (`{}`) to block,
|
|
1021
|
+
// so edited nodes do not render flow (`disabledCommands: [ teams ]` instead of a
|
|
1022
|
+
// `- teams` block list), but it no longer forces block on a normal document —
|
|
1023
|
+
// that flattened committed flow sequences and made this writer disagree with
|
|
1024
|
+
// feed.ts/activity.ts/migrate.ts on the same file (RUSH-2505). parseDocument
|
|
1025
|
+
// still preserves body comments + key ordering; healMetaHeader is a no-op unless
|
|
1026
|
+
// a stale metadata header is actually present, so a headerless central file is
|
|
1027
|
+
// updated in place without gaining one.
|
|
1028
|
+
return healMetaHeader(stringifyDoc(doc));
|
|
987
1029
|
}
|
|
988
|
-
|
|
989
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
|
|
1032
|
+
* lock — the caller must already hold it via {@link withMetaLock}. Exported so a
|
|
1033
|
+
* writer that needs to read fresh state, decide, and commit within a SINGLE lock
|
|
1034
|
+
* acquisition (e.g. browser tombstone eviction) can do so without the
|
|
1035
|
+
* read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
|
|
1036
|
+
*/
|
|
1037
|
+
export function writeMetaUnlocked(meta) {
|
|
1038
|
+
const { agents, isolatedAgents, versions, deviceRoutines, deviceConfig, deviceBrowser, deviceFleet, deviceHosts, deviceAccounts, projectRoot, ...central } = meta;
|
|
990
1039
|
// Write the machine-local files FIRST, then strip central — so a crash mid-write
|
|
991
1040
|
// never removes pins/versions from central before they're persisted elsewhere.
|
|
992
1041
|
const hasAgents = !!agents && Object.keys(agents).length > 0;
|
|
@@ -1049,6 +1098,43 @@ function writeMetaUnlocked(meta) {
|
|
|
1049
1098
|
doc.browser = deviceBrowser;
|
|
1050
1099
|
else
|
|
1051
1100
|
delete doc.browser;
|
|
1101
|
+
// PHNX-3315 device-scoped fleet/hosts/accounts blocks. Each is this box's OWN
|
|
1102
|
+
// slice; the effective fleet view is unioned across every device doc at read
|
|
1103
|
+
// time (lib/devices/device-docs.ts). Empty slices are dropped so a box that
|
|
1104
|
+
// has made no decision leaves no key behind (no committed empty maps).
|
|
1105
|
+
const fleetDiscovery = deviceFleet?.discovery && Object.keys(deviceFleet.discovery).length > 0
|
|
1106
|
+
? deviceFleet.discovery : undefined;
|
|
1107
|
+
const fleetIgnored = deviceFleet?.ignored && deviceFleet.ignored.length > 0
|
|
1108
|
+
? deviceFleet.ignored : undefined;
|
|
1109
|
+
if (fleetDiscovery || fleetIgnored) {
|
|
1110
|
+
const df = {};
|
|
1111
|
+
if (fleetDiscovery)
|
|
1112
|
+
df.discovery = fleetDiscovery;
|
|
1113
|
+
if (fleetIgnored)
|
|
1114
|
+
df.ignored = fleetIgnored;
|
|
1115
|
+
doc.fleet = df;
|
|
1116
|
+
}
|
|
1117
|
+
else
|
|
1118
|
+
delete doc.fleet;
|
|
1119
|
+
const hasDeviceHosts = !!deviceHosts && Object.keys(deviceHosts).length > 0;
|
|
1120
|
+
if (hasDeviceHosts)
|
|
1121
|
+
doc.hosts = deviceHosts;
|
|
1122
|
+
else
|
|
1123
|
+
delete doc.hosts;
|
|
1124
|
+
const accountsNative = deviceAccounts?.native && Object.keys(deviceAccounts.native).length > 0
|
|
1125
|
+
? deviceAccounts.native : undefined;
|
|
1126
|
+
const accountsBindings = deviceAccounts?.bindings && Object.keys(deviceAccounts.bindings).length > 0
|
|
1127
|
+
? deviceAccounts.bindings : undefined;
|
|
1128
|
+
if (accountsNative || accountsBindings) {
|
|
1129
|
+
const da = {};
|
|
1130
|
+
if (accountsNative)
|
|
1131
|
+
da.native = accountsNative;
|
|
1132
|
+
if (accountsBindings)
|
|
1133
|
+
da.bindings = accountsBindings;
|
|
1134
|
+
doc.accounts = da;
|
|
1135
|
+
}
|
|
1136
|
+
else
|
|
1137
|
+
delete doc.accounts;
|
|
1052
1138
|
const hasProjectRoot = typeof projectRoot === 'string' && projectRoot.length > 0;
|
|
1053
1139
|
if (hasProjectRoot)
|
|
1054
1140
|
doc.projectRoot = projectRoot;
|
|
@@ -1121,6 +1207,52 @@ function overlayMachineLocal(meta) {
|
|
|
1121
1207
|
if (dm?.config && typeof dm.config === 'object' && !Array.isArray(dm.config)) {
|
|
1122
1208
|
meta.deviceConfig = { ...meta.deviceConfig, ...dm.config };
|
|
1123
1209
|
}
|
|
1210
|
+
// PHNX-3315: this box's own device-scoped fleet/hosts/accounts slices.
|
|
1211
|
+
// These populate the `device*` keys the writers read-modify-write; the
|
|
1212
|
+
// effective UNION across all boxes is computed separately by
|
|
1213
|
+
// lib/devices/device-docs.ts, not here (this overlay is this box only).
|
|
1214
|
+
// A malformed block on THIS box's own doc is a HARD error, exactly like
|
|
1215
|
+
// `routines` below and the cross-box union readers (device-docs.ts): a
|
|
1216
|
+
// silent drop would let the next writeMetaUnlocked round-trip overwrite the
|
|
1217
|
+
// whole block with just the new entry, discarding the rest on this box's
|
|
1218
|
+
// tracked file (PHNX-3315).
|
|
1219
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
1220
|
+
const dmRaw = dm;
|
|
1221
|
+
if (dmRaw.fleet !== undefined) {
|
|
1222
|
+
if (!isMap(dmRaw.fleet))
|
|
1223
|
+
throw new Error(`Device config corrupted at ${devicePath}: fleet must be a map.`);
|
|
1224
|
+
const df = dmRaw.fleet;
|
|
1225
|
+
if (df.discovery !== undefined && !isMap(df.discovery)) {
|
|
1226
|
+
throw new Error(`Device config corrupted at ${devicePath}: fleet.discovery must be a map.`);
|
|
1227
|
+
}
|
|
1228
|
+
if (df.ignored !== undefined && !Array.isArray(df.ignored)) {
|
|
1229
|
+
throw new Error(`Device config corrupted at ${devicePath}: fleet.ignored must be a list.`);
|
|
1230
|
+
}
|
|
1231
|
+
const discovery = df.discovery;
|
|
1232
|
+
const ignored = df.ignored;
|
|
1233
|
+
if (discovery || ignored)
|
|
1234
|
+
meta.deviceFleet = { ...(discovery ? { discovery } : {}), ...(ignored ? { ignored } : {}) };
|
|
1235
|
+
}
|
|
1236
|
+
if (dmRaw.hosts !== undefined) {
|
|
1237
|
+
if (!isMap(dmRaw.hosts))
|
|
1238
|
+
throw new Error(`Device config corrupted at ${devicePath}: hosts must be a map.`);
|
|
1239
|
+
meta.deviceHosts = { ...meta.deviceHosts, ...dmRaw.hosts };
|
|
1240
|
+
}
|
|
1241
|
+
if (dmRaw.accounts !== undefined) {
|
|
1242
|
+
if (!isMap(dmRaw.accounts))
|
|
1243
|
+
throw new Error(`Device config corrupted at ${devicePath}: accounts must be a map.`);
|
|
1244
|
+
const acc = dmRaw.accounts;
|
|
1245
|
+
if (acc.native !== undefined && !isMap(acc.native)) {
|
|
1246
|
+
throw new Error(`Device config corrupted at ${devicePath}: accounts.native must be a map.`);
|
|
1247
|
+
}
|
|
1248
|
+
if (acc.bindings !== undefined && !isMap(acc.bindings)) {
|
|
1249
|
+
throw new Error(`Device config corrupted at ${devicePath}: accounts.bindings must be a map.`);
|
|
1250
|
+
}
|
|
1251
|
+
const native = acc.native;
|
|
1252
|
+
const bindings = acc.bindings;
|
|
1253
|
+
if (native || bindings)
|
|
1254
|
+
meta.deviceAccounts = { ...(native ? { native } : {}), ...(bindings ? { bindings } : {}) };
|
|
1255
|
+
}
|
|
1124
1256
|
if (Object.prototype.hasOwnProperty.call(dm, 'routines')) {
|
|
1125
1257
|
if (!Array.isArray(dm.routines) || dm.routines.some((name) => typeof name !== 'string')) {
|
|
1126
1258
|
throw new Error(`Device config corrupted at ${devicePath}: routines must be a string list.`);
|
|
@@ -34,6 +34,7 @@ export type InjectResolution = {
|
|
|
34
34
|
} | {
|
|
35
35
|
addressable: false;
|
|
36
36
|
reason: string;
|
|
37
|
+
hint?: string;
|
|
37
38
|
};
|
|
38
39
|
export interface ResolveOptions {
|
|
39
40
|
/**
|
|
@@ -49,6 +50,12 @@ export interface ResolveOptions {
|
|
|
49
50
|
*/
|
|
50
51
|
ptyId?: string;
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Human-facing recovery hint for a session the resolver judged un-addressable.
|
|
55
|
+
* Tells the user both how to continue THIS session and how to make FUTURE runs
|
|
56
|
+
* addressable, so the failure is not silent and the fix is actionable.
|
|
57
|
+
*/
|
|
58
|
+
export declare function addressabilityRecoveryHint(session: ActiveSession, fallbackId?: string): string;
|
|
52
59
|
/**
|
|
53
60
|
* Resolve a target from an already-fetched ActiveSession. Pure — no I/O — so the
|
|
54
61
|
* precedence logic is unit-testable without the process table. This is where the
|
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
import { getActiveSessions } from '../session/active.js';
|
|
2
|
+
import { machineId } from '../machine-id.js';
|
|
2
3
|
/** The editor CLIs that speak the swarm-ext URI protocol, keyed by the host detectHost() reports. */
|
|
3
4
|
const IDE_INJECT_VARIANTS = {
|
|
4
5
|
codium: { cli: 'codium', scheme: 'vscodium' },
|
|
5
6
|
cursor: { cli: 'cursor', scheme: 'cursor' },
|
|
6
7
|
code: { cli: 'code', scheme: 'vscode' },
|
|
7
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* Human-facing recovery hint for a session the resolver judged un-addressable.
|
|
11
|
+
* Tells the user both how to continue THIS session and how to make FUTURE runs
|
|
12
|
+
* addressable, so the failure is not silent and the fix is actionable.
|
|
13
|
+
*/
|
|
14
|
+
export function addressabilityRecoveryHint(session, fallbackId) {
|
|
15
|
+
const sid = session.sessionId;
|
|
16
|
+
// Branch on the live sessionId (an IDE terminal that has not registered one
|
|
17
|
+
// yet is a distinct message), but render the resume command with the real id
|
|
18
|
+
// when the caller can supply it — e.g. `focus` has meta.id even though the
|
|
19
|
+
// live row's sessionId is falsy, so without this the hint printed a useless
|
|
20
|
+
// `agents sessions resume <id>` placeholder in exactly that case (PHNX-3070).
|
|
21
|
+
const resumeId = sid ?? fallbackId;
|
|
22
|
+
const shortId = resumeId ? resumeId.slice(0, 8) : '<id>';
|
|
23
|
+
const device = session.machine ?? machineId();
|
|
24
|
+
const resumeCmd = resumeId ? `agents sessions resume ${shortId}` : 'agents sessions resume <id>';
|
|
25
|
+
const tmuxCmd = `agents config set devices.${device}.tmux on`;
|
|
26
|
+
const interactive = session.context === 'terminal' || !!session.tty;
|
|
27
|
+
if (session.host === 'ghostty') {
|
|
28
|
+
return `Ghostty has no per-split addressing. ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
|
|
29
|
+
}
|
|
30
|
+
if (session.host && session.host in IDE_INJECT_VARIANTS && !sid) {
|
|
31
|
+
return `This IDE terminal has not registered a session id yet. Wait a moment and retry, or use \`${resumeCmd}\` to continue.`;
|
|
32
|
+
}
|
|
33
|
+
if (session.host) {
|
|
34
|
+
return `Host '${session.host}' has no addressable rail here. ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
|
|
35
|
+
}
|
|
36
|
+
return `This session has no addressable terminal rail (not tmux, iTerm, an IDE terminal, or a pty sidecar). ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
|
|
37
|
+
}
|
|
8
38
|
/**
|
|
9
39
|
* Resolve a target from an already-fetched ActiveSession. Pure — no I/O — so the
|
|
10
40
|
* precedence logic is unit-testable without the process table. This is where the
|
|
@@ -35,7 +65,11 @@ export function resolveInjectTargetForSession(session, opts = {}) {
|
|
|
35
65
|
const variant = session.host ? IDE_INJECT_VARIANTS[session.host] : undefined;
|
|
36
66
|
if (variant) {
|
|
37
67
|
if (!session.sessionId) {
|
|
38
|
-
return {
|
|
68
|
+
return {
|
|
69
|
+
addressable: false,
|
|
70
|
+
reason: `IDE terminal (${session.host}) has no session id to address`,
|
|
71
|
+
hint: addressabilityRecoveryHint(session),
|
|
72
|
+
};
|
|
39
73
|
}
|
|
40
74
|
return {
|
|
41
75
|
addressable: true,
|
|
@@ -58,13 +92,18 @@ export function resolveInjectTargetForSession(session, opts = {}) {
|
|
|
58
92
|
note: 'coarse Ghostty window path (opt-in): raises a window and types into the FOCUSED split — not split-precise',
|
|
59
93
|
};
|
|
60
94
|
}
|
|
61
|
-
return {
|
|
95
|
+
return {
|
|
96
|
+
addressable: false,
|
|
97
|
+
reason: 'un-addressable (ghostty, no tmux): no per-split addressing; watchdog skips',
|
|
98
|
+
hint: addressabilityRecoveryHint(session),
|
|
99
|
+
};
|
|
62
100
|
}
|
|
63
101
|
return {
|
|
64
102
|
addressable: false,
|
|
65
103
|
reason: session.host
|
|
66
104
|
? `no precise inject rail for host '${session.host}' (no tmux/iterm/IDE terminal detected)`
|
|
67
105
|
: 'no inject rail: session is not inside tmux, iTerm, or an IDE terminal',
|
|
106
|
+
hint: addressabilityRecoveryHint(session),
|
|
68
107
|
};
|
|
69
108
|
}
|
|
70
109
|
/**
|