@phnx-labs/agents-cli 1.20.86 → 1.20.88
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 +172 -0
- package/README.md +3 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/routines.js +2 -2
- package/dist/commands/sessions-browser.d.ts +18 -0
- package/dist/commands/sessions-browser.js +126 -24
- package/dist/commands/sessions-picker.d.ts +21 -8
- package/dist/commands/sessions-picker.js +83 -7
- package/dist/commands/sessions.d.ts +19 -0
- package/dist/commands/sessions.js +148 -18
- package/dist/commands/ssh.js +62 -4
- package/dist/commands/teams-picker.d.ts +2 -0
- package/dist/commands/teams-picker.js +2 -1
- package/dist/commands/teams.d.ts +4 -1
- package/dist/commands/teams.js +106 -70
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/commands/view.js +14 -3
- package/dist/index.js +31 -1
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/claude-account-token.d.ts +12 -0
- package/dist/lib/claude-account-token.js +63 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +19 -1
- package/dist/lib/daemon.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/devices/registry.d.ts +25 -0
- package/dist/lib/devices/registry.js +82 -1
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +13 -0
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +51 -9
- package/dist/lib/format.d.ts +7 -0
- package/dist/lib/format.js +11 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/runner.d.ts +14 -1
- package/dist/lib/runner.js +37 -8
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -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/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/db.d.ts +21 -0
- package/dist/lib/session/db.js +45 -4
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/remote-list.d.ts +7 -0
- package/dist/lib/session/remote-list.js +8 -4
- package/dist/lib/session/state.js +69 -2
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/team-filter.d.ts +22 -3
- package/dist/lib/session/team-filter.js +106 -17
- package/dist/lib/session/types.d.ts +9 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/signin-badge.d.ts +17 -0
- package/dist/lib/signin-badge.js +19 -0
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +22 -87
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
package/dist/commands/exec.js
CHANGED
|
@@ -1667,7 +1667,7 @@ export function registerRunCommand(program) {
|
|
|
1667
1667
|
const { pickSessionInteractive } = await import('./sessions.js');
|
|
1668
1668
|
const { buildContinuePrompt } = await import('../lib/loop.js');
|
|
1669
1669
|
// Freshen the index for this agent before any lookup (incremental, cached).
|
|
1670
|
-
// AgentId is wider than SessionAgentId (
|
|
1670
|
+
// AgentId is wider than SessionAgentId (amp/kiro/goose/copilot keep no transcripts);
|
|
1671
1671
|
// those simply yield no matches and fall through to the not-found error.
|
|
1672
1672
|
const sessionAgent = agent;
|
|
1673
1673
|
await discoverSessions({ agent: sessionAgent, version });
|
|
@@ -1899,8 +1899,8 @@ export function registerRunCommand(program) {
|
|
|
1899
1899
|
// safest native mode (modes[0], typically edit). That covers both the
|
|
1900
1900
|
// implicit default and an explicit `--mode plan`, so multi-agent
|
|
1901
1901
|
// scripts can pass a uniform plan flag without per-agent branching.
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1902
|
+
// Mode degradation is never silent: buildExecCommand emits one stderr
|
|
1903
|
+
// warning for the requested-to-resolved transition unless --quiet is set.
|
|
1904
1904
|
// `skip` still hard-fails when unsupported — pretending we bypassed
|
|
1905
1905
|
// permissions would be unsafe.
|
|
1906
1906
|
const modeIsDefault = modeSource === 'default';
|
|
@@ -1913,20 +1913,7 @@ export function registerRunCommand(program) {
|
|
|
1913
1913
|
console.error(chalk.red(err.message));
|
|
1914
1914
|
process.exit(1);
|
|
1915
1915
|
}
|
|
1916
|
-
|
|
1917
|
-
mode = resolvedMode;
|
|
1918
|
-
if (!options.quiet) {
|
|
1919
|
-
if (requestedMode === 'plan' && !modeIsDefault) {
|
|
1920
|
-
process.stderr.write(chalk.yellow(`[agents] ${agent} has no read-only 'plan' mode; using '${mode}' (writable) instead. Pass --mode ${mode} to silence this.\n`));
|
|
1921
|
-
}
|
|
1922
|
-
else {
|
|
1923
|
-
process.stderr.write(chalk.gray(`[agents] ${agent} has no '${requestedMode}' mode; using '${mode}'\n`));
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
else {
|
|
1928
|
-
mode = resolvedMode;
|
|
1929
|
-
}
|
|
1916
|
+
mode = resolvedMode;
|
|
1930
1917
|
// Fail fast on the headless-plan stall footgun: a slash command run
|
|
1931
1918
|
// headless under the implicit default 'plan' mode hangs forever at
|
|
1932
1919
|
// ExitPlanMode (no TTY to approve the plan). Tell the user how to fix it
|
|
@@ -1935,7 +1922,7 @@ export function registerRunCommand(program) {
|
|
|
1935
1922
|
const stallCmd = headlessPlanStallCommand({
|
|
1936
1923
|
prompt,
|
|
1937
1924
|
interactive: options.interactive,
|
|
1938
|
-
mode,
|
|
1925
|
+
mode: resolvedMode,
|
|
1939
1926
|
modeIsDefault,
|
|
1940
1927
|
});
|
|
1941
1928
|
if (stallCmd) {
|
|
@@ -2029,7 +2016,7 @@ export function registerRunCommand(program) {
|
|
|
2029
2016
|
version,
|
|
2030
2017
|
prompt,
|
|
2031
2018
|
interactive: options.interactive || forceInteractive,
|
|
2032
|
-
mode,
|
|
2019
|
+
mode: requestedMode,
|
|
2033
2020
|
effort,
|
|
2034
2021
|
cwd: options.cwd,
|
|
2035
2022
|
model,
|
|
@@ -2040,6 +2027,7 @@ export function registerRunCommand(program) {
|
|
|
2040
2027
|
name: options.name,
|
|
2041
2028
|
resume: resumeNative,
|
|
2042
2029
|
verbose: options.verbose,
|
|
2030
|
+
modeWarningState: { quiet: options.quiet },
|
|
2043
2031
|
// --raw, --no-tmux (commander negation → options.tmux === false), and
|
|
2044
2032
|
// --disable-tmux all bypass the interactive tmux wrapper. AGENTS_NO_TMUX=1
|
|
2045
2033
|
// does the same via the env check in exec.ts.
|
package/dist/commands/inspect.js
CHANGED
|
@@ -32,6 +32,7 @@ import { listMcpServerConfigs, discoverMcpConfigsFromRepo } from '../lib/mcp.js'
|
|
|
32
32
|
import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../lib/plugins.js';
|
|
33
33
|
import { PLUGIN_GROUP_COLORS } from './plugins.js';
|
|
34
34
|
import { countSessionsInScope } from '../lib/session/discover.js';
|
|
35
|
+
import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
35
36
|
import { damerauLevenshtein } from '../lib/fuzzy.js';
|
|
36
37
|
import { terminalWidth, truncateToWidth, stringWidth, stripAnsi } from '../lib/session/width.js';
|
|
37
38
|
/** Resource kinds the inspect command can drill into. */
|
|
@@ -1256,14 +1257,11 @@ function safeStat(p) {
|
|
|
1256
1257
|
return null;
|
|
1257
1258
|
}
|
|
1258
1259
|
}
|
|
1259
|
-
const SESSION_AGENTS = new Set([
|
|
1260
|
-
'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid',
|
|
1261
|
-
]);
|
|
1262
1260
|
function safeCountSessions(agent) {
|
|
1263
|
-
if (!
|
|
1261
|
+
if (!isSessionTrackedAgent(agent))
|
|
1264
1262
|
return 0;
|
|
1265
1263
|
try {
|
|
1266
|
-
return countSessionsInScope({ agent
|
|
1264
|
+
return countSessionsInScope({ agent });
|
|
1267
1265
|
}
|
|
1268
1266
|
catch {
|
|
1269
1267
|
return 0;
|
|
@@ -19,7 +19,7 @@ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js'
|
|
|
19
19
|
import { getRoutinesDir } from '../lib/state.js';
|
|
20
20
|
import { IS_WINDOWS } from '../lib/platform/index.js';
|
|
21
21
|
import { safeJoin } from '../lib/paths.js';
|
|
22
|
-
import { executeJob, executeJobDetached, monitorRunningJobs } from '../lib/runner.js';
|
|
22
|
+
import { executeJob, executeJobDetached, monitorRunningJobs, ROUTINE_AGENT_IDS } from '../lib/runner.js';
|
|
23
23
|
import { JobScheduler } from '../lib/scheduler.js';
|
|
24
24
|
import { detectOverdueJobs } from '../lib/overdue.js';
|
|
25
25
|
import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
|
|
@@ -572,7 +572,7 @@ export function registerRoutinesCommands(program) {
|
|
|
572
572
|
.command('add [nameOrPath]')
|
|
573
573
|
.description('Create a new routine from a YAML file or inline flags. Starts the scheduler automatically if it is not already running.')
|
|
574
574
|
.option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
|
|
575
|
-
.option('-a, --agent <agent>',
|
|
575
|
+
.option('-a, --agent <agent>', `Which agent runs this routine: ${ROUTINE_AGENT_IDS.join(', ')}`)
|
|
576
576
|
.option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
|
|
577
577
|
.option('--command <sh>', 'Run a plain shell command directly (no agent, no auth, no sandbox) — for deterministic housekeeping routines. Mutually exclusive with --agent and --workflow; --prompt is not used.')
|
|
578
578
|
.option('-p, --prompt <prompt>', 'Task instruction for the agent')
|
|
@@ -24,11 +24,27 @@ export interface BrowserFilter {
|
|
|
24
24
|
agent?: string;
|
|
25
25
|
/** filter to one machine, or all — the `D` key / `--device`. */
|
|
26
26
|
device?: string;
|
|
27
|
+
/** filter to one team's lineage, or all — the `T` key / `--in-team`. */
|
|
28
|
+
team?: string;
|
|
27
29
|
/** this-repo subtree vs every directory — the `P` key / `--all`. */
|
|
28
30
|
projectScope: 'repo' | 'all';
|
|
29
31
|
/** time window (undefined = all time) — the `W` key / `--since`. */
|
|
30
32
|
window?: string;
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Complete a seed into the filter the picker actually runs on.
|
|
36
|
+
*
|
|
37
|
+
* Every optional field of {@link BrowserFilter} has to be named here, because the
|
|
38
|
+
* seed is copied field-by-field and an omission is silent: the field is optional,
|
|
39
|
+
* so the compiler says nothing and the browser just opens without that filter.
|
|
40
|
+
* `team` was dropped exactly this way, which made `--in-team` a no-op on the
|
|
41
|
+
* interactive path while the scope half of the same seed still applied — so the
|
|
42
|
+
* view opened wide and all-time and looked like the flag had worked.
|
|
43
|
+
*
|
|
44
|
+
* Exported so a test can assert the FILTER, not just the seed; testing the seed
|
|
45
|
+
* alone cannot see this class of bug.
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildInitialFilter(initial: Partial<BrowserFilter>): BrowserFilter;
|
|
32
48
|
/** Return the next value in `[undefined, ...options]`, wrapping. */
|
|
33
49
|
export declare function cycle(current: string | undefined, options: string[]): string | undefined;
|
|
34
50
|
export declare function cycleWindow(current: string | undefined): string | undefined;
|
|
@@ -74,6 +90,8 @@ export declare function bareBrowserSeed(opts: {
|
|
|
74
90
|
agent?: string;
|
|
75
91
|
all?: boolean;
|
|
76
92
|
since?: string;
|
|
93
|
+
host?: string[];
|
|
94
|
+
inTeam?: string;
|
|
77
95
|
}): Partial<BrowserFilter>;
|
|
78
96
|
/**
|
|
79
97
|
* A live session's stable row key: its session id when the agent reported one,
|
|
@@ -11,13 +11,51 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import { spawnSync } from 'child_process';
|
|
14
|
+
import chalk from 'chalk';
|
|
14
15
|
import { dynamicPicker } from '../lib/picker.js';
|
|
15
16
|
import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
16
17
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
17
18
|
import { gatherRemoteList } from '../lib/session/remote-list.js';
|
|
19
|
+
import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
|
|
18
20
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
19
21
|
import { buildPreview } from './sessions-picker.js';
|
|
20
|
-
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
|
|
22
|
+
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, } from './sessions.js';
|
|
23
|
+
/**
|
|
24
|
+
* Complete a seed into the filter the picker actually runs on.
|
|
25
|
+
*
|
|
26
|
+
* Every optional field of {@link BrowserFilter} has to be named here, because the
|
|
27
|
+
* seed is copied field-by-field and an omission is silent: the field is optional,
|
|
28
|
+
* so the compiler says nothing and the browser just opens without that filter.
|
|
29
|
+
* `team` was dropped exactly this way, which made `--in-team` a no-op on the
|
|
30
|
+
* interactive path while the scope half of the same seed still applied — so the
|
|
31
|
+
* view opened wide and all-time and looked like the flag had worked.
|
|
32
|
+
*
|
|
33
|
+
* Exported so a test can assert the FILTER, not just the seed; testing the seed
|
|
34
|
+
* alone cannot see this class of bug.
|
|
35
|
+
*/
|
|
36
|
+
export function buildInitialFilter(initial) {
|
|
37
|
+
return {
|
|
38
|
+
running: initial.running ?? false,
|
|
39
|
+
teams: initial.teams ?? false,
|
|
40
|
+
agent: initial.agent,
|
|
41
|
+
device: initial.device,
|
|
42
|
+
team: initial.team,
|
|
43
|
+
projectScope: initial.projectScope ?? 'repo',
|
|
44
|
+
window: 'window' in initial ? initial.window : '30d',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Cache key for the transcript pool: every filter that changes what is FETCHED
|
|
48
|
+
* (window, team-origin inclusion, and the team filter's deeper limit) — not the
|
|
49
|
+
* ones applied in memory afterwards. */
|
|
50
|
+
function poolCacheKey(f) {
|
|
51
|
+
// The team filter contributes only whether it is SET, not which team: any team
|
|
52
|
+
// fetches the same deep pool and is then narrowed in memory. Keying on the name
|
|
53
|
+
// would make `t` the one hotkey that re-fans-out the fleet on every step of the
|
|
54
|
+
// cycle, at up to REMOTE_TIMEOUT_MS per unreachable peer.
|
|
55
|
+
return `${f.window ?? 'all'}|${f.teams}|${f.team ? 'team' : ''}`;
|
|
56
|
+
}
|
|
57
|
+
/** Pool size when a team filter is active; one team's rows can sit anywhere. */
|
|
58
|
+
const WHOLE_TEAM_POOL_LIMIT = 5000;
|
|
21
59
|
/** Ordered window cycle for the `W` key. `undefined` = all time. */
|
|
22
60
|
const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
|
|
23
61
|
/** Return the next value in `[undefined, ...options]`, wrapping. */
|
|
@@ -73,6 +111,8 @@ export function browserFilterToArgv(f, query = '') {
|
|
|
73
111
|
a.push('-a', f.agent);
|
|
74
112
|
if (f.device)
|
|
75
113
|
a.push('--device', f.device);
|
|
114
|
+
if (f.team)
|
|
115
|
+
a.push('--in-team', f.team);
|
|
76
116
|
if (f.projectScope === 'all')
|
|
77
117
|
a.push('--all');
|
|
78
118
|
if (f.window)
|
|
@@ -116,12 +156,28 @@ export function activeBrowserSeed(opts) {
|
|
|
116
156
|
* `-a`/`--device` still narrow their axis.
|
|
117
157
|
*/
|
|
118
158
|
export function bareBrowserSeed(opts) {
|
|
159
|
+
// An explicit --device scopes the pool to a peer, whose cwds live under that
|
|
160
|
+
// machine's home — none of them can be under OUR process.cwd(), so the default
|
|
161
|
+
// 'repo' scope would filter every fetched row away and render an empty list.
|
|
162
|
+
// A host scope therefore implies all-directories, exactly as --all does.
|
|
163
|
+
const scoped = (opts.host?.length ?? 0) > 0;
|
|
164
|
+
// --in-team asks for ONE team's lineage, and a team's teammates run in their own
|
|
165
|
+
// `.agents/worktrees/<slug>/` — a different cwd from ours — while the team itself
|
|
166
|
+
// may be older than the default window. Both defaults would hide exactly the rows
|
|
167
|
+
// the flag exists to surface, so it widens the scope the way --all does. The flag
|
|
168
|
+
// path does this too (sessions.ts `wantsWholeTeam`); the browser is the one a
|
|
169
|
+
// human actually reaches, so it must not be the one that stays narrow.
|
|
170
|
+
const wholeTeam = !!opts.inTeam;
|
|
119
171
|
return {
|
|
120
172
|
teams: !!opts.teams,
|
|
121
173
|
agent: opts.agent,
|
|
174
|
+
// The filter carries one device; seed it only when the scope names exactly
|
|
175
|
+
// one, so a two-device scope isn't narrowed to the first of them.
|
|
176
|
+
device: opts.host?.length === 1 ? normalizeDeviceSeed(opts.host[0]) : undefined,
|
|
177
|
+
team: opts.inTeam,
|
|
122
178
|
// --all maxes every non-status filter: all dirs AND all-time. --since wins.
|
|
123
|
-
projectScope: opts.all ? 'all' : 'repo',
|
|
124
|
-
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
179
|
+
projectScope: opts.all || scoped || wholeTeam ? 'all' : 'repo',
|
|
180
|
+
window: opts.since ?? (opts.all || wholeTeam ? undefined : '30d'),
|
|
125
181
|
};
|
|
126
182
|
}
|
|
127
183
|
/** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
|
|
@@ -154,6 +210,7 @@ function copyToClipboard(text) {
|
|
|
154
210
|
* not add" contract instead of always sweeping the whole fleet. */
|
|
155
211
|
async function fetchRawPool(f, self, local, hosts) {
|
|
156
212
|
const since = f.window;
|
|
213
|
+
let unreachable = [];
|
|
157
214
|
// Local pool: wide (every directory) — device/agent/project are applied in
|
|
158
215
|
// memory so a hotkey toggle is instant and doesn't re-hit the disk. Skipped
|
|
159
216
|
// when an explicit host scope excludes this machine.
|
|
@@ -163,7 +220,10 @@ async function fetchRawPool(f, self, local, hosts) {
|
|
|
163
220
|
cwd: process.cwd(),
|
|
164
221
|
since,
|
|
165
222
|
excludeTeamOrigin: !f.teams,
|
|
166
|
-
|
|
223
|
+
// A team filter reaches back past the usual browse window, so the pool it
|
|
224
|
+
// draws from has to as well — otherwise the newest 500 rows decide which
|
|
225
|
+
// teams exist.
|
|
226
|
+
limit: f.team ? WHOLE_TEAM_POOL_LIMIT : 500,
|
|
167
227
|
sortBy: 'timestamp',
|
|
168
228
|
})
|
|
169
229
|
: [];
|
|
@@ -171,22 +231,40 @@ async function fetchRawPool(f, self, local, hosts) {
|
|
|
171
231
|
// Skipped under --local. An explicit --host/--device scopes exactly which peers
|
|
172
232
|
// are dialed (undefined = sweep every online device). Best-effort — a fan-out
|
|
173
233
|
// failure leaves the local list intact.
|
|
174
|
-
|
|
234
|
+
const remoteHosts = remoteHostsToDial(hosts, self);
|
|
235
|
+
// An explicit scope naming only this machine leaves nothing remote to dial.
|
|
236
|
+
// gatherRemoteList reads `[]` as "no hosts given" and falls through to the
|
|
237
|
+
// whole-fleet sweep, so `--device <self>` would dial every online box — the
|
|
238
|
+
// exact opposite of the flag's scope-not-add contract. Skip the fan-out here,
|
|
239
|
+
// the same way gatherActiveSessions does for --active.
|
|
240
|
+
const dialPeers = !local && (!hosts?.length || (remoteHosts && remoteHosts.length > 0));
|
|
241
|
+
if (dialPeers) {
|
|
175
242
|
try {
|
|
176
|
-
|
|
243
|
+
// The peer's cap has to match the local one, or a team filter widens only
|
|
244
|
+
// this machine's half of the pool and a peer's older rows stay invisible —
|
|
245
|
+
// the same bug one hop out. A numeric --limit is forwarded rather than
|
|
246
|
+
// --in-team itself, which a peer on an older build would reject as an
|
|
247
|
+
// unknown option and fail the whole fan-out.
|
|
248
|
+
const forwarded = ['sessions', '--all', '--json', '--limit', String(f.team ? WHOLE_TEAM_POOL_LIMIT : 500)];
|
|
177
249
|
if (since)
|
|
178
250
|
forwarded.push('--since', since);
|
|
179
251
|
if (f.teams)
|
|
180
252
|
forwarded.push('--teams');
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
253
|
+
const remoteResult = await gatherRemoteList(forwarded, remoteHosts);
|
|
254
|
+
unreachable = remoteResult.unreachable;
|
|
255
|
+
if (remoteResult.sessions.length > 0)
|
|
256
|
+
rows = mergeLocalFirst([...rows, ...remoteResult.sessions], self);
|
|
184
257
|
}
|
|
185
258
|
catch {
|
|
186
259
|
// enrichment, never a hard dependency
|
|
187
260
|
}
|
|
188
261
|
}
|
|
189
|
-
|
|
262
|
+
// Team rows are anonymous without their meta.json (team name, handle, the
|
|
263
|
+
// orchestrator that spawned them). Only pay for that read when team rows are
|
|
264
|
+
// actually in the pool — with `c` off they were excluded at the query.
|
|
265
|
+
if (f.teams)
|
|
266
|
+
rows = enrichTeamOrigins(rows);
|
|
267
|
+
return { key: poolCacheKey(f), rows, unreachable };
|
|
190
268
|
}
|
|
191
269
|
/**
|
|
192
270
|
* A live session's stable row key: its session id when the agent reported one,
|
|
@@ -288,6 +366,8 @@ function applyFilters(rows, live, f, self) {
|
|
|
288
366
|
out = out.filter((r) => r.agent === f.agent);
|
|
289
367
|
if (f.device)
|
|
290
368
|
out = out.filter((r) => (r.machine ?? self) === f.device);
|
|
369
|
+
if (f.team)
|
|
370
|
+
out = out.filter((r) => matchesTeam(r, f.team));
|
|
291
371
|
if (f.projectScope === 'repo') {
|
|
292
372
|
const cwd = process.cwd();
|
|
293
373
|
out = out.filter((r) => !!r.cwd && (r.cwd === cwd || r.cwd.startsWith(cwd + '/')));
|
|
@@ -310,6 +390,7 @@ function headerFor(f) {
|
|
|
310
390
|
const bits = [
|
|
311
391
|
`device:${f.device ?? 'all'}`,
|
|
312
392
|
`agent:${f.agent ?? 'all'}`,
|
|
393
|
+
`team:${f.team ?? 'all'}`,
|
|
313
394
|
f.projectScope === 'repo' ? 'this repo' : 'all dirs',
|
|
314
395
|
`window:${f.window ?? 'all'}`,
|
|
315
396
|
];
|
|
@@ -323,7 +404,7 @@ function helpFor(_f, mode) {
|
|
|
323
404
|
if (mode === 'search') {
|
|
324
405
|
return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
|
|
325
406
|
}
|
|
326
|
-
return 's search · r running · c teams · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
|
|
407
|
+
return 's search · r running · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
|
|
327
408
|
}
|
|
328
409
|
/**
|
|
329
410
|
* Launch the interactive session browser. `initial` seeds the filter (e.g.
|
|
@@ -337,10 +418,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
337
418
|
// Updated after each load so the A/D cycles range over what's actually present.
|
|
338
419
|
let agentsInPool = [];
|
|
339
420
|
let devicesInPool = [];
|
|
421
|
+
let teamsInPool = [];
|
|
340
422
|
let cols = {};
|
|
341
|
-
// Cache the transcript fetch, keyed by (
|
|
342
|
-
// running are applied in memory so their
|
|
423
|
+
// Cache the transcript fetch, keyed by poolCacheKey (everything that changes
|
|
424
|
+
// what is FETCHED); agent/device/project/running are applied in memory so their
|
|
425
|
+
// hotkeys don't re-fan-out the fleet.
|
|
343
426
|
let rawCache = null;
|
|
427
|
+
// Peers that didn't answer the last fan-out. The fan-out's own note goes to
|
|
428
|
+
// stderr, which the full-screen picker repaints over — so it is surfaced in
|
|
429
|
+
// the header instead, where "that box is asleep" stays distinguishable from
|
|
430
|
+
// "that box has nothing matching".
|
|
431
|
+
let unreachable = [];
|
|
344
432
|
// The live index is slow (a full ps/tmux scan) and only the running filter
|
|
345
433
|
// needs it — fetch it once, lazily, the first time running is toggled on.
|
|
346
434
|
let liveCache = null;
|
|
@@ -350,17 +438,13 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
350
438
|
// caches) is a side channel it can't see — so a stale load must never commit
|
|
351
439
|
// it. We compute into locals and only write the shared state as the latest load.
|
|
352
440
|
let loadGen = 0;
|
|
353
|
-
const initialFilter =
|
|
354
|
-
running: initial.running ?? false,
|
|
355
|
-
teams: initial.teams ?? false,
|
|
356
|
-
agent: initial.agent,
|
|
357
|
-
device: initial.device,
|
|
358
|
-
projectScope: initial.projectScope ?? 'repo',
|
|
359
|
-
window: 'window' in initial ? initial.window : '30d',
|
|
360
|
-
};
|
|
441
|
+
const initialFilter = buildInitialFilter(initial);
|
|
361
442
|
const load = async (f) => {
|
|
362
443
|
const myGen = ++loadGen;
|
|
363
|
-
|
|
444
|
+
// f.team decides the fetch limit, so it belongs in the key — otherwise arriving
|
|
445
|
+
// via `t` reuses a 500-row pool while --in-team fetched 5000, and the cycle can
|
|
446
|
+
// only offer the teams that happened to be in whichever pool was built first.
|
|
447
|
+
const key = poolCacheKey(f);
|
|
364
448
|
let pool = rawCache && rawCache.key === key ? rawCache : null;
|
|
365
449
|
if (!pool) {
|
|
366
450
|
const fetched = await fetchRawPool(f, self, local, hosts);
|
|
@@ -386,6 +470,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
386
470
|
// Latest load — commit shared state atomically (no await past this point, so
|
|
387
471
|
// no newer load can interleave between these writes).
|
|
388
472
|
rawCache = pool;
|
|
473
|
+
unreachable = pool.unreachable;
|
|
389
474
|
if (live)
|
|
390
475
|
liveCache = live;
|
|
391
476
|
// Live sessions the transcript pool lacks become rows of their own, so the
|
|
@@ -393,6 +478,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
393
478
|
const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
|
|
394
479
|
agentsInPool = distinct(rows.map((r) => r.agent));
|
|
395
480
|
devicesInPool = distinct(rows.map((r) => r.machine ?? self));
|
|
481
|
+
// Both ends of the lineage seed the cycle: teams a row spawned, and teams a
|
|
482
|
+
// row belongs to. Teammate rows only carry `teamOrigin` when `c` is on, so
|
|
483
|
+
// with teams hidden this ranges over spawned teams alone — which is exactly
|
|
484
|
+
// the set whose rows are visible.
|
|
485
|
+
// Through safeTeamText: the cycle's values become `f.team`, which headerFor
|
|
486
|
+
// interpolates into the header and browserFilterToArgv copies into a command,
|
|
487
|
+
// and on a peer's row these strings are that machine's to choose.
|
|
488
|
+
teamsInPool = distinct([
|
|
489
|
+
...rows.map((r) => safeTeamText(r.spawnedTeam)),
|
|
490
|
+
...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
|
|
491
|
+
]);
|
|
396
492
|
const filtered = applyFilters(rows, live ?? new Map(), f, self);
|
|
397
493
|
cols = pickerColumnsFor(filtered);
|
|
398
494
|
cols.showHost = shouldShowHostColumn(f, live, filtered);
|
|
@@ -406,7 +502,9 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
406
502
|
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
|
|
407
503
|
matches: sessionMatchesQuery,
|
|
408
504
|
buildPreview,
|
|
409
|
-
headerFor
|
|
505
|
+
headerFor: (f) => unreachable.length > 0
|
|
506
|
+
? `${headerFor(f)} · ${chalk.yellow(`${unreachable.join(', ')}: unreachable`)}`
|
|
507
|
+
: headerFor(f),
|
|
410
508
|
helpFor,
|
|
411
509
|
enterHint: 'resume',
|
|
412
510
|
emptyMessage: 'No sessions match this filter.',
|
|
@@ -416,7 +514,11 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
416
514
|
c: (f) => ({ ...f, teams: !f.teams }),
|
|
417
515
|
a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
|
|
418
516
|
d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
|
|
419
|
-
|
|
517
|
+
t: (f) => ({ ...f, team: cycle(f.team, teamsInPool) }),
|
|
518
|
+
// Under an explicit --device scope every row is a peer's, and no peer cwd
|
|
519
|
+
// is under our process.cwd() — so narrowing to "this repo" could only ever
|
|
520
|
+
// empty the list. Returning the same reference makes the key a no-op.
|
|
521
|
+
p: (f) => (hosts ? f : { ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
|
|
420
522
|
w: (f) => ({ ...f, window: cycleWindow(f.window) }),
|
|
421
523
|
},
|
|
422
524
|
onKey: (name, f, _active, query) => {
|
|
@@ -31,18 +31,31 @@ export interface SessionPickerConfig {
|
|
|
31
31
|
}
|
|
32
32
|
/** Build a cached multi-line preview string for display in the session picker. */
|
|
33
33
|
export declare function buildPreview(session: SessionMeta): string;
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Body lines available from SessionMeta alone (no transcript parse) — used for
|
|
36
|
+
* remote / unindexed sessions so checklist progress still surfaces when the
|
|
37
|
+
* parser teammate (or a prior scan) has populated `session.todos`.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* The session's place in a team, from whichever end it sits at: the orchestrator
|
|
41
|
+
* that ran `agents teams create` (from the scan-derived `spawnedTeam`), or a
|
|
42
|
+
* teammate (from its `meta.json`, via `classifyTeamSession`). Empty for a session
|
|
43
|
+
* with no team involvement, which is the overwhelming majority.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately no live teammate counts: tallying a team means reading every
|
|
46
|
+
* record under the teams-agents dir, which runs to thousands of files on a busy
|
|
47
|
+
* machine — far too much for a pane that repaints as the cursor moves. The line
|
|
48
|
+
* names the command that does report them instead.
|
|
49
|
+
*/
|
|
50
|
+
export declare function formatTeamLineage(session: SessionMeta): string;
|
|
38
51
|
/**
|
|
39
52
|
* Unique directories the session touched, compact and human-readable.
|
|
40
|
-
* Prefer `session.
|
|
41
|
-
*
|
|
53
|
+
* Prefer `session.recentDirectoriesTouched` — the scan records it on the row, so
|
|
54
|
+
* it is present for remote rows whose transcript we can't parse here — otherwise
|
|
55
|
+
* derive from the file-change + tool paths already available.
|
|
42
56
|
*/
|
|
43
|
-
export declare function directoriesTouched(session:
|
|
57
|
+
export declare function directoriesTouched(session: SessionMeta, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
|
|
44
58
|
/** Relativize a file path to its parent dir, short enough for one preview line. */
|
|
45
59
|
export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
|
|
46
60
|
/** Show an interactive session picker and return the selected session with its action (resume or view). */
|
|
47
61
|
export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
|
|
48
|
-
export {};
|
|
@@ -10,6 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { truncate, humanDuration } from '../lib/format.js';
|
|
12
12
|
import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
|
|
13
|
+
import { safeTeamText } from '../lib/session/team-filter.js';
|
|
13
14
|
import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
|
|
14
15
|
import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
|
|
15
16
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
@@ -84,6 +85,12 @@ function sanitizeMeta(s) {
|
|
|
84
85
|
label: clean(s.label),
|
|
85
86
|
ticketId: clean(s.ticketId),
|
|
86
87
|
prUrl: clean(s.prUrl),
|
|
88
|
+
// A remote row's meta is peer-supplied JSON that parseRemoteList hands over
|
|
89
|
+
// unsanitized, and both of these reach the preview pane — so an escape
|
|
90
|
+
// sequence in a peer's plan text or path list would otherwise hit our TTY.
|
|
91
|
+
plan: clean(s.plan),
|
|
92
|
+
spawnedTeam: clean(s.spawnedTeam),
|
|
93
|
+
recentDirectoriesTouched: s.recentDirectoriesTouched?.map(sanitizeForTerminal),
|
|
87
94
|
todos,
|
|
88
95
|
};
|
|
89
96
|
}
|
|
@@ -205,15 +212,69 @@ function formatHeader(session, events) {
|
|
|
205
212
|
* remote / unindexed sessions so checklist progress still surfaces when the
|
|
206
213
|
* parser teammate (or a prior scan) has populated `session.todos`.
|
|
207
214
|
*/
|
|
215
|
+
/**
|
|
216
|
+
* The session's place in a team, from whichever end it sits at: the orchestrator
|
|
217
|
+
* that ran `agents teams create` (from the scan-derived `spawnedTeam`), or a
|
|
218
|
+
* teammate (from its `meta.json`, via `classifyTeamSession`). Empty for a session
|
|
219
|
+
* with no team involvement, which is the overwhelming majority.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately no live teammate counts: tallying a team means reading every
|
|
222
|
+
* record under the teams-agents dir, which runs to thousands of files on a busy
|
|
223
|
+
* machine — far too much for a pane that repaints as the cursor moves. The line
|
|
224
|
+
* names the command that does report them instead.
|
|
225
|
+
*/
|
|
226
|
+
export function formatTeamLineage(session) {
|
|
227
|
+
const origin = session.teamOrigin;
|
|
228
|
+
if (origin) {
|
|
229
|
+
const team = safeTeamText(origin.team);
|
|
230
|
+
const handleName = safeTeamText(origin.handle);
|
|
231
|
+
const mode = safeTeamText(origin.mode);
|
|
232
|
+
const parent = safeTeamText(origin.parentSessionId);
|
|
233
|
+
const parts = [chalk.white(team ?? 'team')];
|
|
234
|
+
const handle = handleName ? `teammate ${handleName}` : 'teammate';
|
|
235
|
+
parts.push(chalk.white(mode ? `${handle} (${mode})` : handle));
|
|
236
|
+
if (parent) {
|
|
237
|
+
parts.push(chalk.gray('spawned by ') + chalk.white(parent.slice(0, 8)));
|
|
238
|
+
}
|
|
239
|
+
return parts.join(chalk.gray(' · '));
|
|
240
|
+
}
|
|
241
|
+
const spawned = safeTeamText(session.spawnedTeam);
|
|
242
|
+
if (spawned) {
|
|
243
|
+
return (chalk.gray('spawned team ') +
|
|
244
|
+
chalk.white(spawned) +
|
|
245
|
+
chalk.gray(` · agents teams status ${spawned}`));
|
|
246
|
+
}
|
|
247
|
+
return '';
|
|
248
|
+
}
|
|
208
249
|
function formatMetaOnlyBody(session) {
|
|
209
250
|
const lines = [];
|
|
210
251
|
if (session.topic) {
|
|
211
252
|
lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), (process.stdout.columns || 80) - 12)));
|
|
212
253
|
}
|
|
254
|
+
const teamLine = formatTeamLineage(session);
|
|
255
|
+
if (teamLine) {
|
|
256
|
+
lines.push(chalk.cyan('Team: ') + teamLine);
|
|
257
|
+
}
|
|
213
258
|
const compact = formatTodoCompact(session.todos);
|
|
214
259
|
if (compact) {
|
|
215
260
|
lines.push(chalk.cyan('Todos: ') + chalk.white(compact));
|
|
216
261
|
}
|
|
262
|
+
const termWidth = process.stdout.columns || 80;
|
|
263
|
+
for (const l of session.todos?.items?.length ? renderTodos(session.todos.items, termWidth) : []) {
|
|
264
|
+
lines.push(l);
|
|
265
|
+
}
|
|
266
|
+
const dirs = session.recentDirectoriesTouched?.slice(0, DIRS_TOUCHED_MAX) ?? [];
|
|
267
|
+
if (dirs.length) {
|
|
268
|
+
lines.push(chalk.cyan('Dirs: ') + chalk.white(dirs.join(chalk.gray(' · '))));
|
|
269
|
+
}
|
|
270
|
+
// `plan` is the whole ExitPlanMode markdown, not a path — summarize it. The
|
|
271
|
+
// pane is height-clamped anyway, so pasting the blob would just push every
|
|
272
|
+
// other line out of view.
|
|
273
|
+
const planLines = session.plan?.trim() ? session.plan.trim().split('\n') : [];
|
|
274
|
+
if (planLines.length) {
|
|
275
|
+
const head = truncate(planLines[0].replace(/^#+\s*/, ''), termWidth - 20);
|
|
276
|
+
lines.push(chalk.cyan('Plan: ') + chalk.white(head) + chalk.gray(` · ${planLines.length} lines`));
|
|
277
|
+
}
|
|
217
278
|
if (lines.length === 0)
|
|
218
279
|
return '';
|
|
219
280
|
return lines.map(l => ' ' + l).join('\n');
|
|
@@ -360,6 +421,10 @@ function formatCompactPreview(events, session) {
|
|
|
360
421
|
if (dirs.length) {
|
|
361
422
|
lines.push(chalk.cyan('Dirs: ') + chalk.white(dirs.join(chalk.gray(' · '))));
|
|
362
423
|
}
|
|
424
|
+
const teamLine = formatTeamLineage(session);
|
|
425
|
+
if (teamLine) {
|
|
426
|
+
lines.push(chalk.cyan('Team: ') + teamLine);
|
|
427
|
+
}
|
|
363
428
|
const activity = [];
|
|
364
429
|
const changed = chg.created + chg.modified + chg.deleted;
|
|
365
430
|
if (changed) {
|
|
@@ -435,16 +500,27 @@ function isSubAgentTool(tool, command) {
|
|
|
435
500
|
}
|
|
436
501
|
/**
|
|
437
502
|
* Unique directories the session touched, compact and human-readable.
|
|
438
|
-
* Prefer `session.
|
|
439
|
-
*
|
|
503
|
+
* Prefer `session.recentDirectoriesTouched` — the scan records it on the row, so
|
|
504
|
+
* it is present for remote rows whose transcript we can't parse here — otherwise
|
|
505
|
+
* derive from the file-change + tool paths already available.
|
|
440
506
|
*/
|
|
441
507
|
export function directoriesTouched(session, events, changes) {
|
|
442
|
-
const fromMeta = session.
|
|
508
|
+
const fromMeta = session.recentDirectoriesTouched;
|
|
443
509
|
if (Array.isArray(fromMeta) && fromMeta.length > 0) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
510
|
+
// The scan stores ABSOLUTE paths, so they go through the same relativizer the
|
|
511
|
+
// derived branch below uses — otherwise adopting this field (which a remote row
|
|
512
|
+
// needs, having no transcript to derive from) would turn every local preview's
|
|
513
|
+
// `Dirs:` line from `src/lib · docs` into a column of full home-rooted paths.
|
|
514
|
+
const seen = new Set();
|
|
515
|
+
for (const raw of fromMeta) {
|
|
516
|
+
const dir = relativizeDir(sanitizeForTerminal(String(raw).trim()), session.cwd);
|
|
517
|
+
if (dir)
|
|
518
|
+
seen.add(dir);
|
|
519
|
+
if (seen.size >= DIRS_TOUCHED_MAX)
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
if (seen.size > 0)
|
|
523
|
+
return [...seen];
|
|
448
524
|
}
|
|
449
525
|
const counts = new Map();
|
|
450
526
|
const bump = (raw) => {
|
|
@@ -8,6 +8,7 @@ interface SessionFilterOptions {
|
|
|
8
8
|
project?: string;
|
|
9
9
|
all?: boolean;
|
|
10
10
|
teams?: boolean;
|
|
11
|
+
inTeam?: string;
|
|
11
12
|
routine?: boolean;
|
|
12
13
|
since?: string;
|
|
13
14
|
until?: string;
|
|
@@ -274,6 +275,24 @@ export declare function isBareBrowserListing(options: SessionsOptions, query: st
|
|
|
274
275
|
* `runSessionBrowser` picker cannot represent.
|
|
275
276
|
*/
|
|
276
277
|
export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
|
|
278
|
+
/**
|
|
279
|
+
* Whether a session belongs to `team`, from either end: it spawned the team, or
|
|
280
|
+
* it is one of the team's teammates. Case-insensitive, matching the SQL
|
|
281
|
+
* predicate behind `querySessions({ spawnedTeam })`.
|
|
282
|
+
*/
|
|
283
|
+
export declare function matchesTeam(session: SessionMeta, team: string): boolean;
|
|
284
|
+
/**
|
|
285
|
+
* The `team:<name>` badge for a session that SPAWNED a team — the orchestrator
|
|
286
|
+
* end of the lineage, from the scan-derived `spawnedTeam`. Returned as a plain
|
|
287
|
+
* (uncolored) string plus its display width so callers can reserve the width
|
|
288
|
+
* from the topic budget and color it as their own segment: folding it into the
|
|
289
|
+
* topic string would lose the color, since renderTopicCell strips ANSI and
|
|
290
|
+
* re-whitens every slice.
|
|
291
|
+
*/
|
|
292
|
+
export declare function teamBadge(session: SessionMeta): {
|
|
293
|
+
plain: string;
|
|
294
|
+
width: number;
|
|
295
|
+
};
|
|
277
296
|
/** One flat table row:
|
|
278
297
|
* shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
|
|
279
298
|
* `doing` is the live preview when running, else the topic. The `ticket` column
|