@phnx-labs/agents-cli 1.20.89 → 1.20.90
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 +240 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +28 -6
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +18 -6
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions.d.ts +103 -20
- package/dist/commands/sessions.js +356 -62
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Favorited (starred) sessions — the durable "keep this one handy" mark a human
|
|
3
|
+
* puts on a session, deliberately kept OUT of the session index.
|
|
4
|
+
*
|
|
5
|
+
* `sessions.db` is a rebuildable CACHE: a reindex or a schema bump throws its
|
|
6
|
+
* rows away and re-derives them from the transcripts on disk. A favorite is not
|
|
7
|
+
* derivable from a transcript — it is a human's choice — so a column there would
|
|
8
|
+
* be silently lost on the next rebuild. It lives in `~/.agents/.history/` instead,
|
|
9
|
+
* next to the actor sidecars, which is never pruned.
|
|
10
|
+
*
|
|
11
|
+
* One flat set of session ids. The id is the transcript's own uuid, so it is
|
|
12
|
+
* stable and machine-independent — but the file is NOT synced today: session sync
|
|
13
|
+
* carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Favorites
|
|
14
|
+
* are therefore per-machine; carrying them across the fleet would mean adding
|
|
15
|
+
* them to the sync manifest, which this does not do.
|
|
16
|
+
*
|
|
17
|
+
* Reads are memoized against the file's mtime — the picker asks `isFavorite` once
|
|
18
|
+
* per rendered row, and re-reading a JSON file per row on every keystroke is the
|
|
19
|
+
* kind of cost that makes a TUI feel broken.
|
|
20
|
+
*/
|
|
21
|
+
export declare function favoritesFilePath(): string;
|
|
22
|
+
/** Drop the memoized read. Tests that write the file directly need this; nothing
|
|
23
|
+
* in the CLI does, because every mutation here refreshes the cache itself. */
|
|
24
|
+
export declare function clearFavoritesCache(): void;
|
|
25
|
+
/**
|
|
26
|
+
* Every favorited session id. Empty (never throws) when the file is absent,
|
|
27
|
+
* unreadable, or malformed — a corrupt favorites file must not take down
|
|
28
|
+
* `agents sessions`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function listFavorites(): Set<string>;
|
|
31
|
+
export declare function isFavorite(sessionId: string | undefined): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Set (or clear) the favorite mark on a session. Returns the resulting state, so
|
|
34
|
+
* a caller can report it without a second read. A no-op write is skipped, which
|
|
35
|
+
* keeps the file's mtime — and every other process's memoized read — untouched.
|
|
36
|
+
*/
|
|
37
|
+
export declare function setFavorite(sessionId: string, on: boolean): boolean;
|
|
38
|
+
/** Flip the mark; returns the new state (`true` = now favorited). */
|
|
39
|
+
export declare function toggleFavorite(sessionId: string): boolean;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Favorited (starred) sessions — the durable "keep this one handy" mark a human
|
|
3
|
+
* puts on a session, deliberately kept OUT of the session index.
|
|
4
|
+
*
|
|
5
|
+
* `sessions.db` is a rebuildable CACHE: a reindex or a schema bump throws its
|
|
6
|
+
* rows away and re-derives them from the transcripts on disk. A favorite is not
|
|
7
|
+
* derivable from a transcript — it is a human's choice — so a column there would
|
|
8
|
+
* be silently lost on the next rebuild. It lives in `~/.agents/.history/` instead,
|
|
9
|
+
* next to the actor sidecars, which is never pruned.
|
|
10
|
+
*
|
|
11
|
+
* One flat set of session ids. The id is the transcript's own uuid, so it is
|
|
12
|
+
* stable and machine-independent — but the file is NOT synced today: session sync
|
|
13
|
+
* carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Favorites
|
|
14
|
+
* are therefore per-machine; carrying them across the fleet would mean adding
|
|
15
|
+
* them to the sync manifest, which this does not do.
|
|
16
|
+
*
|
|
17
|
+
* Reads are memoized against the file's mtime — the picker asks `isFavorite` once
|
|
18
|
+
* per rendered row, and re-reading a JSON file per row on every keystroke is the
|
|
19
|
+
* kind of cost that makes a TUI feel broken.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { getHistoryDir } from '../state.js';
|
|
24
|
+
export function favoritesFilePath() {
|
|
25
|
+
return path.join(getHistoryDir(), 'favorites.json');
|
|
26
|
+
}
|
|
27
|
+
/** Memoized parse, invalidated by the file's mtime+size (another process — or
|
|
28
|
+
* another machine's sync — can rewrite it under us). */
|
|
29
|
+
let cache = null;
|
|
30
|
+
function statKey(file) {
|
|
31
|
+
try {
|
|
32
|
+
const st = fs.statSync(file);
|
|
33
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return 'absent';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Drop the memoized read. Tests that write the file directly need this; nothing
|
|
40
|
+
* in the CLI does, because every mutation here refreshes the cache itself. */
|
|
41
|
+
export function clearFavoritesCache() {
|
|
42
|
+
cache = null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every favorited session id. Empty (never throws) when the file is absent,
|
|
46
|
+
* unreadable, or malformed — a corrupt favorites file must not take down
|
|
47
|
+
* `agents sessions`.
|
|
48
|
+
*/
|
|
49
|
+
export function listFavorites() {
|
|
50
|
+
const file = favoritesFilePath();
|
|
51
|
+
const key = statKey(file);
|
|
52
|
+
if (cache && cache.key === key)
|
|
53
|
+
return cache.ids;
|
|
54
|
+
let ids = new Set();
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
57
|
+
if (Array.isArray(parsed?.sessionIds)) {
|
|
58
|
+
ids = new Set(parsed.sessionIds.filter((id) => typeof id === 'string' && id.length > 0));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// absent / unreadable / malformed — an empty set is the honest answer
|
|
63
|
+
}
|
|
64
|
+
cache = { key, ids };
|
|
65
|
+
return ids;
|
|
66
|
+
}
|
|
67
|
+
export function isFavorite(sessionId) {
|
|
68
|
+
if (!sessionId)
|
|
69
|
+
return false;
|
|
70
|
+
return listFavorites().has(sessionId);
|
|
71
|
+
}
|
|
72
|
+
/** Atomic write (tmp + rename) so a concurrent reader never sees a half file. */
|
|
73
|
+
function writeFavorites(ids) {
|
|
74
|
+
const file = favoritesFilePath();
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
const body = { version: 1, sessionIds: [...ids].sort() };
|
|
77
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
78
|
+
fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n');
|
|
79
|
+
fs.renameSync(tmp, file);
|
|
80
|
+
cache = { key: statKey(file), ids };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Set (or clear) the favorite mark on a session. Returns the resulting state, so
|
|
84
|
+
* a caller can report it without a second read. A no-op write is skipped, which
|
|
85
|
+
* keeps the file's mtime — and every other process's memoized read — untouched.
|
|
86
|
+
*/
|
|
87
|
+
export function setFavorite(sessionId, on) {
|
|
88
|
+
const ids = new Set(listFavorites());
|
|
89
|
+
if (ids.has(sessionId) === on)
|
|
90
|
+
return on;
|
|
91
|
+
if (on)
|
|
92
|
+
ids.add(sessionId);
|
|
93
|
+
else
|
|
94
|
+
ids.delete(sessionId);
|
|
95
|
+
writeFavorites(ids);
|
|
96
|
+
return on;
|
|
97
|
+
}
|
|
98
|
+
/** Flip the mark; returns the new state (`true` = now favorited). */
|
|
99
|
+
export function toggleFavorite(sessionId) {
|
|
100
|
+
return setFavorite(sessionId, !isFavorite(sessionId));
|
|
101
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host link — whether anything is still on the other end of a live session.
|
|
3
|
+
*
|
|
4
|
+
* Every liveness signal the scan already has answers "is the AGENT process
|
|
5
|
+
* alive". None of them answer "is anyone still driving it", and those come apart
|
|
6
|
+
* in exactly the two ways a user notices:
|
|
7
|
+
*
|
|
8
|
+
* - **The host program died and took the agent with it.** VS Code / the editor
|
|
9
|
+
* window crashes or the SSH connection drops, so the agent process dies with
|
|
10
|
+
* its parent, but the window never got to run its teardown — its slice of
|
|
11
|
+
* `live-terminals.json` is left behind, stale, still naming a dead pid. Today
|
|
12
|
+
* that entry is filtered out at read time, so the session simply VANISHES
|
|
13
|
+
* from `--active` instead of reporting that it fell over. That is `host-gone`.
|
|
14
|
+
*
|
|
15
|
+
* - **The host program died and the agent survived it.** The agent was hosted
|
|
16
|
+
* in tmux (or otherwise reparented), so it keeps running with zero clients
|
|
17
|
+
* attached: still burning tokens, or sitting on a question nobody will ever
|
|
18
|
+
* answer, with no window anywhere showing it. That is `no-client`.
|
|
19
|
+
*
|
|
20
|
+
* Both are DERIVED, never asserted — from the agent pid, the owning window's
|
|
21
|
+
* keepalive, and tmux's own attached-client count. A deliberately backgrounded
|
|
22
|
+
* session (`agents sessions detach`) is excluded by construction: it is supposed
|
|
23
|
+
* to have no client, so calling it orphaned would be a false alarm on the one
|
|
24
|
+
* case the user asked for.
|
|
25
|
+
*/
|
|
26
|
+
/** How a session is connected to the client that should be driving it. */
|
|
27
|
+
export type HostLink =
|
|
28
|
+
/** A client is attached, or nothing indicates otherwise. The normal case. */
|
|
29
|
+
'connected'
|
|
30
|
+
/** Alive, but nothing is viewing it — the host window is gone and the agent outlived it. */
|
|
31
|
+
| 'no-client'
|
|
32
|
+
/** The host window is gone AND the agent process died with it — an unclean exit. */
|
|
33
|
+
| 'host-gone';
|
|
34
|
+
/**
|
|
35
|
+
* How long an IDE window's registry slice may go without a refresh before we
|
|
36
|
+
* treat that window as gone. The Factory extension force-republishes its slice
|
|
37
|
+
* every 4 minutes (`KEEPALIVE_FORCE_MS` in `apps/factory/src/vscode/foreman.registry.ts`)
|
|
38
|
+
* and on every terminal open/close, so a slice this old means the window is no
|
|
39
|
+
* longer running — it is not merely quiet. Deliberately the same 10 minutes the
|
|
40
|
+
* extension itself uses to garbage-collect a peer window's slice, so the CLI and
|
|
41
|
+
* the extension agree on when a window is dead.
|
|
42
|
+
*/
|
|
43
|
+
export declare const HOST_HEARTBEAT_STALE_MS: number;
|
|
44
|
+
export interface HostLinkInput {
|
|
45
|
+
/** The agent process is still alive (already pid-reuse-checked by the caller). */
|
|
46
|
+
pidAlive: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* When the owning IDE window last refreshed its slice of the live-terminals
|
|
49
|
+
* registry. Absent for a session no IDE window owns (a bare terminal, a team
|
|
50
|
+
* spawn, a cloud task) — those have no window whose death we could observe.
|
|
51
|
+
*/
|
|
52
|
+
windowHeartbeatMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Clients attached to this session's tmux session (`#{session_attached}`).
|
|
55
|
+
* Absent when the session is not tmux-hosted, which is NOT the same as zero:
|
|
56
|
+
* zero is a positive "nobody is looking", absent is "we cannot tell".
|
|
57
|
+
*/
|
|
58
|
+
tmuxClients?: number;
|
|
59
|
+
/** The session was deliberately backgrounded — `presence` is `background`/`parked`. */
|
|
60
|
+
deliberatelyDetached?: boolean;
|
|
61
|
+
nowMs?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Classify a live row's host link. Pure — every signal is passed in, so the
|
|
65
|
+
* whole decision table is unit-testable without a process table, a tmux server,
|
|
66
|
+
* or a running editor.
|
|
67
|
+
*/
|
|
68
|
+
export declare function classifyHostLink(input: HostLinkInput): HostLink;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host link — whether anything is still on the other end of a live session.
|
|
3
|
+
*
|
|
4
|
+
* Every liveness signal the scan already has answers "is the AGENT process
|
|
5
|
+
* alive". None of them answer "is anyone still driving it", and those come apart
|
|
6
|
+
* in exactly the two ways a user notices:
|
|
7
|
+
*
|
|
8
|
+
* - **The host program died and took the agent with it.** VS Code / the editor
|
|
9
|
+
* window crashes or the SSH connection drops, so the agent process dies with
|
|
10
|
+
* its parent, but the window never got to run its teardown — its slice of
|
|
11
|
+
* `live-terminals.json` is left behind, stale, still naming a dead pid. Today
|
|
12
|
+
* that entry is filtered out at read time, so the session simply VANISHES
|
|
13
|
+
* from `--active` instead of reporting that it fell over. That is `host-gone`.
|
|
14
|
+
*
|
|
15
|
+
* - **The host program died and the agent survived it.** The agent was hosted
|
|
16
|
+
* in tmux (or otherwise reparented), so it keeps running with zero clients
|
|
17
|
+
* attached: still burning tokens, or sitting on a question nobody will ever
|
|
18
|
+
* answer, with no window anywhere showing it. That is `no-client`.
|
|
19
|
+
*
|
|
20
|
+
* Both are DERIVED, never asserted — from the agent pid, the owning window's
|
|
21
|
+
* keepalive, and tmux's own attached-client count. A deliberately backgrounded
|
|
22
|
+
* session (`agents sessions detach`) is excluded by construction: it is supposed
|
|
23
|
+
* to have no client, so calling it orphaned would be a false alarm on the one
|
|
24
|
+
* case the user asked for.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* How long an IDE window's registry slice may go without a refresh before we
|
|
28
|
+
* treat that window as gone. The Factory extension force-republishes its slice
|
|
29
|
+
* every 4 minutes (`KEEPALIVE_FORCE_MS` in `apps/factory/src/vscode/foreman.registry.ts`)
|
|
30
|
+
* and on every terminal open/close, so a slice this old means the window is no
|
|
31
|
+
* longer running — it is not merely quiet. Deliberately the same 10 minutes the
|
|
32
|
+
* extension itself uses to garbage-collect a peer window's slice, so the CLI and
|
|
33
|
+
* the extension agree on when a window is dead.
|
|
34
|
+
*/
|
|
35
|
+
export const HOST_HEARTBEAT_STALE_MS = 10 * 60_000;
|
|
36
|
+
/**
|
|
37
|
+
* Classify a live row's host link. Pure — every signal is passed in, so the
|
|
38
|
+
* whole decision table is unit-testable without a process table, a tmux server,
|
|
39
|
+
* or a running editor.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyHostLink(input) {
|
|
42
|
+
const now = input.nowMs ?? Date.now();
|
|
43
|
+
// A session detached on purpose has no client BY DESIGN. It is the one case
|
|
44
|
+
// that looks identical to an orphan from the outside, so it is excluded first —
|
|
45
|
+
// otherwise every `agents sessions detach` would raise a false alarm.
|
|
46
|
+
if (input.deliberatelyDetached)
|
|
47
|
+
return 'connected';
|
|
48
|
+
const windowGone = input.windowHeartbeatMs !== undefined && now - input.windowHeartbeatMs >= HOST_HEARTBEAT_STALE_MS;
|
|
49
|
+
// The host window stopped keeping its registry slice alive AND the agent it
|
|
50
|
+
// owned is dead: the pair went down together without teardown.
|
|
51
|
+
if (windowGone && !input.pidAlive)
|
|
52
|
+
return 'host-gone';
|
|
53
|
+
if (!input.pidAlive)
|
|
54
|
+
return 'connected'; // a plain dead pid is `closed`, not an orphan
|
|
55
|
+
// Alive with tmux reporting zero attached clients: nobody is watching it. This
|
|
56
|
+
// is the authoritative signal — tmux knows exactly how many clients it has.
|
|
57
|
+
if (input.tmuxClients === 0)
|
|
58
|
+
return 'no-client';
|
|
59
|
+
// Alive, not tmux-hosted (or tmux says someone is attached), but the window
|
|
60
|
+
// that owned it is gone. The agent outlived its editor.
|
|
61
|
+
if (windowGone)
|
|
62
|
+
return 'no-client';
|
|
63
|
+
return 'connected';
|
|
64
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type PresenceStatus = 'connected' | 'disconnected';
|
|
2
|
+
export type PresenceLocation = 'local' | 'ssh';
|
|
3
|
+
/** A session as OBSERVED by one tick's active scan — the pure reconcile input. */
|
|
4
|
+
export interface ObservedSession {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
agent: string;
|
|
7
|
+
/** `local` when the session runs on this box, `ssh` when it's on a peer. */
|
|
8
|
+
location: PresenceLocation;
|
|
9
|
+
/** The device/host the session runs on (this box's hostname, or the peer). */
|
|
10
|
+
device: string;
|
|
11
|
+
/** Transport the session's provenance reports (`local` | `ssh` | ...). */
|
|
12
|
+
transport: string;
|
|
13
|
+
/** True for an interactive (terminal/tmux) session — a drop is reconnect-worthy;
|
|
14
|
+
* false for a headless remote continuation, where a drop means keep-alive. */
|
|
15
|
+
interactive: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PresenceRecord {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
agent: string;
|
|
20
|
+
location: PresenceLocation;
|
|
21
|
+
device: string;
|
|
22
|
+
transport: string;
|
|
23
|
+
interactive: boolean;
|
|
24
|
+
/** ms of the last tick this session was observed active. */
|
|
25
|
+
lastSeenMs: number;
|
|
26
|
+
status: PresenceStatus;
|
|
27
|
+
}
|
|
28
|
+
/** What the tick should do about a session that just flipped `disconnected`. */
|
|
29
|
+
export type PresenceAction = 'reconnect-nudge' | 'keep-alive' | 'none';
|
|
30
|
+
export interface PresenceTransition {
|
|
31
|
+
record: PresenceRecord;
|
|
32
|
+
from: PresenceStatus;
|
|
33
|
+
to: PresenceStatus;
|
|
34
|
+
action: PresenceAction;
|
|
35
|
+
}
|
|
36
|
+
/** A `disconnected` record older than this is pruned — the session is gone for
|
|
37
|
+
* good, not merely between clients. Kept generous so a long reconnect window
|
|
38
|
+
* (the reconnect loop's own bounded backoff) never drops a record mid-recovery. */
|
|
39
|
+
export declare const PRESENCE_TTL_MS: number;
|
|
40
|
+
/** `presence.json` under the watchdog state dir. `dir` is the watchdog state dir
|
|
41
|
+
* (the tick passes its own, overridable in tests); default is the real one. */
|
|
42
|
+
export declare function presenceFilePath(dir?: string): string;
|
|
43
|
+
export declare function loadPresence(dir?: string): Record<string, PresenceRecord>;
|
|
44
|
+
export declare function savePresence(map: Record<string, PresenceRecord>, dir?: string): void;
|
|
45
|
+
/** The subset of an `ActiveSession` the presence adapter reads (structural, so
|
|
46
|
+
* this module doesn't import the heavy active-session graph). */
|
|
47
|
+
export interface ActiveSessionLike {
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
/** 'terminal' (interactive) | 'headless' | 'teams' | 'cloud' | ... */
|
|
51
|
+
context: string;
|
|
52
|
+
/** Set for a peer session by the fleet fan-out (`gatherRemoteActive`). */
|
|
53
|
+
machine?: string;
|
|
54
|
+
provenance?: {
|
|
55
|
+
transport?: string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Map this tick's active sessions to presence observations. A session on a peer
|
|
60
|
+
* carries `machine` (from the fleet fan-out) → `location: 'ssh'`; a local one →
|
|
61
|
+
* `'local'`. `interactive` is true only for a `terminal` context (a tmux/terminal
|
|
62
|
+
* session whose drop is reconnect-worthy) — headless/teams/cloud are not.
|
|
63
|
+
* Sessions with no id are skipped (they can't be tracked or addressed).
|
|
64
|
+
*/
|
|
65
|
+
export declare function observedFromActive(sessions: ActiveSessionLike[], selfHost?: string): ObservedSession[];
|
|
66
|
+
/** The action for a session that just flipped to `disconnected`: an interactive
|
|
67
|
+
* session wants a reconnect nudge; a headless remote wants keep-alive; a local
|
|
68
|
+
* headless that vanished is simply gone (no action). */
|
|
69
|
+
export declare function actionFor(record: PresenceRecord): PresenceAction;
|
|
70
|
+
/**
|
|
71
|
+
* Pure state machine. Given the prior store, the sessions observed THIS tick, and
|
|
72
|
+
* `nowMs`, return the next store plus the set of status transitions.
|
|
73
|
+
*
|
|
74
|
+
* - observed now -> `connected`, lastSeen = now (record refreshed)
|
|
75
|
+
* - tracked but absent now -> `disconnected` (lastSeen kept from prior tick)
|
|
76
|
+
* - `disconnected` older than TTL -> pruned (gone for good)
|
|
77
|
+
*
|
|
78
|
+
* `transitions` carries only sessions whose status actually changed, each with
|
|
79
|
+
* the action the tick should take — so the caller never re-nudges a session that
|
|
80
|
+
* was already disconnected last tick.
|
|
81
|
+
*/
|
|
82
|
+
export declare function reconcilePresence(prev: Record<string, PresenceRecord>, observed: ObservedSession[], nowMs: number): {
|
|
83
|
+
next: Record<string, PresenceRecord>;
|
|
84
|
+
transitions: PresenceTransition[];
|
|
85
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUSH-2007 Layer C — per-session presence tracking, folded into the `agents
|
|
3
|
+
* watchdog` tick (NOT a revived daemon).
|
|
4
|
+
*
|
|
5
|
+
* Each tick the watchdog observes the active sessions across the fleet
|
|
6
|
+
* (`gatherRemoteActive` + the local scan). This module persists one record per
|
|
7
|
+
* session — `{location, device, transport, lastSeen, status}` — and DERIVES
|
|
8
|
+
* `connected` / `disconnected` by diffing consecutive observations: a session
|
|
9
|
+
* present this tick is `connected`; one that was tracked but is now absent (its
|
|
10
|
+
* peer went unreachable, or the interactive client dropped) flips to
|
|
11
|
+
* `disconnected` while its record — and the transition — is surfaced so the tick
|
|
12
|
+
* can act (an interactive drop is a reconnect-nudge candidate; a headless remote
|
|
13
|
+
* is a keep-alive). The store is honest across a crash: it only reflects what the
|
|
14
|
+
* last scan actually saw, never an asserted state.
|
|
15
|
+
*
|
|
16
|
+
* Store: `~/.agents/.cache/state/watchdog/presence.json` — sibling of the tick's
|
|
17
|
+
* existing `nudges/flags/last-tick` files. Best-effort: an unreadable/corrupt
|
|
18
|
+
* file degrades to an empty store, never throws into the tick loop.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { getRuntimeStateDir } from '../state.js';
|
|
24
|
+
/** A `disconnected` record older than this is pruned — the session is gone for
|
|
25
|
+
* good, not merely between clients. Kept generous so a long reconnect window
|
|
26
|
+
* (the reconnect loop's own bounded backoff) never drops a record mid-recovery. */
|
|
27
|
+
export const PRESENCE_TTL_MS = 30 * 60_000; // 30 minutes
|
|
28
|
+
/** `presence.json` under the watchdog state dir. `dir` is the watchdog state dir
|
|
29
|
+
* (the tick passes its own, overridable in tests); default is the real one. */
|
|
30
|
+
export function presenceFilePath(dir) {
|
|
31
|
+
return path.join(dir ?? path.join(getRuntimeStateDir(), 'watchdog'), 'presence.json');
|
|
32
|
+
}
|
|
33
|
+
export function loadPresence(dir) {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = fs.readFileSync(presenceFilePath(dir), 'utf8');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* corrupt — degrade to empty, never throw into the tick */
|
|
49
|
+
}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
export function savePresence(map, dir) {
|
|
53
|
+
try {
|
|
54
|
+
const file = presenceFilePath(dir);
|
|
55
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
56
|
+
fs.writeFileSync(file, JSON.stringify(map), 'utf8');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* best-effort; a failed write just re-derives next tick */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Map this tick's active sessions to presence observations. A session on a peer
|
|
64
|
+
* carries `machine` (from the fleet fan-out) → `location: 'ssh'`; a local one →
|
|
65
|
+
* `'local'`. `interactive` is true only for a `terminal` context (a tmux/terminal
|
|
66
|
+
* session whose drop is reconnect-worthy) — headless/teams/cloud are not.
|
|
67
|
+
* Sessions with no id are skipped (they can't be tracked or addressed).
|
|
68
|
+
*/
|
|
69
|
+
export function observedFromActive(sessions, selfHost = os.hostname()) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const s of sessions) {
|
|
72
|
+
if (!s.sessionId)
|
|
73
|
+
continue;
|
|
74
|
+
const location = s.machine ? 'ssh' : 'local';
|
|
75
|
+
out.push({
|
|
76
|
+
sessionId: s.sessionId,
|
|
77
|
+
agent: s.kind,
|
|
78
|
+
location,
|
|
79
|
+
device: s.machine ?? selfHost,
|
|
80
|
+
transport: s.provenance?.transport ?? location,
|
|
81
|
+
interactive: s.context === 'terminal',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** The action for a session that just flipped to `disconnected`: an interactive
|
|
87
|
+
* session wants a reconnect nudge; a headless remote wants keep-alive; a local
|
|
88
|
+
* headless that vanished is simply gone (no action). */
|
|
89
|
+
export function actionFor(record) {
|
|
90
|
+
if (record.status !== 'disconnected')
|
|
91
|
+
return 'none';
|
|
92
|
+
if (record.interactive)
|
|
93
|
+
return 'reconnect-nudge';
|
|
94
|
+
if (record.location === 'ssh')
|
|
95
|
+
return 'keep-alive';
|
|
96
|
+
return 'none';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Pure state machine. Given the prior store, the sessions observed THIS tick, and
|
|
100
|
+
* `nowMs`, return the next store plus the set of status transitions.
|
|
101
|
+
*
|
|
102
|
+
* - observed now -> `connected`, lastSeen = now (record refreshed)
|
|
103
|
+
* - tracked but absent now -> `disconnected` (lastSeen kept from prior tick)
|
|
104
|
+
* - `disconnected` older than TTL -> pruned (gone for good)
|
|
105
|
+
*
|
|
106
|
+
* `transitions` carries only sessions whose status actually changed, each with
|
|
107
|
+
* the action the tick should take — so the caller never re-nudges a session that
|
|
108
|
+
* was already disconnected last tick.
|
|
109
|
+
*/
|
|
110
|
+
export function reconcilePresence(prev, observed, nowMs) {
|
|
111
|
+
const next = {};
|
|
112
|
+
const transitions = [];
|
|
113
|
+
const observedIds = new Set(observed.map((o) => o.sessionId));
|
|
114
|
+
// 1. Everything observed this tick is connected.
|
|
115
|
+
for (const o of observed) {
|
|
116
|
+
const before = prev[o.sessionId];
|
|
117
|
+
const record = {
|
|
118
|
+
sessionId: o.sessionId,
|
|
119
|
+
agent: o.agent,
|
|
120
|
+
location: o.location,
|
|
121
|
+
device: o.device,
|
|
122
|
+
transport: o.transport,
|
|
123
|
+
interactive: o.interactive,
|
|
124
|
+
lastSeenMs: nowMs,
|
|
125
|
+
status: 'connected',
|
|
126
|
+
};
|
|
127
|
+
next[o.sessionId] = record;
|
|
128
|
+
if (before && before.status === 'disconnected') {
|
|
129
|
+
transitions.push({ record, from: 'disconnected', to: 'connected', action: 'none' });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 2. Tracked-but-absent sessions flip to disconnected (or prune past TTL).
|
|
133
|
+
for (const [id, before] of Object.entries(prev)) {
|
|
134
|
+
if (observedIds.has(id))
|
|
135
|
+
continue;
|
|
136
|
+
if (nowMs - before.lastSeenMs > PRESENCE_TTL_MS)
|
|
137
|
+
continue; // prune: gone for good
|
|
138
|
+
const record = { ...before, status: 'disconnected' };
|
|
139
|
+
next[id] = record;
|
|
140
|
+
if (before.status === 'connected') {
|
|
141
|
+
transitions.push({
|
|
142
|
+
record,
|
|
143
|
+
from: 'connected',
|
|
144
|
+
to: 'disconnected',
|
|
145
|
+
action: actionFor(record),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { next, transitions };
|
|
150
|
+
}
|
|
@@ -16,6 +16,16 @@ export declare function remoteListCommand(forwardedArgs: string[], os?: string):
|
|
|
16
16
|
* testing without a live tailnet.
|
|
17
17
|
*/
|
|
18
18
|
export declare function parseRemoteList(stdout: string, machine: string): SessionMeta[];
|
|
19
|
+
export declare function parseRemoteListPayload(stdout: string, machine: string, safeResolver?: boolean): {
|
|
20
|
+
sessions: SessionMeta[];
|
|
21
|
+
valid: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** Convert one completed peer process into the exact aggregation result. This
|
|
24
|
+
* is the production parent/peer seam and is exercised with real child output. */
|
|
25
|
+
export declare function remoteListCaptureResult(code: number | null, stdout: string, machine: string, display: string, safeResolver?: boolean): {
|
|
26
|
+
sessions: SessionMeta[];
|
|
27
|
+
unreachable?: string;
|
|
28
|
+
};
|
|
19
29
|
export interface RemoteListResult {
|
|
20
30
|
sessions: SessionMeta[];
|
|
21
31
|
/** How many peer machines we attempted to reach (drives the empty-fleet tip). */
|
|
@@ -61,15 +61,52 @@ export function parseRemoteList(stdout, machine) {
|
|
|
61
61
|
}
|
|
62
62
|
if (!Array.isArray(parsed))
|
|
63
63
|
return [];
|
|
64
|
+
return parsed.flatMap((value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
65
|
+
? [{ ...value, machine, _remote: true }]
|
|
66
|
+
: []);
|
|
67
|
+
}
|
|
68
|
+
/** Strict parser used at the live peer boundary. A successful process with
|
|
69
|
+
* malformed/non-array JSON is an incomplete source, not an empty machine. */
|
|
70
|
+
const SAFE_RESOLVER_KEYS = new Set([
|
|
71
|
+
'id', 'shortId', 'agent', 'origin', 'timestamp', 'lastActivity', 'project',
|
|
72
|
+
'version', 'label', 'topic', 'machine',
|
|
73
|
+
]);
|
|
74
|
+
function isSafeResolverRow(value) {
|
|
75
|
+
if (typeof value.id !== 'string' || typeof value.shortId !== 'string'
|
|
76
|
+
|| typeof value.agent !== 'string' || typeof value.timestamp !== 'string')
|
|
77
|
+
return false;
|
|
78
|
+
return Object.keys(value).every(key => SAFE_RESOLVER_KEYS.has(key));
|
|
79
|
+
}
|
|
80
|
+
export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(stdout);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { sessions: [], valid: false };
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(parsed))
|
|
89
|
+
return { sessions: [], valid: false };
|
|
64
90
|
const out = [];
|
|
65
91
|
for (const x of parsed) {
|
|
66
|
-
if (x
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
92
|
+
if (!x || typeof x !== 'object' || Array.isArray(x))
|
|
93
|
+
return { sessions: [], valid: false };
|
|
94
|
+
if (safeResolver && !isSafeResolverRow(x)) {
|
|
95
|
+
return { sessions: [], valid: false };
|
|
70
96
|
}
|
|
97
|
+
// `_remote` marks these as living on the peer's disk (not a local mirror),
|
|
98
|
+
// so the picker routes read/resume back over SSH instead of the local FS.
|
|
99
|
+
out.push({ ...x, machine, _remote: true });
|
|
71
100
|
}
|
|
72
|
-
return out;
|
|
101
|
+
return { sessions: out, valid: true };
|
|
102
|
+
}
|
|
103
|
+
/** Convert one completed peer process into the exact aggregation result. This
|
|
104
|
+
* is the production parent/peer seam and is exercised with real child output. */
|
|
105
|
+
export function remoteListCaptureResult(code, stdout, machine, display, safeResolver = false) {
|
|
106
|
+
if (code !== 0)
|
|
107
|
+
return { sessions: [], unreachable: display };
|
|
108
|
+
const parsed = parseRemoteListPayload(stdout, machine, safeResolver);
|
|
109
|
+
return parsed.valid ? { sessions: parsed.sessions } : { sessions: [], unreachable: display };
|
|
73
110
|
}
|
|
74
111
|
/** Run one remote `agents sessions … --json` and capture stdout. Resolves
|
|
75
112
|
* `{ code: null }` on spawn error or timeout (host treated as dead). */
|
|
@@ -95,11 +132,12 @@ function sshCapture(target, remoteCmd, timeoutMs) {
|
|
|
95
132
|
}
|
|
96
133
|
async function fetchByTarget(target, machine, display, forwardedArgs, os) {
|
|
97
134
|
const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
|
|
98
|
-
|
|
135
|
+
const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
|
|
136
|
+
const result = remoteListCaptureResult(code, stdout, machine, display, safeResolver);
|
|
137
|
+
if (result.unreachable) {
|
|
99
138
|
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
100
|
-
return { sessions: [], unreachable: display };
|
|
101
139
|
}
|
|
102
|
-
return
|
|
140
|
+
return result;
|
|
103
141
|
}
|
|
104
142
|
/**
|
|
105
143
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -122,7 +160,7 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
122
160
|
reg = await loadDevices();
|
|
123
161
|
}
|
|
124
162
|
catch {
|
|
125
|
-
return { sessions: [], deviceCount: 0, unreachable: [] };
|
|
163
|
+
return { sessions: [], deviceCount: 0, unreachable: ['device registry'] };
|
|
126
164
|
}
|
|
127
165
|
for (const d of Object.values(reg)) {
|
|
128
166
|
if (d.tailscale?.online !== true)
|
|
@@ -46,6 +46,13 @@ export interface RunTmuxOptions {
|
|
|
46
46
|
throwOnError?: boolean;
|
|
47
47
|
/** Child process env. */
|
|
48
48
|
env?: NodeJS.ProcessEnv;
|
|
49
|
+
/**
|
|
50
|
+
* Kill the child and reject after this many ms. Unset = wait forever (the
|
|
51
|
+
* historical behavior). A wedged tmux server would otherwise hang the caller
|
|
52
|
+
* indefinitely — the active-session scan passes a bound so a bad server can't
|
|
53
|
+
* freeze `agents sessions --active`.
|
|
54
|
+
*/
|
|
55
|
+
timeoutMs?: number;
|
|
49
56
|
}
|
|
50
57
|
/**
|
|
51
58
|
* Run a tmux command and capture stdout/stderr. The socket arg is hoisted in
|
package/dist/lib/tmux/binary.js
CHANGED
|
@@ -131,10 +131,20 @@ export async function runTmux(opts) {
|
|
|
131
131
|
});
|
|
132
132
|
let stdout = '';
|
|
133
133
|
let stderr = '';
|
|
134
|
+
let timer;
|
|
135
|
+
if (opts.timeoutMs && opts.timeoutMs > 0) {
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
child.kill();
|
|
138
|
+
reject(new Error(`tmux ${fullArgs.join(' ')} timed out after ${opts.timeoutMs}ms`));
|
|
139
|
+
}, opts.timeoutMs);
|
|
140
|
+
}
|
|
134
141
|
child.stdout?.on('data', (b) => { stdout += b.toString('utf8'); });
|
|
135
142
|
child.stderr?.on('data', (b) => { stderr += b.toString('utf8'); });
|
|
136
|
-
child.on('error',
|
|
143
|
+
child.on('error', (err) => { if (timer)
|
|
144
|
+
clearTimeout(timer); reject(err); });
|
|
137
145
|
child.on('close', (code) => {
|
|
146
|
+
if (timer)
|
|
147
|
+
clearTimeout(timer);
|
|
138
148
|
const exitCode = code ?? -1;
|
|
139
149
|
const throwOnError = opts.throwOnError !== false;
|
|
140
150
|
if (throwOnError && exitCode !== 0) {
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -737,9 +737,10 @@ export interface Meta {
|
|
|
737
737
|
secretsBundle?: string;
|
|
738
738
|
};
|
|
739
739
|
/** macOS secrets-agent config. `policy` is the default prompt policy for
|
|
740
|
-
* bundles without an explicit per-bundle policy: `
|
|
741
|
-
* once per
|
|
742
|
-
*
|
|
740
|
+
* bundles without an explicit per-bundle policy: `hold` (the default) asks
|
|
741
|
+
* once per hold window (7 days out of the box), `always` asks every time.
|
|
742
|
+
* `auto` (default on) lets the
|
|
743
|
+
* first real keychain read of a `hold` bundle populate the broker so
|
|
743
744
|
* concurrent runs read silently — set it `false` to force a prompt on every read.
|
|
744
745
|
* `holdMs` caps how long an unlocked/auto-cached bundle is held before the next
|
|
745
746
|
* read re-prompts (default 7 days; e.g. 86400000 for a 24h cap). Clamped to
|