@phnx-labs/agents-cli 1.20.89 → 1.20.91
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 +361 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/feed.js +77 -4
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- 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 +58 -30
- package/dist/commands/secrets.d.ts +52 -4
- package/dist/commands/secrets.js +234 -37
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- 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-picker.js +70 -1
- 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/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- 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/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -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/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- 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/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -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/digest.d.ts +7 -0
- package/dist/lib/session/digest.js +29 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/relative-time.d.ts +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/session/render.d.ts +7 -0
- package/dist/lib/session/render.js +87 -17
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +11 -0
- 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 +3 -1
|
@@ -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
|
+
}
|
|
@@ -5,3 +5,17 @@
|
|
|
5
5
|
* `remote.ts`, so a back-import would cycle.
|
|
6
6
|
*/
|
|
7
7
|
export declare function formatRelativeTime(isoTimestamp: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
|
|
10
|
+
* milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
|
|
11
|
+
* `--unused` on `secrets list`.
|
|
12
|
+
*
|
|
13
|
+
* It lives here, in a module with no imports, rather than in `discover.ts` where
|
|
14
|
+
* it started: `discover.ts` loads `../sqlite.js`, so importing this one function
|
|
15
|
+
* from there pulls `node:sqlite` into the caller's module graph and Node prints
|
|
16
|
+
* `ExperimentalWarning: SQLite …` on stderr. That is invisible in a command that
|
|
17
|
+
* already touches the session DB, and a broken contract in one that doesn't —
|
|
18
|
+
* `monitors --json` asserts a clean stderr. `discover.ts` re-exports it, so
|
|
19
|
+
* existing importers are unaffected.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseTimeFilter(input: string): number;
|
|
@@ -30,3 +30,39 @@ export function formatRelativeTime(isoTimestamp) {
|
|
|
30
30
|
? label
|
|
31
31
|
: `${label} '${String(d.getFullYear()).slice(-2)}`;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
|
|
35
|
+
* milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
|
|
36
|
+
* `--unused` on `secrets list`.
|
|
37
|
+
*
|
|
38
|
+
* It lives here, in a module with no imports, rather than in `discover.ts` where
|
|
39
|
+
* it started: `discover.ts` loads `../sqlite.js`, so importing this one function
|
|
40
|
+
* from there pulls `node:sqlite` into the caller's module graph and Node prints
|
|
41
|
+
* `ExperimentalWarning: SQLite …` on stderr. That is invisible in a command that
|
|
42
|
+
* already touches the session DB, and a broken contract in one that doesn't —
|
|
43
|
+
* `monitors --json` asserts a clean stderr. `discover.ts` re-exports it, so
|
|
44
|
+
* existing importers are unaffected.
|
|
45
|
+
*/
|
|
46
|
+
export function parseTimeFilter(input) {
|
|
47
|
+
// Units: m=minute, h=hour, d=day, w=week, mo=month(30d), y=year(365d). `mo`
|
|
48
|
+
// must precede the single-letter alternatives so "1mo" isn't read as "1m"+"o".
|
|
49
|
+
const relativeMatch = input.match(/^(\d+)(mo|[mhdwy])$/i);
|
|
50
|
+
if (relativeMatch) {
|
|
51
|
+
const value = parseInt(relativeMatch[1], 10);
|
|
52
|
+
const unit = relativeMatch[2].toLowerCase();
|
|
53
|
+
if (unit === 'm')
|
|
54
|
+
return Date.now() - value * 60_000;
|
|
55
|
+
if (unit === 'h')
|
|
56
|
+
return Date.now() - value * 3_600_000;
|
|
57
|
+
if (unit === 'd')
|
|
58
|
+
return Date.now() - value * 86_400_000;
|
|
59
|
+
if (unit === 'w')
|
|
60
|
+
return Date.now() - value * 7 * 86_400_000;
|
|
61
|
+
if (unit === 'mo')
|
|
62
|
+
return Date.now() - value * 30 * 86_400_000;
|
|
63
|
+
if (unit === 'y')
|
|
64
|
+
return Date.now() - value * 365 * 86_400_000;
|
|
65
|
+
}
|
|
66
|
+
const ts = new Date(input).getTime();
|
|
67
|
+
return Number.isNaN(ts) ? 0 : ts;
|
|
68
|
+
}
|
|
@@ -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)
|
|
@@ -11,6 +11,13 @@ import type { SessionEvent, SessionMeta } from './types.js';
|
|
|
11
11
|
* Return absPath relative to cwd; fall back to ~/… then absolute.
|
|
12
12
|
*/
|
|
13
13
|
export declare function relativeToCwd(absPath: string, cwd?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Display form for a touched path: cwd-relative first; then collapse any
|
|
16
|
+
* `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
|
|
17
|
+
* group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
|
|
18
|
+
* else home-collapse.
|
|
19
|
+
*/
|
|
20
|
+
export declare function displayPath(absPath: string, cwd?: string): string;
|
|
14
21
|
/**
|
|
15
22
|
* Wrap a filesystem path label in an OSC 8 `file://` hyperlink when the terminal
|
|
16
23
|
* supports it. Degrades to the plain label otherwise.
|
|
@@ -13,6 +13,7 @@ import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
|
|
|
13
13
|
import { renderMarkdown } from '../markdown.js';
|
|
14
14
|
import { redactSecrets } from '../redact.js';
|
|
15
15
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
|
|
16
|
+
import { extractArtifacts, extractHooks, extractLinks, extractSkills } from './highlights.js';
|
|
16
17
|
import { extractTodoProgressFromEvents } from './state.js';
|
|
17
18
|
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
18
19
|
/**
|
|
@@ -29,6 +30,31 @@ export function relativeToCwd(absPath, cwd) {
|
|
|
29
30
|
}
|
|
30
31
|
return absPath;
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Display form for a touched path: cwd-relative first; then collapse any
|
|
35
|
+
* `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
|
|
36
|
+
* group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
|
|
37
|
+
* else home-collapse.
|
|
38
|
+
*/
|
|
39
|
+
export function displayPath(absPath, cwd) {
|
|
40
|
+
const rel = relativeToCwd(absPath, cwd);
|
|
41
|
+
const norm = rel.replace(/\\/g, '/');
|
|
42
|
+
const wt = norm.match(/(^|\/)\.agents\/worktrees\/([^/]+)/);
|
|
43
|
+
if (wt) {
|
|
44
|
+
const after = norm.slice(norm.indexOf(wt[0]) + wt[0].length).replace(/^\//, '');
|
|
45
|
+
return after ? `⧉ ${wt[2]}/${after}` : `⧉ ${wt[2]}`;
|
|
46
|
+
}
|
|
47
|
+
return rel;
|
|
48
|
+
}
|
|
49
|
+
/** One checklist line with a status marker: `[x] done` / `[>] doing` / `[ ] todo`. */
|
|
50
|
+
function renderTodoMarker(item) {
|
|
51
|
+
const text = item.content;
|
|
52
|
+
if (item.status === 'completed')
|
|
53
|
+
return chalk.green('[x]') + ' ' + chalk.gray(text);
|
|
54
|
+
if (item.status === 'in_progress')
|
|
55
|
+
return chalk.yellow('[>]') + ' ' + chalk.white(text);
|
|
56
|
+
return chalk.gray('[ ]') + ' ' + chalk.white(text);
|
|
57
|
+
}
|
|
32
58
|
/** Best-effort feature-detect for OSC 8 hyperlink support in the current TTY. */
|
|
33
59
|
function supportsHyperlinks() {
|
|
34
60
|
return Boolean(process.stdout.isTTY &&
|
|
@@ -387,11 +413,12 @@ function pickDistinct(samples, max) {
|
|
|
387
413
|
return result.length > 0 ? result : samples.slice(0, max);
|
|
388
414
|
}
|
|
389
415
|
// ── File grouping ─────────────────────────────────────────────────────────────
|
|
390
|
-
/** Group file paths by their parent directory,
|
|
416
|
+
/** Group file paths by their parent directory, in display form (cwd-relative,
|
|
417
|
+
* worktree-collapsed, home-collapsed). */
|
|
391
418
|
function groupByParentDir(paths, cwd) {
|
|
392
419
|
const groups = new Map();
|
|
393
420
|
for (const p of paths) {
|
|
394
|
-
const rel =
|
|
421
|
+
const rel = displayPath(p, cwd);
|
|
395
422
|
const slashIdx = rel.lastIndexOf('/');
|
|
396
423
|
const dir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '.';
|
|
397
424
|
const base = slashIdx >= 0 ? rel.slice(slashIdx + 1) : rel;
|
|
@@ -454,17 +481,17 @@ const OP_MARK = { created: '+', modified: '~', deleted: '−' };
|
|
|
454
481
|
* create/modify/delete lifecycle, plus a `+N ~N −N` summary. Replaces the old
|
|
455
482
|
* flat "Modified" list. Returns true if anything was rendered.
|
|
456
483
|
*/
|
|
457
|
-
function renderChangesSection(lines,
|
|
484
|
+
function renderChangesSection(lines, allChanges, cwd) {
|
|
458
485
|
// In-project changes only; edits outside cwd (e.g. /tmp) keep their own
|
|
459
486
|
// "External edits" section so they don't clutter the project's changeset.
|
|
460
487
|
const inCwd = (p) => !cwd || !p.startsWith('/') || p.startsWith(cwd + '/');
|
|
461
|
-
const changes =
|
|
488
|
+
const changes = allChanges.filter(ch => inCwd(ch.path));
|
|
462
489
|
if (changes.length === 0)
|
|
463
490
|
return false;
|
|
464
491
|
const c = changeCounts(changes);
|
|
465
492
|
const opByRel = new Map();
|
|
466
493
|
for (const ch of changes)
|
|
467
|
-
opByRel.set(
|
|
494
|
+
opByRel.set(displayPath(ch.path, cwd), ch.op);
|
|
468
495
|
const summary = [
|
|
469
496
|
c.created ? chalk.green(`+${c.created}`) : '',
|
|
470
497
|
c.modified ? chalk.yellow(`~${c.modified}`) : '',
|
|
@@ -528,8 +555,8 @@ export function renderSummary(events, cwd) {
|
|
|
528
555
|
const filesModifiedExternal = new Set();
|
|
529
556
|
// Commands with timestamps
|
|
530
557
|
const cmdList = [];
|
|
531
|
-
// Plan items
|
|
532
|
-
const todoItems = extractTodoProgressFromEvents(events)?.items
|
|
558
|
+
// Plan items (checklist entries keep their status for [x]/[>]/[ ] markers)
|
|
559
|
+
const todoItems = extractTodoProgressFromEvents(events)?.items ?? [];
|
|
533
560
|
let exitPlanContent = null;
|
|
534
561
|
let planFilePath = null;
|
|
535
562
|
// Subagent spawns
|
|
@@ -559,7 +586,7 @@ export function renderSummary(events, cwd) {
|
|
|
559
586
|
}
|
|
560
587
|
else {
|
|
561
588
|
(isInsideCwd(p) || !cwd ? filesModifiedAbs : filesModifiedExternal).add(p);
|
|
562
|
-
recentActivity.push({ kind: 'edit', label:
|
|
589
|
+
recentActivity.push({ kind: 'edit', label: displayPath(p, cwd), ts, absPath: p });
|
|
563
590
|
}
|
|
564
591
|
}
|
|
565
592
|
}
|
|
@@ -624,11 +651,11 @@ export function renderSummary(events, cwd) {
|
|
|
624
651
|
filesReadAbs.delete(p);
|
|
625
652
|
for (const p of filesModifiedExternal)
|
|
626
653
|
filesReadAbs.delete(p);
|
|
627
|
-
// Build abs→
|
|
654
|
+
// Build abs→display mapping for linkPath
|
|
628
655
|
const buildAbsMap = (absSet) => {
|
|
629
656
|
const m = new Map();
|
|
630
657
|
for (const abs of absSet) {
|
|
631
|
-
const rel =
|
|
658
|
+
const rel = displayPath(abs, cwd);
|
|
632
659
|
m.set(rel, abs);
|
|
633
660
|
}
|
|
634
661
|
return m;
|
|
@@ -668,22 +695,27 @@ export function renderSummary(events, cwd) {
|
|
|
668
695
|
}
|
|
669
696
|
lines.push('');
|
|
670
697
|
}
|
|
671
|
-
// 3. Plan
|
|
698
|
+
// 3. Plan — the plan document (ExitPlanMode text / plan file) AND the live
|
|
699
|
+
// checklist. Both render: the checklist used to be hidden whenever plan text
|
|
700
|
+
// existed, which read as "this session had no todos".
|
|
672
701
|
if (todoItems.length > 0 || exitPlanContent || planFilePath) {
|
|
673
702
|
lines.push(chalk.bold('Plan'));
|
|
674
703
|
if (planFilePath) {
|
|
675
704
|
const home = process.env.HOME ?? '';
|
|
676
|
-
const
|
|
677
|
-
lines.push(' ' + chalk.cyan(
|
|
705
|
+
const planPathDisplay = home && planFilePath.startsWith(home) ? planFilePath.replace(home, '~') : planFilePath;
|
|
706
|
+
lines.push(' ' + chalk.cyan(planPathDisplay));
|
|
678
707
|
}
|
|
679
708
|
if (exitPlanContent) {
|
|
680
709
|
const planLines = exitPlanContent.split('\n').slice(0, 10);
|
|
681
710
|
for (const l of planLines)
|
|
682
711
|
lines.push(' ' + l);
|
|
683
712
|
}
|
|
684
|
-
|
|
713
|
+
if (todoItems.length > 0) {
|
|
685
714
|
for (const item of todoItems.slice(0, 20)) {
|
|
686
|
-
lines.push('
|
|
715
|
+
lines.push(' ' + renderTodoMarker(item));
|
|
716
|
+
}
|
|
717
|
+
if (todoItems.length > 20) {
|
|
718
|
+
lines.push(' ' + chalk.gray(`… (${todoItems.length - 20} more)`));
|
|
687
719
|
}
|
|
688
720
|
}
|
|
689
721
|
lines.push('');
|
|
@@ -698,6 +730,30 @@ export function renderSummary(events, cwd) {
|
|
|
698
730
|
}
|
|
699
731
|
lines.push('');
|
|
700
732
|
}
|
|
733
|
+
// 4b. Highlights — what the session USED (skills, hooks) and the references
|
|
734
|
+
// it mentioned (links). Shared extraction with the picker preview so the two
|
|
735
|
+
// renders never disagree.
|
|
736
|
+
const skills = extractSkills(events);
|
|
737
|
+
if (skills.length > 0) {
|
|
738
|
+
const shown = skills.map(s => chalk.white(s.name) + (s.count > 1 ? chalk.gray(` ×${s.count}`) : ''));
|
|
739
|
+
lines.push(chalk.bold('Skills') + chalk.gray(` (${skills.length})`) + ' ' + shown.join(chalk.gray(' · ')));
|
|
740
|
+
lines.push('');
|
|
741
|
+
}
|
|
742
|
+
const hooks = extractHooks(events);
|
|
743
|
+
if (hooks.length > 0) {
|
|
744
|
+
const shown = hooks.map(h => chalk.white(h.name) + (h.count > 1 ? chalk.gray(` ×${h.count}`) : '') + (h.failed ? chalk.red(` (${h.failed} failed)`) : ''));
|
|
745
|
+
lines.push(chalk.bold('Hooks') + chalk.gray(` (${hooks.length})`) + ' ' + shown.join(chalk.gray(' · ')));
|
|
746
|
+
lines.push('');
|
|
747
|
+
}
|
|
748
|
+
const links = extractLinks(events);
|
|
749
|
+
if (links.length > 0) {
|
|
750
|
+
// Width-capped like the picker's Dirs line: a link-heavy session must not
|
|
751
|
+
// wrap the summary pane.
|
|
752
|
+
const shown = links.slice(0, 6).map(l => chalk.blue(linkUrl(l.url, l.label)));
|
|
753
|
+
const more = links.length > 6 ? chalk.gray(` · +${links.length - 6} more`) : '';
|
|
754
|
+
lines.push(chalk.bold('Links') + chalk.gray(` (${links.length})`) + ' ' + shown.join(chalk.gray(' · ')) + more);
|
|
755
|
+
lines.push('');
|
|
756
|
+
}
|
|
701
757
|
// 5. Errors (moved up from the bottom: it describes failed attempts, not the
|
|
702
758
|
// session's final state. Sitting at the bottom previously made early errors
|
|
703
759
|
// look recent, which confused readers.)
|
|
@@ -713,8 +769,22 @@ export function renderSummary(events, cwd) {
|
|
|
713
769
|
lines.push('');
|
|
714
770
|
}
|
|
715
771
|
// 6. Changes — files grouped by directory with create/modify/delete lifecycle
|
|
716
|
-
// (replaces the old flat "Modified" + "External edits" lists).
|
|
717
|
-
|
|
772
|
+
// (replaces the old flat "Modified" + "External edits" lists). Classified
|
|
773
|
+
// once here and shared with the Artifacts section below.
|
|
774
|
+
const allChanges = classifyFileChanges(events);
|
|
775
|
+
renderChangesSection(lines, allChanges, cwd);
|
|
776
|
+
// 6a. Artifacts — documents the session PRODUCED (`.agents/artifacts|plans|
|
|
777
|
+
// reports`, other *.md/*.html creations), named and clickable. These drown in
|
|
778
|
+
// the Changeset's source churn, so they get their own section.
|
|
779
|
+
const artifacts = extractArtifacts(allChanges);
|
|
780
|
+
if (artifacts.length > 0) {
|
|
781
|
+
lines.push(chalk.bold('Artifacts') + chalk.gray(` (${artifacts.length})`));
|
|
782
|
+
for (const a of artifacts) {
|
|
783
|
+
const tag = a.bucket === 'docs' ? '' : chalk.gray(` (${a.bucket})`);
|
|
784
|
+
lines.push(' ' + chalk.green('+') + ' ' + chalk.cyan(linkPath(a.path, a.basename)) + tag);
|
|
785
|
+
}
|
|
786
|
+
lines.push('');
|
|
787
|
+
}
|
|
718
788
|
// 6b. Catch-up signals: last test/build verdict, then the tool histogram.
|
|
719
789
|
renderTestsLine(lines, events);
|
|
720
790
|
renderToolsSection(lines, computeSummaryStats(events));
|
|
@@ -18,7 +18,7 @@ export declare const SESSION_AGENTS: SessionAgentId[];
|
|
|
18
18
|
export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
|
|
19
19
|
/** A single normalized event within a session (message, tool call, thinking, etc.). */
|
|
20
20
|
export interface SessionEvent {
|
|
21
|
-
type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment';
|
|
21
|
+
type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook';
|
|
22
22
|
agent: SessionAgentId;
|
|
23
23
|
timestamp: string;
|
|
24
24
|
role?: 'user' | 'assistant';
|
|
@@ -49,6 +49,9 @@ export interface SessionEvent {
|
|
|
49
49
|
name?: string;
|
|
50
50
|
mediaType?: string;
|
|
51
51
|
sizeBytes?: number;
|
|
52
|
+
hookName?: string;
|
|
53
|
+
/** Lifecycle event the hook fired on (SessionStart, PreToolUse, …). */
|
|
54
|
+
hookEvent?: string;
|
|
52
55
|
}
|
|
53
56
|
/** A displayable file attachment discovered in a session transcript. */
|
|
54
57
|
export interface SessionAttachment {
|
|
@@ -70,6 +70,7 @@ export declare const loadDrive: ModuleLoader;
|
|
|
70
70
|
export declare const loadFactory: ModuleLoader;
|
|
71
71
|
export declare const loadUsage: ModuleLoader;
|
|
72
72
|
export declare const loadCost: ModuleLoader;
|
|
73
|
+
export declare const loadPerf: ModuleLoader;
|
|
73
74
|
export declare const loadOutput: ModuleLoader;
|
|
74
75
|
export declare const loadBudget: ModuleLoader;
|
|
75
76
|
export declare const loadAlias: ModuleLoader;
|
|
@@ -48,6 +48,7 @@ export const loadDrive = async () => (await import('../../commands/drive.js')).r
|
|
|
48
48
|
export const loadFactory = async () => (await import('../../commands/factory.js')).registerFactoryCommands;
|
|
49
49
|
export const loadUsage = async () => (await import('../../commands/usage.js')).registerUsageCommand;
|
|
50
50
|
export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
|
|
51
|
+
export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
|
|
51
52
|
export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
|
|
52
53
|
export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
|
|
53
54
|
export const loadAlias = async () => (await import('../../commands/alias.js')).registerAliasCommand;
|
|
@@ -162,6 +163,7 @@ export const COMMAND_LOADERS = {
|
|
|
162
163
|
factory: [loadFactory],
|
|
163
164
|
usage: [loadUsage],
|
|
164
165
|
cost: [loadCost],
|
|
166
|
+
perf: [loadPerf],
|
|
165
167
|
output: [loadOutput],
|
|
166
168
|
budget: [loadBudget],
|
|
167
169
|
alias: [loadAlias],
|