@phnx-labs/agents-cli 1.20.74 → 1.20.76
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 +893 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +31 -3
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +58 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +1 -32
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +37 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
package/dist/lib/session/fork.js
CHANGED
|
@@ -19,6 +19,7 @@ import * as fs from 'fs';
|
|
|
19
19
|
import * as path from 'path';
|
|
20
20
|
import { upsertSession } from './db.js';
|
|
21
21
|
import { recordRunName } from './run-names.js';
|
|
22
|
+
import { deriveShortId } from './short-id.js';
|
|
22
23
|
/** Agents that `fork` can branch today (see the module doc for why). */
|
|
23
24
|
export const FORKABLE_AGENTS = ['claude'];
|
|
24
25
|
/** Whether a session's agent can be forked by {@link forkSession}. */
|
|
@@ -68,7 +69,7 @@ export function forkSession(source, opts = {}) {
|
|
|
68
69
|
throw new Error(`transcript not found for session ${source.shortId}: ${source.filePath}`);
|
|
69
70
|
}
|
|
70
71
|
const newId = randomUUID();
|
|
71
|
-
const shortId = newId
|
|
72
|
+
const shortId = deriveShortId(newId);
|
|
72
73
|
const dir = path.dirname(source.filePath);
|
|
73
74
|
const filePath = path.join(dir, `${newId}.jsonl`);
|
|
74
75
|
const transcript = fs.readFileSync(source.filePath, 'utf-8');
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The subset of the hook's on-disk record this reader relies on. Extra fields are ignored. */
|
|
2
|
+
export interface HookSessionRecord {
|
|
3
|
+
session_id: string;
|
|
4
|
+
agent?: string;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
pid: number;
|
|
7
|
+
launch_id?: string;
|
|
8
|
+
terminal_id?: string;
|
|
9
|
+
ts?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Pre-built lookup maps over one scan of the hook state dir. */
|
|
12
|
+
export interface HookSessionIndex {
|
|
13
|
+
byLaunchId: Map<string, HookSessionRecord>;
|
|
14
|
+
byTerminalId: Map<string, HookSessionRecord>;
|
|
15
|
+
byPid: Map<number, HookSessionRecord>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Scan the hook state dir once and index every record by launch_id, terminal_id,
|
|
19
|
+
* and pid. Returns empty maps if the dir is absent. Newest-by-`ts` wins a key
|
|
20
|
+
* collision (pid reuse, or a launch id lingering from a since-dead process).
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadHookSessionIndex(): HookSessionIndex;
|
|
23
|
+
export interface ResolveOpts {
|
|
24
|
+
pid: number;
|
|
25
|
+
kind: string;
|
|
26
|
+
launchId?: string;
|
|
27
|
+
terminalId?: string;
|
|
28
|
+
/** Immediate child pids of `pid` — the hook records under the agent pid, which
|
|
29
|
+
* for a wrapper/shell pid we recorded is a child. */
|
|
30
|
+
childPids?: number[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve an agent's OWN authoritative session id from the hook index. Priority
|
|
34
|
+
* mirrors the session-tracker's getLiveSession: launchId join (survives pid
|
|
35
|
+
* divergence — the hook runs under the agent pid, our registry entry may sit on a
|
|
36
|
+
* tmux pane leaf or cmd.exe wrapper) → terminalId join → direct pid → children.
|
|
37
|
+
* Every hit is kind-guarded so a stale file at a reused pid can't cross agents.
|
|
38
|
+
* Undefined until the hook lands (it can lag the spawn) or for hookless harnesses.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveHookSessionRecord(index: HookSessionIndex, opts: ResolveOpts): HookSessionRecord | undefined;
|
|
41
|
+
/** The session id alone — see {@link resolveHookSessionRecord} for the full record
|
|
42
|
+
* (which also carries the SessionStart `ts` used to stamp `startedAtMs`). */
|
|
43
|
+
export declare function resolveHookSessionId(index: HookSessionIndex, opts: ResolveOpts): string | undefined;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only view of the SessionStart hook's live-session state files.
|
|
3
|
+
*
|
|
4
|
+
* The `@agents/session-tracker` hook (installed into each agent's own config)
|
|
5
|
+
* writes `~/.agents/.cache/terminals/sessions/<pid>.json` AFTER an agent boots,
|
|
6
|
+
* carrying the agent's OWN authoritative session id (from its SessionStart
|
|
7
|
+
* payload) plus the join keys `launch_id` / `terminal_id`. It is the only writer
|
|
8
|
+
* that sees agents `ag run` did NOT launch (you typing `claude` in a terminal),
|
|
9
|
+
* and the only source of an exact id for non-Claude agents (whose id we can't
|
|
10
|
+
* know at spawn).
|
|
11
|
+
*
|
|
12
|
+
* This module lets `sessions --active` reconcile a `ps`-discovered pid to that
|
|
13
|
+
* authoritative id. It is deliberately a small hand-rolled reader — the CLI does
|
|
14
|
+
* NOT import the session-tracker package (a separate, workspace-less package;
|
|
15
|
+
* see packages/session-tracker/CLAUDE.md) — mirroring the Factory extension's own
|
|
16
|
+
* hand-copied reader in apps/factory/src/core/liveSession.ts. It only READS the
|
|
17
|
+
* existing dir/schema; it never writes or moves anything, so it is safe across a
|
|
18
|
+
* fleet where old hooks and a new CLI coexist.
|
|
19
|
+
*
|
|
20
|
+
* The whole state dir is scanned ONCE per active-scan into an index (the listing
|
|
21
|
+
* path polls every ~3s), and every lookup keys the pre-built maps — never a
|
|
22
|
+
* re-scan per candidate.
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { getTerminalsDir } from '../state.js';
|
|
27
|
+
function hookSessionsDir() {
|
|
28
|
+
// Sibling of the pid-registry's by-pid/ dir. The hook hardcodes this same path
|
|
29
|
+
// (packages/session-tracker/src/hook.sh); we read it, never move it.
|
|
30
|
+
return path.join(getTerminalsDir(), 'sessions');
|
|
31
|
+
}
|
|
32
|
+
function parseRecord(raw) {
|
|
33
|
+
try {
|
|
34
|
+
const o = JSON.parse(raw);
|
|
35
|
+
if (o && typeof o === 'object' && typeof o.session_id === 'string' && o.session_id) {
|
|
36
|
+
return o;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* unparseable — treat as absent */
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Keep the newest record (by `ts`) when two collide on the same key. Uses the
|
|
45
|
+
* same strict `>` tie-break as the session-tracker's own reader (reader.ts). */
|
|
46
|
+
function keepNewest(map, key, rec) {
|
|
47
|
+
const prev = map.get(key);
|
|
48
|
+
if (!prev || (rec.ts ?? 0) > (prev.ts ?? 0))
|
|
49
|
+
map.set(key, rec);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Scan the hook state dir once and index every record by launch_id, terminal_id,
|
|
53
|
+
* and pid. Returns empty maps if the dir is absent. Newest-by-`ts` wins a key
|
|
54
|
+
* collision (pid reuse, or a launch id lingering from a since-dead process).
|
|
55
|
+
*/
|
|
56
|
+
export function loadHookSessionIndex() {
|
|
57
|
+
const byLaunchId = new Map();
|
|
58
|
+
const byTerminalId = new Map();
|
|
59
|
+
const byPid = new Map();
|
|
60
|
+
let files;
|
|
61
|
+
try {
|
|
62
|
+
files = fs.readdirSync(hookSessionsDir()).filter(f => f.endsWith('.json'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { byLaunchId, byTerminalId, byPid };
|
|
66
|
+
}
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
let rec;
|
|
69
|
+
try {
|
|
70
|
+
rec = parseRecord(fs.readFileSync(path.join(hookSessionsDir(), f), 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* raced with the hook / pruner — skip */
|
|
74
|
+
}
|
|
75
|
+
if (!rec)
|
|
76
|
+
continue;
|
|
77
|
+
if (typeof rec.pid === 'number')
|
|
78
|
+
keepNewest(byPid, rec.pid, rec);
|
|
79
|
+
if (rec.launch_id)
|
|
80
|
+
keepNewest(byLaunchId, rec.launch_id, rec);
|
|
81
|
+
if (rec.terminal_id)
|
|
82
|
+
keepNewest(byTerminalId, rec.terminal_id, rec);
|
|
83
|
+
}
|
|
84
|
+
return { byLaunchId, byTerminalId, byPid };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* True if a hook record's `agent` is compatible with a `ps`-detected kind. Guards
|
|
88
|
+
* the weak pid/children lookups against a STALE file at a reused pid (a dead
|
|
89
|
+
* hooked agent's `<pid>.json` inherited by a live hookless agent at the same pid).
|
|
90
|
+
* Permissive when the record's agent is absent/legacy-`unknown` (never reject on
|
|
91
|
+
* missing metadata). Normalizes the one known naming gap: `ps` reports Cursor as
|
|
92
|
+
* `cursor-agent`, the hook records it as `cursor`.
|
|
93
|
+
*/
|
|
94
|
+
function kindMatches(recordAgent, kind) {
|
|
95
|
+
if (!recordAgent || recordAgent === 'unknown')
|
|
96
|
+
return true;
|
|
97
|
+
const norm = (k) => (k === 'cursor-agent' ? 'cursor' : k);
|
|
98
|
+
return norm(recordAgent) === norm(kind);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve an agent's OWN authoritative session id from the hook index. Priority
|
|
102
|
+
* mirrors the session-tracker's getLiveSession: launchId join (survives pid
|
|
103
|
+
* divergence — the hook runs under the agent pid, our registry entry may sit on a
|
|
104
|
+
* tmux pane leaf or cmd.exe wrapper) → terminalId join → direct pid → children.
|
|
105
|
+
* Every hit is kind-guarded so a stale file at a reused pid can't cross agents.
|
|
106
|
+
* Undefined until the hook lands (it can lag the spawn) or for hookless harnesses.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveHookSessionRecord(index, opts) {
|
|
109
|
+
const { pid, kind, launchId, terminalId, childPids } = opts;
|
|
110
|
+
const take = (rec) => rec?.session_id && kindMatches(rec.agent, kind) ? rec : undefined;
|
|
111
|
+
if (launchId) {
|
|
112
|
+
const hit = take(index.byLaunchId.get(launchId));
|
|
113
|
+
if (hit)
|
|
114
|
+
return hit;
|
|
115
|
+
}
|
|
116
|
+
if (terminalId) {
|
|
117
|
+
const hit = take(index.byTerminalId.get(terminalId));
|
|
118
|
+
if (hit)
|
|
119
|
+
return hit;
|
|
120
|
+
}
|
|
121
|
+
const direct = take(index.byPid.get(pid));
|
|
122
|
+
if (direct)
|
|
123
|
+
return direct;
|
|
124
|
+
for (const c of childPids ?? []) {
|
|
125
|
+
const hit = take(index.byPid.get(c));
|
|
126
|
+
if (hit)
|
|
127
|
+
return hit;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/** The session id alone — see {@link resolveHookSessionRecord} for the full record
|
|
132
|
+
* (which also carries the SessionStart `ts` used to stamp `startedAtMs`). */
|
|
133
|
+
export function resolveHookSessionId(index, opts) {
|
|
134
|
+
return resolveHookSessionRecord(index, opts)?.session_id;
|
|
135
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The configured Linear workspace slug, or undefined when unknown. Cached per process. */
|
|
2
|
+
export declare function linearWorkspace(): string | undefined;
|
|
3
|
+
/** Canonical Linear issue URL for a tracker key, or undefined if unresolvable. */
|
|
4
|
+
export declare function linearIssueUrl(ticketId?: string): string | undefined;
|
|
5
|
+
/** Clear the cached workspace lookup. Tests only. */
|
|
6
|
+
export declare function _resetLinearWorkspaceCache(): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a clickable Linear issue URL for a tracker key (e.g. `RUSH-1234`).
|
|
3
|
+
*
|
|
4
|
+
* Linear issue URLs are workspace-scoped — `https://linear.app/<workspace>/issue/<KEY>`
|
|
5
|
+
* — so a bare key can't be linked without knowing the workspace slug (Linear's
|
|
6
|
+
* `organization.urlKey`). That slug is resolved config-first, never hardcoded:
|
|
7
|
+
*
|
|
8
|
+
* 1. `LINEAR_WORKSPACE` env var — explicit override, else
|
|
9
|
+
* 2. `workspaceUrlKey` in the linear-cli config (`~/.linear-cli/config.json`).
|
|
10
|
+
*
|
|
11
|
+
* When neither is set the ticket stays plain text — it's still shown, just not
|
|
12
|
+
* linked. This keeps the OSS CLI free of any Rush/workspace-specific string.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
/** A Linear tracker key: `TEAM-N`, e.g. `RUSH-1234`. Mirrors the detector's shape. */
|
|
18
|
+
const LINEAR_KEY_RE = /^[A-Z]{2,6}-\d{1,6}$/;
|
|
19
|
+
// `undefined` = not yet resolved; `null` = resolved-but-unknown (skip re-reading).
|
|
20
|
+
let workspaceCache;
|
|
21
|
+
/** The configured Linear workspace slug, or undefined when unknown. Cached per process. */
|
|
22
|
+
export function linearWorkspace() {
|
|
23
|
+
if (workspaceCache !== undefined)
|
|
24
|
+
return workspaceCache ?? undefined;
|
|
25
|
+
const env = process.env.LINEAR_WORKSPACE?.trim();
|
|
26
|
+
if (env) {
|
|
27
|
+
workspaceCache = env;
|
|
28
|
+
return env;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const cfgPath = path.join(os.homedir(), '.linear-cli', 'config.json');
|
|
32
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
33
|
+
const ws = cfg.workspaceUrlKey?.trim();
|
|
34
|
+
if (ws) {
|
|
35
|
+
workspaceCache = ws;
|
|
36
|
+
return ws;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// no linear-cli config (or unreadable) — leave tickets unlinked
|
|
41
|
+
}
|
|
42
|
+
workspaceCache = null;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Canonical Linear issue URL for a tracker key, or undefined if unresolvable. */
|
|
46
|
+
export function linearIssueUrl(ticketId) {
|
|
47
|
+
if (!ticketId || !LINEAR_KEY_RE.test(ticketId))
|
|
48
|
+
return undefined;
|
|
49
|
+
const ws = linearWorkspace();
|
|
50
|
+
return ws ? `https://linear.app/${ws}/issue/${ticketId}` : undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Clear the cached workspace lookup. Tests only. */
|
|
53
|
+
export function _resetLinearWorkspaceCache() {
|
|
54
|
+
workspaceCache = undefined;
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target enumeration + `--auto` scorer for `agents sessions migrate` (RUSH-1977).
|
|
3
|
+
*
|
|
4
|
+
* `sessions migrate` relocates a running session onto another machine. This
|
|
5
|
+
* module is the pure decision layer: given the fleet (enrolled hosts + devices
|
|
6
|
+
* from `listAllHosts()`), their live headroom ({@link Headroom} buckets derived
|
|
7
|
+
* from `probeFleetStats()` / the disk stats-cache), and the reusable ephemeral
|
|
8
|
+
* crabbox boxes (`reusableBoxes()`), it enumerates eligible {@link MigrateTarget}s
|
|
9
|
+
* and ranks them for `--auto`.
|
|
10
|
+
*
|
|
11
|
+
* Kept side-effect-free (no ssh, no disk, no clock) so the ranking is a unit test
|
|
12
|
+
* against synthetic Host[]/DeviceStats — the command layer does the probing and
|
|
13
|
+
* hands the results in. The one genuine-bug guard the test pins: the source and
|
|
14
|
+
* the interactive machine (os.hostname()) are never offered as targets.
|
|
15
|
+
*/
|
|
16
|
+
import type { Host } from '../hosts/types.js';
|
|
17
|
+
import type { DeviceStats, Headroom } from '../devices/health.js';
|
|
18
|
+
import type { CrabboxBox } from '../crabbox/cli.js';
|
|
19
|
+
/** A machine the current session could be relocated onto. */
|
|
20
|
+
export interface MigrateTarget {
|
|
21
|
+
/** The `--host` value: a host/device name, or a warm box slug for ephemeral. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Where the target comes from — a fleet host/device, or a warm crabbox box. */
|
|
24
|
+
kind: 'fleet' | 'ephemeral';
|
|
25
|
+
/** Captured OS string ('darwin'/'linux'/'windows'/…) when known, for platform-match ranking. */
|
|
26
|
+
os?: string;
|
|
27
|
+
/** Live headroom bucket ('idle' is best); 'unknown' when no stats resolved. */
|
|
28
|
+
headroom: Headroom;
|
|
29
|
+
/** The underlying host (fleet targets only). */
|
|
30
|
+
host?: Host;
|
|
31
|
+
/** The underlying warm box (ephemeral targets only). */
|
|
32
|
+
box?: CrabboxBox;
|
|
33
|
+
}
|
|
34
|
+
/** Inputs the scorer needs about the session being moved and this machine. */
|
|
35
|
+
export interface MigrateContext {
|
|
36
|
+
/** os.hostname() of the interactive machine — never a valid target. */
|
|
37
|
+
selfHostname: string;
|
|
38
|
+
/** The source session's host (machine it currently runs on) — never a valid target. */
|
|
39
|
+
sourceHostname?: string;
|
|
40
|
+
/** The source session's OS, for the platform-match bonus. */
|
|
41
|
+
sourceOs?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Enumerate the fleet + ephemeral targets a session could move to, excluding the
|
|
45
|
+
* interactive machine and the source. A fleet host is eligible only when it is
|
|
46
|
+
* dispatchable and not obviously offline; ephemeral (warm) boxes are always
|
|
47
|
+
* eligible (already provisioned and reachable). `statsByName` supplies live
|
|
48
|
+
* headroom — a missing entry yields 'unknown' (still eligible, just ranked lower).
|
|
49
|
+
*/
|
|
50
|
+
export declare function enumerateTargets(hosts: Host[], warmBoxes: CrabboxBox[], statsByName: Map<string, DeviceStats>, ctx: MigrateContext): MigrateTarget[];
|
|
51
|
+
/**
|
|
52
|
+
* Rank targets for `--auto`. Order:
|
|
53
|
+
* 1. Platform match with the source (a faithful --resume wants the same OS family).
|
|
54
|
+
* 2. Fleet before ephemeral (prefer a warm worker over spinning a box).
|
|
55
|
+
* 3. Headroom (idle > light > busy > loaded > unknown).
|
|
56
|
+
* 4. Name, for a stable tie-break.
|
|
57
|
+
* Returns a new sorted array; does not mutate the input.
|
|
58
|
+
*/
|
|
59
|
+
export declare function rankTargets(targets: MigrateTarget[], ctx: MigrateContext): MigrateTarget[];
|
|
60
|
+
/**
|
|
61
|
+
* Pick the single best `--auto` target, or null when nothing is eligible. A
|
|
62
|
+
* fully-loaded ('loaded') fleet is still returned (better than failing the
|
|
63
|
+
* migrate); the command layer can offer `--lease` when this is null.
|
|
64
|
+
*/
|
|
65
|
+
export declare function pickBestTarget(hosts: Host[], warmBoxes: CrabboxBox[], statsByName: Map<string, DeviceStats>, ctx: MigrateContext): MigrateTarget | null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { headroom } from '../devices/health.js';
|
|
2
|
+
/** Headroom rank: lower is better (idle preferred). 'unknown' sorts last among reachable. */
|
|
3
|
+
const HEADROOM_ORDER = {
|
|
4
|
+
idle: 0,
|
|
5
|
+
light: 1,
|
|
6
|
+
busy: 2,
|
|
7
|
+
loaded: 3,
|
|
8
|
+
unknown: 4,
|
|
9
|
+
};
|
|
10
|
+
/** Normalize an os string to a coarse platform token for match comparison. */
|
|
11
|
+
function platformOf(os) {
|
|
12
|
+
if (!os)
|
|
13
|
+
return undefined;
|
|
14
|
+
const s = os.toLowerCase();
|
|
15
|
+
if (s.includes('darwin') || s.includes('mac'))
|
|
16
|
+
return 'darwin';
|
|
17
|
+
if (s.includes('win'))
|
|
18
|
+
return 'windows';
|
|
19
|
+
if (s.includes('linux'))
|
|
20
|
+
return 'linux';
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Enumerate the fleet + ephemeral targets a session could move to, excluding the
|
|
25
|
+
* interactive machine and the source. A fleet host is eligible only when it is
|
|
26
|
+
* dispatchable and not obviously offline; ephemeral (warm) boxes are always
|
|
27
|
+
* eligible (already provisioned and reachable). `statsByName` supplies live
|
|
28
|
+
* headroom — a missing entry yields 'unknown' (still eligible, just ranked lower).
|
|
29
|
+
*/
|
|
30
|
+
export function enumerateTargets(hosts, warmBoxes, statsByName, ctx) {
|
|
31
|
+
const excluded = new Set([ctx.selfHostname, ctx.sourceHostname].filter((n) => !!n).map((n) => n.toLowerCase()));
|
|
32
|
+
const fleet = [];
|
|
33
|
+
for (const host of hosts) {
|
|
34
|
+
if (excluded.has(host.name.toLowerCase()))
|
|
35
|
+
continue;
|
|
36
|
+
// dispatchable is absent-means-yes; only an explicit false is disqualifying.
|
|
37
|
+
if (host.dispatchable === false)
|
|
38
|
+
continue;
|
|
39
|
+
if (host.status === 'offline')
|
|
40
|
+
continue;
|
|
41
|
+
fleet.push({
|
|
42
|
+
name: host.name,
|
|
43
|
+
kind: 'fleet',
|
|
44
|
+
os: host.os,
|
|
45
|
+
headroom: headroom(statsByName.get(host.name)),
|
|
46
|
+
host,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const ephemeral = warmBoxes.map((box) => ({
|
|
50
|
+
name: box.slug,
|
|
51
|
+
kind: 'ephemeral',
|
|
52
|
+
// crabbox boxes are Linux; the address is resolved at use time.
|
|
53
|
+
os: 'linux',
|
|
54
|
+
headroom: headroom(statsByName.get(box.slug)),
|
|
55
|
+
box,
|
|
56
|
+
}));
|
|
57
|
+
return [...fleet, ...ephemeral];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Rank targets for `--auto`. Order:
|
|
61
|
+
* 1. Platform match with the source (a faithful --resume wants the same OS family).
|
|
62
|
+
* 2. Fleet before ephemeral (prefer a warm worker over spinning a box).
|
|
63
|
+
* 3. Headroom (idle > light > busy > loaded > unknown).
|
|
64
|
+
* 4. Name, for a stable tie-break.
|
|
65
|
+
* Returns a new sorted array; does not mutate the input.
|
|
66
|
+
*/
|
|
67
|
+
export function rankTargets(targets, ctx) {
|
|
68
|
+
const srcPlatform = platformOf(ctx.sourceOs);
|
|
69
|
+
const score = (t) => {
|
|
70
|
+
const platformMatch = srcPlatform && platformOf(t.os) === srcPlatform ? 0 : 1;
|
|
71
|
+
const kindRank = t.kind === 'fleet' ? 0 : 1;
|
|
72
|
+
return { platformMatch, kindRank, headroomRank: HEADROOM_ORDER[t.headroom], name: t.name };
|
|
73
|
+
};
|
|
74
|
+
return [...targets].sort((a, b) => {
|
|
75
|
+
const sa = score(a);
|
|
76
|
+
const sb = score(b);
|
|
77
|
+
if (sa.platformMatch !== sb.platformMatch)
|
|
78
|
+
return sa.platformMatch - sb.platformMatch;
|
|
79
|
+
if (sa.kindRank !== sb.kindRank)
|
|
80
|
+
return sa.kindRank - sb.kindRank;
|
|
81
|
+
if (sa.headroomRank !== sb.headroomRank)
|
|
82
|
+
return sa.headroomRank - sb.headroomRank;
|
|
83
|
+
return sa.name.localeCompare(sb.name);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Pick the single best `--auto` target, or null when nothing is eligible. A
|
|
88
|
+
* fully-loaded ('loaded') fleet is still returned (better than failing the
|
|
89
|
+
* migrate); the command layer can offer `--lease` when this is null.
|
|
90
|
+
*/
|
|
91
|
+
export function pickBestTarget(hosts, warmBoxes, statsByName, ctx) {
|
|
92
|
+
const ranked = rankTargets(enumerateTargets(hosts, warmBoxes, statsByName, ctx), ctx);
|
|
93
|
+
return ranked[0] ?? null;
|
|
94
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type MigrationMode = 'resume' | 'rehydrate';
|
|
2
|
+
export interface MigrationEndpoint {
|
|
3
|
+
host: string;
|
|
4
|
+
cwd?: string;
|
|
5
|
+
/** tmux pane on the source; ephemeral box slug on the target. */
|
|
6
|
+
pane?: string;
|
|
7
|
+
box?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MigrationRecord {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
shortId: string;
|
|
12
|
+
agent: string;
|
|
13
|
+
mode: MigrationMode;
|
|
14
|
+
/** true = move (the source was stopped); false = copy (--keep). */
|
|
15
|
+
move: boolean;
|
|
16
|
+
from: MigrationEndpoint;
|
|
17
|
+
to: MigrationEndpoint;
|
|
18
|
+
/** WIP branch the working tree was committed to before the move, if any. */
|
|
19
|
+
branch?: string;
|
|
20
|
+
/** Draft PR opened for that branch, if any. */
|
|
21
|
+
wipPr?: string;
|
|
22
|
+
/** ISO timestamp — passed in by the caller (the CLI has a real clock). */
|
|
23
|
+
at: string;
|
|
24
|
+
status: 'completed' | 'failed';
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function migrationsLedgerPath(): string;
|
|
28
|
+
/**
|
|
29
|
+
* Append one migration event. A ledger write must never break a migration that
|
|
30
|
+
* otherwise succeeded, so a write failure is a visible warning, not a throw.
|
|
31
|
+
* `file` is injectable for tests; production uses the default ledger path.
|
|
32
|
+
*/
|
|
33
|
+
export declare function recordMigration(rec: MigrationRecord, file?: string): void;
|
|
34
|
+
/** Read the whole ledger, oldest first. Blank/corrupt JSONL lines are skipped. */
|
|
35
|
+
export declare function readMigrations(file?: string): MigrationRecord[];
|
|
36
|
+
/** The most recent completed migration for a session id, or undefined. */
|
|
37
|
+
export declare function latestForSession(sessionId: string, file?: string): MigrationRecord | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration ledger (RUSH-1977) — an append-only record of every
|
|
3
|
+
* `agents sessions migrate`, so a session handed off to another machine stays
|
|
4
|
+
* trackable: where it went, when, in which mode, move vs copy, and the WIP
|
|
5
|
+
* branch/PR its working tree was parked on.
|
|
6
|
+
*
|
|
7
|
+
* Append-only JSONL under the already-synced `~/.agents/.history`, so the source
|
|
8
|
+
* and target machines converge on the same trail. One line per event; a session
|
|
9
|
+
* that hops A→B→C leaves three lines — that ordered history IS the lineage, which
|
|
10
|
+
* is why this is an event log, not a mutable field on the session.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { homeDir } from '../platform/paths.js';
|
|
16
|
+
export function migrationsLedgerPath() {
|
|
17
|
+
return path.join(homeDir(), '.agents', '.history', 'migrations.jsonl');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Append one migration event. A ledger write must never break a migration that
|
|
21
|
+
* otherwise succeeded, so a write failure is a visible warning, not a throw.
|
|
22
|
+
* `file` is injectable for tests; production uses the default ledger path.
|
|
23
|
+
*/
|
|
24
|
+
export function recordMigration(rec, file = migrationsLedgerPath()) {
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
fs.appendFileSync(file, JSON.stringify(rec) + '\n');
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.error(chalk.yellow(` Could not write the migration ledger (${err.message}).`));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Read the whole ledger, oldest first. Blank/corrupt JSONL lines are skipped. */
|
|
34
|
+
export function readMigrations(file = migrationsLedgerPath()) {
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return []; // no ledger yet
|
|
41
|
+
}
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const line of raw.split('\n')) {
|
|
44
|
+
const s = line.trim();
|
|
45
|
+
if (!s)
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
out.push(JSON.parse(s));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A single partial line (interrupted append) must not sink the whole read.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** The most recent completed migration for a session id, or undefined. */
|
|
57
|
+
export function latestForSession(sessionId, file = migrationsLedgerPath()) {
|
|
58
|
+
const recs = readMigrations(file).filter((r) => r.sessionId === sessionId && r.status === 'completed');
|
|
59
|
+
return recs.length ? recs[recs.length - 1] : undefined;
|
|
60
|
+
}
|
|
@@ -72,6 +72,24 @@ export declare function parseAntigravity(dbPath: string): SessionEvent[];
|
|
|
72
72
|
* Messages have role (user/assistant) and metadata.
|
|
73
73
|
* Parts contain the actual content: text, tool, reasoning, patch, step-start/finish.
|
|
74
74
|
*/
|
|
75
|
+
/**
|
|
76
|
+
* Parse a Grok (xAI CLI) session into normalized events.
|
|
77
|
+
*
|
|
78
|
+
* A Grok session dir holds several files; the conversation transcript is
|
|
79
|
+
* `chat_history.jsonl`, one JSON object per line with a `type`:
|
|
80
|
+
* - `system` — the system prompt (skipped; not conversational)
|
|
81
|
+
* - `user` — `content` is an array of `{ type: 'text', text }` blocks
|
|
82
|
+
* - `assistant` — `content` is a string, plus a `tool_calls[]` array of
|
|
83
|
+
* `{ id, name, arguments }` (arguments is a JSON string)
|
|
84
|
+
* - `reasoning` — chain-of-thought; text (when present) lives in `summary`
|
|
85
|
+
* - `tool_result` — `{ tool_call_id, content }`, correlated back to the call
|
|
86
|
+
*
|
|
87
|
+
* The scanner records `summary.json` as the session's filePath (see
|
|
88
|
+
* `readGrokMeta` in discover.ts), so resolve `chat_history.jsonl` from the same
|
|
89
|
+
* dir; also accept being handed the transcript file directly. Per-line
|
|
90
|
+
* timestamps aren't stored, so every event carries the session's `created_at`
|
|
91
|
+
* (from summary.json), falling back to the transcript's mtime.
|
|
92
|
+
*/
|
|
75
93
|
export declare function parseGrok(filePath: string): SessionEvent[];
|
|
76
94
|
export declare function parseOpenCode(filePath: string): SessionEvent[];
|
|
77
95
|
/** Parse a Rush JSONL session file into normalized events. */
|