@phnx-labs/agents-cli 1.22.12 → 1.22.14
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 +23 -0
- package/README.md +5 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/secrets.js +10 -6
- package/dist/commands/sessions-optimize.d.ts +14 -0
- package/dist/commands/sessions-optimize.js +41 -0
- package/dist/commands/sessions.d.ts +15 -0
- package/dist/commands/sessions.js +81 -10
- package/dist/commands/workflows.js +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/resources/workflows.js +41 -21
- 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/db.d.ts +19 -0
- package/dist/lib/session/db.js +24 -0
- package/dist/lib/workflows.d.ts +22 -4
- package/dist/lib/workflows.js +79 -29
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.14
|
|
4
|
+
|
|
5
|
+
- **`agents secrets view <bundle> --reveal` now resolves a locked keychain bundle
|
|
6
|
+
interactively at a real terminal.** The command hardcoded `agentOnly: true` on
|
|
7
|
+
both reveal call sites (`commands/secrets.ts`), so an explicit human `--reveal`
|
|
8
|
+
on a locked bundle went through the broker-only path and errored with an unlock
|
|
9
|
+
hint instead of raising the one Touch ID sheet the human just asked for. The
|
|
10
|
+
`agentOnly` flag is now `isHeadlessSecretsContext() || !isInteractiveTerminal()`
|
|
11
|
+
— under an agent (`AGENTS_RUNTIME`) or with no TTY it stays broker-only and
|
|
12
|
+
never prompts, but a deliberate `--reveal` typed at an interactive terminal
|
|
13
|
+
resolves the value with a single biometric sheet. This mirrors the existing
|
|
14
|
+
`reveal && !isInteractiveTerminal()` guard a few lines up. `export --plaintext`
|
|
15
|
+
and `exec` are untouched — they stay intentionally silent for release/CI
|
|
16
|
+
scripts. Source: `apps/cli/src/commands/secrets.ts`.
|
|
17
|
+
|
|
18
|
+
- **`agents sessions optimize` — compact the FTS5 session search index.** The scanner delete+inserts a session's docs into the `tool_call_text` / `session_text` full-text indexes on every rescan, and FTS5 never merges the resulting segments on its own — so over thousands of sessions the `%_data` shadow tables bloat with hundreds of thousands of unmerged segments (observed on a real fleet box: 701 MB of index for ~69 MB of content, 196K segments) and `agents sessions` slows to a crawl / hangs. The new command runs FTS5 `'optimize'` (merge all segments, purge tombstones), non-destructively — no searchable content is lost. Reclaimed space frees as reusable pages inside the DB file (VACUUM with the daemon stopped returns it to disk); wireable to a weekly routine so the index never re-bloats. Source: `apps/cli/src/lib/session/db.ts` (`optimizeSessionSearchIndex`), `apps/cli/src/commands/sessions-optimize.ts`.
|
|
19
|
+
|
|
20
|
+
## 1.22.13
|
|
21
|
+
|
|
22
|
+
- **`agents sessions` accepts direct live-state flags and remains fleet-wide by default.** `--working`, `--idle`, `--waiting`, `--orphan`/`--orphaned`, `--crashed`, `--closed`, `--abandoned`, `--queued`, and `--unknown` each imply the live scan; multiple flags form a union. `--working` is narrower than `--active`: it excludes idle, waiting, and lifecycle-failure rows. Cross-device collection was already the default and stays that way; `--local` opts out, while `--all` continues to widen historical directory and time scope. Source: `apps/cli/src/commands/sessions.ts`, `apps/cli/src/commands/sessions.test.ts`.
|
|
23
|
+
|
|
24
|
+
- **Workflows: `name@source` disambiguation (Phase 5 packaging).** When two plugins (or a plugin and an extra repo) ship the same workflow name, pin the source: `agents run deploy@ship-tools` or `agents run workflow:deploy@social`. Bare names keep layered precedence (project > user > plugin > extra > system); a missing source returns no match instead of silently falling back. Source: `apps/cli/src/lib/workflows.ts`, `apps/cli/src/lib/resources/workflows.ts`.
|
|
25
|
+
|
|
3
26
|
## 1.22.12
|
|
4
27
|
|
|
5
28
|
- Store operational events in daily history directories, retain 7 days and at most 50 MiB automatically, and make `agents logs audit` use the `agents events --audit` reader.
|
package/README.md
CHANGED
|
@@ -292,6 +292,10 @@ Search is the past tense. `--active` is the present -- it infers what each runni
|
|
|
292
292
|
|
|
293
293
|
```bash
|
|
294
294
|
agents sessions --active # every live run across the fleet, with state
|
|
295
|
+
agents sessions --working # actively producing work (fleet-wide)
|
|
296
|
+
agents sessions --idle # stopped between turns (fleet-wide)
|
|
297
|
+
agents sessions --orphan # agent outlived its terminal client
|
|
298
|
+
agents sessions --crashed # terminal and agent disappeared uncleanly
|
|
295
299
|
agents sessions focus a1b2c3d4 # jump back into one — attach in place, or resume
|
|
296
300
|
```
|
|
297
301
|
|
|
@@ -322,7 +326,7 @@ Filters **stack** (they AND together), the active set shows in the header, and t
|
|
|
322
326
|
| --- | --- |
|
|
323
327
|
|  |  |
|
|
324
328
|
|
|
325
|
-
Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), or `
|
|
329
|
+
Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), `idle`, or a lifecycle state such as `orphaned`, `crashed`, `closed`, `abandoned`, `queued`, or `unknown`. Pass the matching flag (`--working`, `--idle`, `--waiting`, `--orphan`, `--crashed`, `--closed`, `--abandoned`, `--queued`, `--unknown`) directly; each implies `--active`, and several flags form a union. The fleet fan-out is already the default; `--local` opts out. `--all` instead widens historical directory and time scope. Rows also carry badges for the PR, worktree, and ticket. `agents sessions focus [id]` attaches the live pane in place -- the tmux split locally or over SSH, or its Ghostty tab -- and falls back to a fresh tab + resume when the terminal is gone.
|
|
326
330
|
|
|
327
331
|
Landing on a session cold? `agents sessions <id>` prints a catch-up digest: an inferred title, files changed grouped by directory (created / modified / deleted), a histogram of which tools did the work (including parsed Bash commands -- `git`, `npm`, `ffmpeg`, `ssh`, and so on), and the last test verdict -- the signals to reload a task in seconds.
|
|
328
332
|
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/secrets.js
CHANGED
|
@@ -18,7 +18,7 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
|
18
18
|
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
|
|
19
19
|
import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
|
|
20
20
|
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
21
|
-
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
21
|
+
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
22
22
|
import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
|
|
23
23
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
24
24
|
import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
|
|
@@ -1254,7 +1254,10 @@ export function registerSecretsCommands(program) {
|
|
|
1254
1254
|
const { env } = readAndResolveBundleEnv(bundle.name, {
|
|
1255
1255
|
caller: 'view --reveal --json',
|
|
1256
1256
|
keyMode: 'storage',
|
|
1257
|
-
|
|
1257
|
+
// An explicit human `--reveal` at a terminal is a deliberate value
|
|
1258
|
+
// access → resolve interactively (one Touch ID). Under an agent
|
|
1259
|
+
// (AGENTS_RUNTIME) or headless (no TTY) it stays broker-only.
|
|
1260
|
+
agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
|
|
1258
1261
|
});
|
|
1259
1262
|
for (const entry of entries) {
|
|
1260
1263
|
if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
|
|
@@ -1369,15 +1372,16 @@ export function registerSecretsCommands(program) {
|
|
|
1369
1372
|
console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
|
|
1370
1373
|
process.exit(1);
|
|
1371
1374
|
}
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
+
// An explicit human `view --reveal` at a terminal is a deliberate value
|
|
1376
|
+
// access: resolve interactively (one Touch ID) so a locked bundle shows
|
|
1377
|
+
// its values. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
|
|
1378
|
+
// stays broker-only and errors with the unlock hint — never a sheet.
|
|
1375
1379
|
const revealedValues = new Map();
|
|
1376
1380
|
if (reveal) {
|
|
1377
1381
|
const { env } = readAndResolveBundleEnv(bundle.name, {
|
|
1378
1382
|
caller: 'view --reveal',
|
|
1379
1383
|
keyMode: 'storage',
|
|
1380
|
-
agentOnly:
|
|
1384
|
+
agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
|
|
1381
1385
|
});
|
|
1382
1386
|
for (const entry of entries) {
|
|
1383
1387
|
if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions optimize` — compact the FTS5 session/tool-call search index.
|
|
3
|
+
*
|
|
4
|
+
* The scanner delete+inserts a session's docs into the `tool_call_text` /
|
|
5
|
+
* `session_text` FTS5 indexes on every rescan (and on every extractor-version
|
|
6
|
+
* bump), and FTS5 never merges the resulting segments on its own. Over thousands
|
|
7
|
+
* of sessions and re-index passes the `%_data` shadow tables bloat with hundreds
|
|
8
|
+
* of thousands of unmerged segments — gigabytes of index for tens of MB of
|
|
9
|
+
* actual content — and `agents sessions` queries slow to a crawl. This runs the
|
|
10
|
+
* FTS5 `'optimize'` command to merge every segment into one and purge tombstones.
|
|
11
|
+
* Non-destructive: no searchable content is lost.
|
|
12
|
+
*/
|
|
13
|
+
import type { Command } from 'commander';
|
|
14
|
+
export declare function registerSessionsOptimizeCommand(sessionsCmd: Command): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { optimizeSessionSearchIndex } from '../lib/session/db.js';
|
|
3
|
+
import { setHelpSections } from '../lib/help.js';
|
|
4
|
+
export function registerSessionsOptimizeCommand(sessionsCmd) {
|
|
5
|
+
const cmd = sessionsCmd
|
|
6
|
+
.command('optimize')
|
|
7
|
+
.description('Compact the session search index (FTS5), reclaiming bloat from repeated re-indexing')
|
|
8
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
9
|
+
.action((_opts, c) => {
|
|
10
|
+
const opts = c.optsWithGlobals();
|
|
11
|
+
const results = optimizeSessionSearchIndex();
|
|
12
|
+
if (opts.json) {
|
|
13
|
+
console.log(JSON.stringify(results, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
for (const r of results) {
|
|
17
|
+
const merged = Math.max(0, r.segmentsBefore - r.segmentsAfter);
|
|
18
|
+
console.log(` ${chalk.cyan(r.table)}: ${r.segmentsBefore} -> ${r.segmentsAfter} segments ` +
|
|
19
|
+
`(${chalk.green(merged)} merged)`);
|
|
20
|
+
}
|
|
21
|
+
console.log(chalk.gray(' Reclaimed space stays as reusable pages inside the file; VACUUM (daemon stopped) returns it to disk.'));
|
|
22
|
+
});
|
|
23
|
+
setHelpSections(cmd, {
|
|
24
|
+
examples: `
|
|
25
|
+
# Compact the session/tool search index once it has grown fragmented
|
|
26
|
+
agents sessions optimize
|
|
27
|
+
|
|
28
|
+
# Machine-readable segment counts
|
|
29
|
+
agents sessions optimize --json
|
|
30
|
+
|
|
31
|
+
# Wire it to a weekly routine so the index never re-bloats
|
|
32
|
+
agents routines add sessions-optimize --schedule "0 4 * * 0" --agent claude \\
|
|
33
|
+
--prompt "Run: agents sessions optimize"
|
|
34
|
+
`,
|
|
35
|
+
notes: `
|
|
36
|
+
- FTS5 appends a segment on every insert and tombstones every delete; the scanner delete+inserts a session's docs on each rescan and never self-merges, so \`tool_call_text_data\` / \`session_text_data\` bloat with unmerged segments — GBs of index for tens of MB of content, and queries slow down.
|
|
37
|
+
- This runs FTS5 \`'optimize'\`: it merges every segment into one and purges tombstones. Non-destructive — no searchable content is lost.
|
|
38
|
+
- Reclaimed space becomes reusable free pages inside the DB file. To return it to the OS, stop the daemon (\`agents routines stop\`) and run \`VACUUM\` against \`~/.agents/.history/sessions/sessions.db\`.
|
|
39
|
+
`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -46,6 +46,16 @@ interface SessionsOptions extends SessionFilterOptions {
|
|
|
46
46
|
flat?: boolean;
|
|
47
47
|
/** With --active: show only sessions waiting on user input; exit 1 if any. */
|
|
48
48
|
waiting?: boolean;
|
|
49
|
+
/** Live-state shorthand filters. Any one implies --active; several compose as OR. */
|
|
50
|
+
working?: boolean;
|
|
51
|
+
idle?: boolean;
|
|
52
|
+
orphan?: boolean;
|
|
53
|
+
orphaned?: boolean;
|
|
54
|
+
crashed?: boolean;
|
|
55
|
+
closed?: boolean;
|
|
56
|
+
abandoned?: boolean;
|
|
57
|
+
queued?: boolean;
|
|
58
|
+
unknown?: boolean;
|
|
49
59
|
/** Show only favorited (starred) sessions — the `f` key's flag twin. */
|
|
50
60
|
favorites?: boolean;
|
|
51
61
|
/** Enrich the listing with live glyphs/preview for running rows. Default on;
|
|
@@ -311,6 +321,11 @@ export declare function gatherActiveSessions(opts?: {
|
|
|
311
321
|
sessions: ActiveSession[];
|
|
312
322
|
remoteDeviceCount: number;
|
|
313
323
|
}>;
|
|
324
|
+
export type LiveStatusFilter = 'working' | 'idle' | 'waiting' | 'orphaned' | 'crashed' | 'closed' | 'abandoned' | 'queued' | 'unknown';
|
|
325
|
+
/** Match the status words users see, preserving activity's richer working signal. */
|
|
326
|
+
export declare function matchesLiveStatus(session: ActiveSession, status: LiveStatusFilter): boolean;
|
|
327
|
+
/** Resolve convenience flags once. Multiple flags intentionally form a union. */
|
|
328
|
+
export declare function requestedLiveStatuses(options: SessionsOptions): LiveStatusFilter[];
|
|
314
329
|
/**
|
|
315
330
|
* A bare interactive fleet listing — no query, no render/filter flag — that the
|
|
316
331
|
* `runSessionBrowser` picker can represent. The single predicate shared by the
|
|
@@ -61,6 +61,7 @@ import { registerSessionsImportCommand } from './sessions-import.js';
|
|
|
61
61
|
import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
|
|
62
62
|
import { registerSessionsBackfillCommand } from './sessions-backfill.js';
|
|
63
63
|
import { registerSessionsStatsCommand } from './sessions-stats.js';
|
|
64
|
+
import { registerSessionsOptimizeCommand } from './sessions-optimize.js';
|
|
64
65
|
import { runBrowserSessions } from '../lib/browser/sessions-list.js';
|
|
65
66
|
import { countToolProgramOccurrences, parseToolProgramCountClause, readToolIndexCoverage, searchToolCalls, TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, serializeToolSearchEnvelope, toolSearchRemoteReceiveBudget, } from '../lib/session/tool-index.js';
|
|
66
67
|
const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
|
|
@@ -1091,9 +1092,12 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
1091
1092
|
const merged = opts.favoritesOnly
|
|
1092
1093
|
? gathered.sessions.filter((s) => !!s.sessionId && listFavorites().has(s.sessionId))
|
|
1093
1094
|
: gathered.sessions;
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
const
|
|
1095
|
+
// Status flags form a union. --waiting additionally retains its scriptable
|
|
1096
|
+
// gate: exit non-zero when the union contains a session awaiting the user.
|
|
1097
|
+
const statusFiltered = opts.statuses?.length
|
|
1098
|
+
? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
|
|
1099
|
+
: merged;
|
|
1100
|
+
const sessions = statusFiltered;
|
|
1097
1101
|
if (asJson) {
|
|
1098
1102
|
// Resolve who is watching each local tmux pane before serializing: `viewingIn`
|
|
1099
1103
|
// is how a consumer distinguishes a session someone is looking at from one
|
|
@@ -1101,7 +1105,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
1101
1105
|
// scriptable path stays cheap — see enrichTmuxLocators.
|
|
1102
1106
|
await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
|
|
1103
1107
|
process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
|
|
1104
|
-
if (waitingOnly && sessions.
|
|
1108
|
+
if (waitingOnly && sessions.some(isAwaitingUser))
|
|
1105
1109
|
process.exitCode = 1;
|
|
1106
1110
|
return;
|
|
1107
1111
|
}
|
|
@@ -1136,9 +1140,40 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
1136
1140
|
if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
|
|
1137
1141
|
printCrossMachineTip();
|
|
1138
1142
|
// Scriptable gate: a non-zero exit when anything is waiting on the user.
|
|
1139
|
-
if (waitingOnly && sessions.
|
|
1143
|
+
if (waitingOnly && sessions.some(isAwaitingUser))
|
|
1140
1144
|
process.exitCode = 1;
|
|
1141
1145
|
}
|
|
1146
|
+
/** Match the status words users see, preserving activity's richer working signal. */
|
|
1147
|
+
export function matchesLiveStatus(session, status) {
|
|
1148
|
+
if (status === 'working')
|
|
1149
|
+
return session.activity === 'working' || (!session.activity && session.status === 'running');
|
|
1150
|
+
if (status === 'waiting')
|
|
1151
|
+
return isAwaitingUser(session);
|
|
1152
|
+
return session.status === status;
|
|
1153
|
+
}
|
|
1154
|
+
/** Resolve convenience flags once. Multiple flags intentionally form a union. */
|
|
1155
|
+
export function requestedLiveStatuses(options) {
|
|
1156
|
+
const statuses = [];
|
|
1157
|
+
if (options.working)
|
|
1158
|
+
statuses.push('working');
|
|
1159
|
+
if (options.idle)
|
|
1160
|
+
statuses.push('idle');
|
|
1161
|
+
if (options.waiting)
|
|
1162
|
+
statuses.push('waiting');
|
|
1163
|
+
if (options.orphan || options.orphaned)
|
|
1164
|
+
statuses.push('orphaned');
|
|
1165
|
+
if (options.crashed)
|
|
1166
|
+
statuses.push('crashed');
|
|
1167
|
+
if (options.closed)
|
|
1168
|
+
statuses.push('closed');
|
|
1169
|
+
if (options.abandoned)
|
|
1170
|
+
statuses.push('abandoned');
|
|
1171
|
+
if (options.queued)
|
|
1172
|
+
statuses.push('queued');
|
|
1173
|
+
if (options.unknown)
|
|
1174
|
+
statuses.push('unknown');
|
|
1175
|
+
return [...new Set(statuses)];
|
|
1176
|
+
}
|
|
1142
1177
|
/** Nudge shown when `--active` has no other machines to fold in. */
|
|
1143
1178
|
function printCrossMachineTip() {
|
|
1144
1179
|
console.log(chalk.gray("\nTip: include sessions from your other machines — register them with 'ag devices sync', then rerun. Use --local to skip."));
|
|
@@ -1204,6 +1239,22 @@ function canonicalSessionsCommand(query, options) {
|
|
|
1204
1239
|
const a = ['sessions'];
|
|
1205
1240
|
if (options.active)
|
|
1206
1241
|
a.push('--active');
|
|
1242
|
+
if (options.working)
|
|
1243
|
+
a.push('--working');
|
|
1244
|
+
if (options.idle)
|
|
1245
|
+
a.push('--idle');
|
|
1246
|
+
if (options.orphan || options.orphaned)
|
|
1247
|
+
a.push('--orphan');
|
|
1248
|
+
if (options.crashed)
|
|
1249
|
+
a.push('--crashed');
|
|
1250
|
+
if (options.closed)
|
|
1251
|
+
a.push('--closed');
|
|
1252
|
+
if (options.abandoned)
|
|
1253
|
+
a.push('--abandoned');
|
|
1254
|
+
if (options.queued)
|
|
1255
|
+
a.push('--queued');
|
|
1256
|
+
if (options.unknown)
|
|
1257
|
+
a.push('--unknown');
|
|
1207
1258
|
if (options.teams)
|
|
1208
1259
|
a.push('--teams');
|
|
1209
1260
|
if (options.inTeam)
|
|
@@ -1421,6 +1472,8 @@ async function sessionsAction(query, options,
|
|
|
1421
1472
|
*/
|
|
1422
1473
|
limitSource) {
|
|
1423
1474
|
const queryClauses = options.query ?? [];
|
|
1475
|
+
const liveStatuses = requestedLiveStatuses(options);
|
|
1476
|
+
const liveOnly = options.active === true || liveStatuses.length > 0;
|
|
1424
1477
|
const toolOnly = options.include?.split(',').map((role) => role.trim()).filter(Boolean).join(',') === 'tools';
|
|
1425
1478
|
const toolEvidenceMode = toolOnly;
|
|
1426
1479
|
if (options.count && !toolOnly) {
|
|
@@ -1551,7 +1604,7 @@ limitSource) {
|
|
|
1551
1604
|
// keeps the legacy per-host stream (each remote's raw stdout under a
|
|
1552
1605
|
// `── host ──` banner). With --active, the hosts are folded into the merged
|
|
1553
1606
|
// machine-grouped view instead (handled below).
|
|
1554
|
-
if (options.host && options.host.length > 0 && !
|
|
1607
|
+
if (options.host && options.host.length > 0 && !liveOnly && !toolEvidenceMode) {
|
|
1555
1608
|
// --local means "skip the SSH fan-out"; --host means "look only over there".
|
|
1556
1609
|
// Together they ask for a peer's sessions without dialing the peer, which can
|
|
1557
1610
|
// only ever be empty — so say that instead of rendering a blank list.
|
|
@@ -1591,7 +1644,7 @@ limitSource) {
|
|
|
1591
1644
|
await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local });
|
|
1592
1645
|
return;
|
|
1593
1646
|
}
|
|
1594
|
-
if (
|
|
1647
|
+
if (liveOnly) {
|
|
1595
1648
|
// The running view is built from the live scan, which carries no team lineage
|
|
1596
1649
|
// (that comes off the transcript index and the teams meta dir), so --in-team
|
|
1597
1650
|
// has nothing to match on here. Say so rather than ignoring the flag.
|
|
@@ -1606,7 +1659,7 @@ limitSource) {
|
|
|
1606
1659
|
// --since seeds the window; --until / --project (no browser field) or a
|
|
1607
1660
|
// multi-host scope fall through to the static dump that already honors them.
|
|
1608
1661
|
if (useInteractiveBrowser(options) &&
|
|
1609
|
-
|
|
1662
|
+
liveStatuses.length === 0 &&
|
|
1610
1663
|
!options.until &&
|
|
1611
1664
|
!options.project &&
|
|
1612
1665
|
!options.sort &&
|
|
@@ -1630,6 +1683,7 @@ limitSource) {
|
|
|
1630
1683
|
local: forceLocal,
|
|
1631
1684
|
hosts: options.host,
|
|
1632
1685
|
favoritesOnly: options.favorites === true,
|
|
1686
|
+
statuses: liveStatuses,
|
|
1633
1687
|
});
|
|
1634
1688
|
return;
|
|
1635
1689
|
}
|
|
@@ -3617,7 +3671,16 @@ export function registerSessionsCommands(program) {
|
|
|
3617
3671
|
.option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
|
|
3618
3672
|
.option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
|
|
3619
3673
|
.option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
|
|
3620
|
-
.option('--
|
|
3674
|
+
.option('--working', 'Show live sessions currently doing work (implies --active)')
|
|
3675
|
+
.option('--idle', 'Show live sessions that have stopped between turns (implies --active)')
|
|
3676
|
+
.option('--waiting', 'Show live sessions waiting on your input; exits non-zero if any (implies --active)')
|
|
3677
|
+
.option('--orphan', 'Show live sessions whose process outlived its terminal client (implies --active)')
|
|
3678
|
+
.option('--orphaned', 'Alias for --orphan')
|
|
3679
|
+
.option('--crashed', 'Show sessions whose terminal disappeared with the process (implies --active)')
|
|
3680
|
+
.option('--closed', 'Show recently observed sessions whose process exited normally (implies --active)')
|
|
3681
|
+
.option('--abandoned', 'Show sessions with no transcript progress for the abandonment window (implies --active)')
|
|
3682
|
+
.option('--queued', 'Show queued sessions that have not started running (implies --active)')
|
|
3683
|
+
.option('--unknown', 'Show sessions whose live state cannot be determined (implies --active)')
|
|
3621
3684
|
.option('--favorites', 'Show only favorited (starred) sessions — star them with `*` in the browser or `agents sessions favorite <id>`')
|
|
3622
3685
|
.option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
|
|
3623
3686
|
.option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
|
|
@@ -3645,6 +3708,12 @@ export function registerSessionsCommands(program) {
|
|
|
3645
3708
|
# Show only what's running right now (terminals, teams, cloud, headless)
|
|
3646
3709
|
agents sessions --active
|
|
3647
3710
|
|
|
3711
|
+
# Filter the live fleet by the status word shown in the roster
|
|
3712
|
+
agents sessions --working
|
|
3713
|
+
agents sessions --idle
|
|
3714
|
+
agents sessions --orphan
|
|
3715
|
+
agents sessions --crashed
|
|
3716
|
+
|
|
3648
3717
|
# --- Session lifecycle (one verb per intent) ---
|
|
3649
3718
|
# Jump to a live session (attach its terminal, or open a tab + resume)
|
|
3650
3719
|
agents sessions focus a1b2c3d4
|
|
@@ -3703,7 +3772,8 @@ export function registerSessionsCommands(program) {
|
|
|
3703
3772
|
detach <id> interactive → headless continuation
|
|
3704
3773
|
attach <id> headless → interactive in this terminal
|
|
3705
3774
|
resume [query] multi-select history → open tabs (or run --resume <id>)
|
|
3706
|
-
- The interactive listing
|
|
3775
|
+
- The interactive listing and every live-status flag fold in your other online machines automatically (live over SSH, no sync) — each row is labelled by host, this machine first. Use --local to skip the fan-out; single-id lookups stay local.
|
|
3776
|
+
- --all is not a device flag: it widens historical directory and time filters. Fleet collection is already the default. A status flag (--working/--idle/--waiting/--orphan/--crashed/--closed/--abandoned/--queued/--unknown) implies --active; combine status flags for a union.
|
|
3707
3777
|
- --host runs the query on the remote's own index over SSH (host alias or user@host); repeat or pass several to fan out. SSH access is the only auth.
|
|
3708
3778
|
- --in-team matches both ends of the lineage: the session that ran 'agents teams create/add', and (with --teams) that team's teammates. In the interactive list, 't' cycles the same filter over the teams in view.
|
|
3709
3779
|
- --include and --exclude are mutually exclusive.
|
|
@@ -3742,6 +3812,7 @@ export function registerSessionsCommands(program) {
|
|
|
3742
3812
|
registerSessionsMigrationsCommand(sessionsCmd);
|
|
3743
3813
|
registerSessionsBackfillCommand(sessionsCmd);
|
|
3744
3814
|
registerSessionsStatsCommand(sessionsCmd);
|
|
3815
|
+
registerSessionsOptimizeCommand(sessionsCmd);
|
|
3745
3816
|
// Observe-umbrella alias (Phase 3): roster → sessions --active.
|
|
3746
3817
|
registerSessionsObserveAliases(program);
|
|
3747
3818
|
}
|
|
@@ -30,6 +30,7 @@ Structure:
|
|
|
30
30
|
plugins/ optional: plugin bundles scoped to this workflow
|
|
31
31
|
|
|
32
32
|
Resolution: project > user > plugin (plugins/*/workflows/) > extra > system.
|
|
33
|
+
Pin a source with name@plugin or name@extra-alias (optional workflow: prefix).
|
|
33
34
|
|
|
34
35
|
Note: agents run defaults to --mode plan (read-only). For workflows that
|
|
35
36
|
write files, post comments, or otherwise mutate state, pass --mode edit or
|
|
Binary file
|
|
Binary file
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { getProjectAgentsDir, getUserWorkflowsDir, getSystemWorkflowsDir, getEnabledExtraRepos, } from '../state.js';
|
|
11
|
-
import { parseWorkflowFrontmatter, countWorkflowSubagents, listPluginWorkflowDirs,
|
|
11
|
+
import { parseWorkflowFrontmatter, countWorkflowSubagents, listPluginWorkflowDirs, resolveWorkflowRef, parseWorkflowRef, } from '../workflows.js';
|
|
12
12
|
function getLayerDirs(cwd) {
|
|
13
13
|
const projectDir = getProjectAgentsDir(cwd);
|
|
14
14
|
const extraRepos = getEnabledExtraRepos();
|
|
@@ -47,6 +47,27 @@ function orderedWorkflowSearchDirs(cwd) {
|
|
|
47
47
|
out.push({ dir: dirs.system, layer: 'system' });
|
|
48
48
|
return out;
|
|
49
49
|
}
|
|
50
|
+
/** Map a resolved absolute workflow dir to its origin layer (best-effort). */
|
|
51
|
+
function layerForWorkflowPath(workflowPath, cwd) {
|
|
52
|
+
const abs = path.resolve(workflowPath);
|
|
53
|
+
const under = (root) => {
|
|
54
|
+
const r = path.resolve(root);
|
|
55
|
+
return abs === r || abs.startsWith(r + path.sep);
|
|
56
|
+
};
|
|
57
|
+
const projectDir = getProjectAgentsDir(cwd);
|
|
58
|
+
if (projectDir && under(path.join(projectDir, 'workflows')))
|
|
59
|
+
return 'project';
|
|
60
|
+
if (under(getUserWorkflowsDir()))
|
|
61
|
+
return 'user';
|
|
62
|
+
if (under(getSystemWorkflowsDir()))
|
|
63
|
+
return 'system';
|
|
64
|
+
for (const extra of getEnabledExtraRepos()) {
|
|
65
|
+
if (under(path.join(extra.dir, 'workflows')))
|
|
66
|
+
return 'system';
|
|
67
|
+
}
|
|
68
|
+
// Plugin marketplaces (and anything else plugin-shaped).
|
|
69
|
+
return 'plugin';
|
|
70
|
+
}
|
|
50
71
|
class WorkflowsHandlerImpl {
|
|
51
72
|
kind = 'workflow';
|
|
52
73
|
listAll(_agent, cwd) {
|
|
@@ -76,27 +97,26 @@ class WorkflowsHandlerImpl {
|
|
|
76
97
|
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
77
98
|
}
|
|
78
99
|
resolve(_agent, name, cwd) {
|
|
79
|
-
//
|
|
80
|
-
|
|
100
|
+
// Delegate to resolveWorkflowRef so bare, workflow:, and name@source share one path.
|
|
101
|
+
const workflowPath = resolveWorkflowRef(name, cwd ?? process.cwd());
|
|
102
|
+
if (!workflowPath)
|
|
81
103
|
return null;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
return null;
|
|
104
|
+
const fm = parseWorkflowFrontmatter(workflowPath);
|
|
105
|
+
if (!fm)
|
|
106
|
+
return null;
|
|
107
|
+
const parsed = parseWorkflowRef(name);
|
|
108
|
+
const displayName = parsed?.name ?? path.basename(workflowPath);
|
|
109
|
+
return {
|
|
110
|
+
name: displayName,
|
|
111
|
+
item: {
|
|
112
|
+
name: fm.name || displayName,
|
|
113
|
+
description: fm.description,
|
|
114
|
+
model: fm.model,
|
|
115
|
+
subagentCount: countWorkflowSubagents(workflowPath),
|
|
116
|
+
},
|
|
117
|
+
layer: layerForWorkflowPath(workflowPath, cwd),
|
|
118
|
+
path: workflowPath,
|
|
119
|
+
};
|
|
100
120
|
}
|
|
101
121
|
sync(_agent, _versionHome, _cwd) {
|
|
102
122
|
// Version-home copies are written by syncResourcesToVersion in versions.ts.
|
|
Binary file
|
|
Binary file
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -120,6 +120,25 @@ export interface QueryOptions {
|
|
|
120
120
|
export declare function getDB(): Database.Database;
|
|
121
121
|
/** Close the cached database connection. */
|
|
122
122
|
export declare function closeDB(): void;
|
|
123
|
+
export interface FtsOptimizeResult {
|
|
124
|
+
table: string;
|
|
125
|
+
segmentsBefore: number;
|
|
126
|
+
segmentsAfter: number;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Compact the session + tool-call full-text-search indexes.
|
|
130
|
+
*
|
|
131
|
+
* FTS5 appends a new segment on every insert and leaves a tombstone on every
|
|
132
|
+
* delete. The scanner delete+inserts a session's docs on each rescan (and on
|
|
133
|
+
* every extractor-version bump), and FTS5 never merges those segments on its
|
|
134
|
+
* own — so `tool_call_text_data` / `session_text_data` accumulate hundreds of
|
|
135
|
+
* thousands of unmerged segments, gigabytes of index for tens of MB of content,
|
|
136
|
+
* and `agents sessions` queries slow to a crawl. The FTS5 `'optimize'` command
|
|
137
|
+
* merges every segment into one and purges tombstones. Non-destructive: no
|
|
138
|
+
* searchable content is lost. Reclaimed space becomes reusable free pages inside
|
|
139
|
+
* the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
|
|
140
|
+
*/
|
|
141
|
+
export declare function optimizeSessionSearchIndex(): FtsOptimizeResult[];
|
|
123
142
|
/**
|
|
124
143
|
* Try to claim the right to run the incremental scan. Returns true if this
|
|
125
144
|
* process should proceed with scanning, false if another live process is
|
package/dist/lib/session/db.js
CHANGED
|
@@ -820,6 +820,30 @@ export function closeDB() {
|
|
|
820
820
|
cachedStmts = {};
|
|
821
821
|
}
|
|
822
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Compact the session + tool-call full-text-search indexes.
|
|
825
|
+
*
|
|
826
|
+
* FTS5 appends a new segment on every insert and leaves a tombstone on every
|
|
827
|
+
* delete. The scanner delete+inserts a session's docs on each rescan (and on
|
|
828
|
+
* every extractor-version bump), and FTS5 never merges those segments on its
|
|
829
|
+
* own — so `tool_call_text_data` / `session_text_data` accumulate hundreds of
|
|
830
|
+
* thousands of unmerged segments, gigabytes of index for tens of MB of content,
|
|
831
|
+
* and `agents sessions` queries slow to a crawl. The FTS5 `'optimize'` command
|
|
832
|
+
* merges every segment into one and purges tombstones. Non-destructive: no
|
|
833
|
+
* searchable content is lost. Reclaimed space becomes reusable free pages inside
|
|
834
|
+
* the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
|
|
835
|
+
*/
|
|
836
|
+
export function optimizeSessionSearchIndex() {
|
|
837
|
+
const db = getDB();
|
|
838
|
+
// Hardcoded literals — never interpolate caller input into an identifier.
|
|
839
|
+
const tables = ['tool_call_text', 'session_text'];
|
|
840
|
+
const segments = (table) => db.prepare(`SELECT count(*) AS n FROM ${table}_data`).get().n;
|
|
841
|
+
return tables.map((table) => {
|
|
842
|
+
const segmentsBefore = segments(table);
|
|
843
|
+
db.prepare(`INSERT INTO ${table}(${table}) VALUES('optimize')`).run();
|
|
844
|
+
return { table, segmentsBefore, segmentsAfter: segments(table) };
|
|
845
|
+
});
|
|
846
|
+
}
|
|
823
847
|
// ---------------------------------------------------------------------------
|
|
824
848
|
// Scan coordinator — prevents concurrent full scans across processes
|
|
825
849
|
// ---------------------------------------------------------------------------
|
package/dist/lib/workflows.d.ts
CHANGED
|
@@ -318,19 +318,37 @@ export declare function grokWorkflowMarker(filePath: string): string | null;
|
|
|
318
318
|
* runnable via `agents run <name>` without a separate install into
|
|
319
319
|
* ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
|
|
320
320
|
* plugins beat user/system plugins (same first-hit-wins as other layers).
|
|
321
|
+
*
|
|
322
|
+
* Pass `pluginName` to restrict to one plugin (for `name@plugin` resolution).
|
|
321
323
|
*/
|
|
322
|
-
export declare function listPluginWorkflowDirs(cwd?: string): string[];
|
|
324
|
+
export declare function listPluginWorkflowDirs(cwd?: string, pluginName?: string): string[];
|
|
323
325
|
/**
|
|
324
|
-
* True when `ref` is a single bare workflow
|
|
325
|
-
* Name lookup must not path-join multi-segment
|
|
326
|
+
* True when `ref` is a single bare workflow / source identifier (no path
|
|
327
|
+
* separators, no `..`, no `@`). Name lookup must not path-join multi-segment
|
|
328
|
+
* or traversal refs into search roots. `name@source` is parsed separately.
|
|
326
329
|
*/
|
|
327
330
|
export declare function isBareWorkflowName(ref: string): boolean;
|
|
331
|
+
/** Parsed `agents run` workflow reference (docs/07-entrypoints). */
|
|
332
|
+
export interface ParsedWorkflowRef {
|
|
333
|
+
/** Workflow directory name (WORKFLOW.md parent). */
|
|
334
|
+
name: string;
|
|
335
|
+
/**
|
|
336
|
+
* When set (`name@source`), pin resolution to that source only:
|
|
337
|
+
* a plugin name, or an enabled extra-repo alias.
|
|
338
|
+
*/
|
|
339
|
+
source?: string;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Parse a workflow run target: optional `workflow:` type prefix and optional
|
|
343
|
+
* `@source` pin. Returns null when the form is not a valid name lookup.
|
|
344
|
+
*/
|
|
345
|
+
export declare function parseWorkflowRef(ref: string): ParsedWorkflowRef | null;
|
|
328
346
|
/**
|
|
329
347
|
* Resolve an `agents run <workflow>` reference.
|
|
330
348
|
*
|
|
331
349
|
* Directories are accepted anywhere on disk when they contain WORKFLOW.md.
|
|
332
350
|
* Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
|
|
333
|
-
*
|
|
351
|
+
* Pin a source with `name@plugin` or `name@extra-alias` (optional `workflow:` prefix).
|
|
334
352
|
*/
|
|
335
353
|
export declare function resolveWorkflowRef(ref: string, cwd?: string): string | null;
|
|
336
354
|
/**
|
package/dist/lib/workflows.js
CHANGED
|
@@ -522,15 +522,8 @@ function resolveWorkflowPath(ref, cwd) {
|
|
|
522
522
|
const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
|
|
523
523
|
return isWorkflowDir(candidate) ? candidate : null;
|
|
524
524
|
}
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
* extra). Used by name resolution and listing so a plugin-packaged workflow is
|
|
528
|
-
* runnable via `agents run <name>` without a separate install into
|
|
529
|
-
* ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
|
|
530
|
-
* plugins beat user/system plugins (same first-hit-wins as other layers).
|
|
531
|
-
*/
|
|
532
|
-
export function listPluginWorkflowDirs(cwd = process.cwd()) {
|
|
533
|
-
const pluginRoots = [];
|
|
525
|
+
/** Plugin-root marketplace dirs in project → user → system → extra order. */
|
|
526
|
+
function pluginMarketplaceDirs(cwd) {
|
|
534
527
|
const pluginsDirs = [];
|
|
535
528
|
const projectPlugins = getProjectPluginsDir(cwd);
|
|
536
529
|
if (projectPlugins)
|
|
@@ -539,7 +532,34 @@ export function listPluginWorkflowDirs(cwd = process.cwd()) {
|
|
|
539
532
|
for (const extra of getEnabledExtraRepos()) {
|
|
540
533
|
pluginsDirs.push(path.join(extra.dir, 'plugins'));
|
|
541
534
|
}
|
|
542
|
-
|
|
535
|
+
return pluginsDirs;
|
|
536
|
+
}
|
|
537
|
+
function isPluginDirectory(pluginRoot, entry) {
|
|
538
|
+
if (entry.name.startsWith('.'))
|
|
539
|
+
return false;
|
|
540
|
+
let isDir = entry.isDirectory();
|
|
541
|
+
if (!isDir && entry.isSymbolicLink()) {
|
|
542
|
+
try {
|
|
543
|
+
isDir = fs.statSync(pluginRoot).isDirectory();
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
isDir = false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return isDir;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Plugin `workflows/` directories in discovery order (project → user → system →
|
|
553
|
+
* extra). Used by name resolution and listing so a plugin-packaged workflow is
|
|
554
|
+
* runnable via `agents run <name>` without a separate install into
|
|
555
|
+
* ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
|
|
556
|
+
* plugins beat user/system plugins (same first-hit-wins as other layers).
|
|
557
|
+
*
|
|
558
|
+
* Pass `pluginName` to restrict to one plugin (for `name@plugin` resolution).
|
|
559
|
+
*/
|
|
560
|
+
export function listPluginWorkflowDirs(cwd = process.cwd(), pluginName) {
|
|
561
|
+
const pluginRoots = [];
|
|
562
|
+
for (const pluginsDir of pluginMarketplaceDirs(cwd)) {
|
|
543
563
|
if (!fs.existsSync(pluginsDir))
|
|
544
564
|
continue;
|
|
545
565
|
let entries;
|
|
@@ -550,20 +570,10 @@ export function listPluginWorkflowDirs(cwd = process.cwd()) {
|
|
|
550
570
|
continue;
|
|
551
571
|
}
|
|
552
572
|
for (const entry of entries) {
|
|
553
|
-
if (entry.name
|
|
573
|
+
if (pluginName !== undefined && entry.name !== pluginName)
|
|
554
574
|
continue;
|
|
555
|
-
// Directories and symlinks-to-directories (plugin marketplaces often symlink).
|
|
556
575
|
const pluginRoot = path.join(pluginsDir, entry.name);
|
|
557
|
-
|
|
558
|
-
if (!isDir && entry.isSymbolicLink()) {
|
|
559
|
-
try {
|
|
560
|
-
isDir = fs.statSync(pluginRoot).isDirectory();
|
|
561
|
-
}
|
|
562
|
-
catch {
|
|
563
|
-
isDir = false;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
if (!isDir)
|
|
576
|
+
if (!isPluginDirectory(pluginRoot, entry))
|
|
567
577
|
continue;
|
|
568
578
|
const workflowsDir = path.join(pluginRoot, 'workflows');
|
|
569
579
|
if (fs.existsSync(workflowsDir))
|
|
@@ -573,8 +583,9 @@ export function listPluginWorkflowDirs(cwd = process.cwd()) {
|
|
|
573
583
|
return pluginRoots;
|
|
574
584
|
}
|
|
575
585
|
/**
|
|
576
|
-
* True when `ref` is a single bare workflow
|
|
577
|
-
* Name lookup must not path-join multi-segment
|
|
586
|
+
* True when `ref` is a single bare workflow / source identifier (no path
|
|
587
|
+
* separators, no `..`, no `@`). Name lookup must not path-join multi-segment
|
|
588
|
+
* or traversal refs into search roots. `name@source` is parsed separately.
|
|
578
589
|
*/
|
|
579
590
|
export function isBareWorkflowName(ref) {
|
|
580
591
|
if (!ref || ref === '.' || ref === '..')
|
|
@@ -583,26 +594,65 @@ export function isBareWorkflowName(ref) {
|
|
|
583
594
|
return false;
|
|
584
595
|
if (ref.includes('..'))
|
|
585
596
|
return false;
|
|
597
|
+
if (ref.includes('@'))
|
|
598
|
+
return false;
|
|
586
599
|
// Reject absolute paths (posix or Windows).
|
|
587
600
|
if (path.isAbsolute(ref))
|
|
588
601
|
return false;
|
|
589
602
|
return path.basename(ref) === ref;
|
|
590
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* Parse a workflow run target: optional `workflow:` type prefix and optional
|
|
606
|
+
* `@source` pin. Returns null when the form is not a valid name lookup.
|
|
607
|
+
*/
|
|
608
|
+
export function parseWorkflowRef(ref) {
|
|
609
|
+
let r = ref.trim();
|
|
610
|
+
if (r.startsWith('workflow:'))
|
|
611
|
+
r = r.slice('workflow:'.length);
|
|
612
|
+
if (!r)
|
|
613
|
+
return null;
|
|
614
|
+
const at = r.lastIndexOf('@');
|
|
615
|
+
if (at > 0) {
|
|
616
|
+
const name = r.slice(0, at);
|
|
617
|
+
const source = r.slice(at + 1);
|
|
618
|
+
if (!isBareWorkflowName(name) || !isBareWorkflowName(source))
|
|
619
|
+
return null;
|
|
620
|
+
return { name, source };
|
|
621
|
+
}
|
|
622
|
+
if (!isBareWorkflowName(r))
|
|
623
|
+
return null;
|
|
624
|
+
return { name: r };
|
|
625
|
+
}
|
|
591
626
|
/**
|
|
592
627
|
* Resolve an `agents run <workflow>` reference.
|
|
593
628
|
*
|
|
594
629
|
* Directories are accepted anywhere on disk when they contain WORKFLOW.md.
|
|
595
630
|
* Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
|
|
596
|
-
*
|
|
631
|
+
* Pin a source with `name@plugin` or `name@extra-alias` (optional `workflow:` prefix).
|
|
597
632
|
*/
|
|
598
633
|
export function resolveWorkflowRef(ref, cwd = process.cwd()) {
|
|
599
634
|
const direct = resolveWorkflowPath(ref, cwd);
|
|
600
635
|
if (direct)
|
|
601
636
|
return direct;
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if (!isBareWorkflowName(ref))
|
|
637
|
+
const parsed = parseWorkflowRef(ref);
|
|
638
|
+
if (!parsed)
|
|
605
639
|
return null;
|
|
640
|
+
// Source-qualified: only that plugin or extra-repo workflows/ (no layered fallback).
|
|
641
|
+
if (parsed.source) {
|
|
642
|
+
for (const dir of listPluginWorkflowDirs(cwd, parsed.source)) {
|
|
643
|
+
const workflowPath = path.join(dir, parsed.name);
|
|
644
|
+
if (isWorkflowDir(workflowPath))
|
|
645
|
+
return workflowPath;
|
|
646
|
+
}
|
|
647
|
+
for (const extra of getEnabledExtraRepos()) {
|
|
648
|
+
if (extra.alias !== parsed.source)
|
|
649
|
+
continue;
|
|
650
|
+
const workflowPath = path.join(extra.dir, 'workflows', parsed.name);
|
|
651
|
+
if (isWorkflowDir(workflowPath))
|
|
652
|
+
return workflowPath;
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
606
656
|
const projectAgentsDir = getProjectAgentsDir(cwd);
|
|
607
657
|
const searchDirs = [
|
|
608
658
|
...(projectAgentsDir ? [path.join(projectAgentsDir, 'workflows')] : []),
|
|
@@ -612,7 +662,7 @@ export function resolveWorkflowRef(ref, cwd = process.cwd()) {
|
|
|
612
662
|
getSystemWorkflowsDir(),
|
|
613
663
|
];
|
|
614
664
|
for (const dir of searchDirs) {
|
|
615
|
-
const workflowPath = path.join(dir,
|
|
665
|
+
const workflowPath = path.join(dir, parsed.name);
|
|
616
666
|
if (isWorkflowDir(workflowPath))
|
|
617
667
|
return workflowPath;
|
|
618
668
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.14",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|