@phnx-labs/agents-cli 1.21.2 → 1.22.0
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 +101 -0
- package/README.md +32 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/computer-actions.d.ts +4 -0
- package/dist/commands/computer-actions.js +35 -0
- package/dist/commands/computer.js +4 -2
- package/dist/commands/exec.d.ts +27 -0
- package/dist/commands/exec.js +123 -6
- package/dist/commands/models.js +36 -1
- package/dist/commands/perf.d.ts +16 -0
- package/dist/commands/perf.js +11 -1
- package/dist/commands/projects.d.ts +11 -1
- package/dist/commands/projects.js +38 -4
- package/dist/commands/sessions-backfill.d.ts +32 -0
- package/dist/commands/sessions-backfill.js +186 -0
- package/dist/commands/sessions-picker.js +17 -2
- package/dist/commands/sessions.d.ts +22 -1
- package/dist/commands/sessions.js +331 -18
- package/dist/commands/teams.js +1 -1
- package/dist/commands/worktree.d.ts +3 -3
- package/dist/commands/worktree.js +35 -4
- package/dist/index.js +8 -0
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/computer/dispatch.d.ts +3 -1
- package/dist/lib/computer/dispatch.js +10 -2
- package/dist/lib/daemon.d.ts +5 -1
- package/dist/lib/daemon.js +63 -14
- package/dist/lib/devices/resolve-target.d.ts +6 -0
- package/dist/lib/devices/resolve-target.js +9 -3
- package/dist/lib/event-stream.d.ts +2 -0
- package/dist/lib/event-stream.js +3 -0
- package/dist/lib/events.d.ts +3 -1
- package/dist/lib/events.js +4 -2
- package/dist/lib/exec.js +39 -8
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +36 -0
- package/dist/lib/hooks/profile.js +1 -14
- package/dist/lib/hosts/dispatch.d.ts +12 -0
- package/dist/lib/hosts/dispatch.js +23 -6
- package/dist/lib/hosts/reconnect.d.ts +38 -0
- package/dist/lib/hosts/reconnect.js +85 -4
- package/dist/lib/hosts/run-target.js +14 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/model-tiers.d.ts +54 -0
- package/dist/lib/model-tiers.js +229 -0
- package/dist/lib/models.d.ts +3 -0
- package/dist/lib/models.js +44 -7
- package/dist/lib/percentile.d.ts +12 -0
- package/dist/lib/percentile.js +24 -0
- package/dist/lib/perf/db.d.ts +1 -2
- package/dist/lib/perf/db.js +2 -14
- package/dist/lib/plugins.js +12 -1
- package/dist/lib/pricing/prices.json +16 -1
- package/dist/lib/project-focus.d.ts +42 -0
- package/dist/lib/project-focus.js +80 -0
- package/dist/lib/project-schedule.d.ts +75 -0
- package/dist/lib/project-schedule.js +110 -0
- package/dist/lib/redact.d.ts +2 -0
- package/dist/lib/redact.js +22 -0
- package/dist/lib/remote-agents-json.d.ts +2 -0
- package/dist/lib/remote-agents-json.js +3 -3
- package/dist/lib/resources.d.ts +16 -0
- package/dist/lib/resources.js +25 -14
- package/dist/lib/rotate.d.ts +84 -1
- package/dist/lib/rotate.js +155 -5
- package/dist/lib/routines.js +1 -14
- package/dist/lib/runner.d.ts +4 -2
- package/dist/lib/runner.js +21 -5
- 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/bash-command.js +60 -9
- package/dist/lib/session/db.d.ts +22 -1
- package/dist/lib/session/db.js +516 -24
- package/dist/lib/session/discover.d.ts +68 -7
- package/dist/lib/session/discover.js +186 -84
- package/dist/lib/session/highlights.d.ts +24 -4
- package/dist/lib/session/highlights.js +52 -7
- package/dist/lib/session/parse.d.ts +8 -1
- package/dist/lib/session/parse.js +102 -35
- package/dist/lib/session/prompt.d.ts +19 -0
- package/dist/lib/session/prompt.js +43 -0
- package/dist/lib/session/remote-list.d.ts +71 -0
- package/dist/lib/session/remote-list.js +410 -2
- package/dist/lib/session/shell-programs.d.ts +15 -0
- package/dist/lib/session/shell-programs.js +359 -0
- package/dist/lib/session/tool-calls.d.ts +88 -0
- package/dist/lib/session/tool-calls.js +612 -0
- package/dist/lib/session/tool-index.d.ts +100 -0
- package/dist/lib/session/tool-index.js +773 -0
- package/dist/lib/session/tool-store.d.ts +15 -0
- package/dist/lib/session/tool-store.js +198 -0
- package/dist/lib/session/types.d.ts +49 -0
- package/dist/lib/state.d.ts +10 -1
- package/dist/lib/state.js +11 -2
- package/dist/lib/teams/remoteWorktree.d.ts +3 -4
- package/dist/lib/teams/remoteWorktree.js +3 -4
- package/dist/lib/teams/worktree.d.ts +11 -1
- package/dist/lib/teams/worktree.js +42 -4
- package/dist/lib/types.d.ts +31 -0
- package/dist/lib/types.js +17 -0
- package/package.json +3 -1
package/dist/lib/rotate.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { accountDisplayLabel, getAccountInfo } from './agents.js';
|
|
9
|
+
import { accountDisplayLabel, getAccountInfo, ALL_AGENT_IDS } from './agents.js';
|
|
10
10
|
import { readMeta, writeMeta, getHelpersDir } from './state.js';
|
|
11
11
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
@@ -342,6 +342,136 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
|
|
|
342
342
|
: undefined;
|
|
343
343
|
return { picked: preferred ?? bestVerified, healthy: sorted, excluded, usageUnverified };
|
|
344
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Classify every harness's candidates into healthy (with a representative
|
|
347
|
+
* best account) vs excluded (with per-reason counts). Pure — the pick and the
|
|
348
|
+
* zero-healthy error message both read this, so they can never disagree.
|
|
349
|
+
*
|
|
350
|
+
* Health uses the exact account-layer gate (`isRotationEligible`: signed in
|
|
351
|
+
* AND not maxed on ANY blocking window, weekly included). The representative
|
|
352
|
+
* best account honors `preferVerified`: confirmed headroom beats apparent
|
|
353
|
+
* headroom, the same freshness rule the account layer routes on.
|
|
354
|
+
*/
|
|
355
|
+
export function classifyHarnessCandidates(byHarness, nowMs = Date.now()) {
|
|
356
|
+
const summaries = [];
|
|
357
|
+
for (const [agent, candidates] of byHarness) {
|
|
358
|
+
const eligible = candidates.filter(isRotationEligible);
|
|
359
|
+
if (eligible.length === 0) {
|
|
360
|
+
const counts = new Map();
|
|
361
|
+
for (const c of candidates) {
|
|
362
|
+
const readiness = readinessFromCandidate(c);
|
|
363
|
+
const reason = readiness.ready ? 'ineligible' : readiness.reason;
|
|
364
|
+
counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
|
365
|
+
}
|
|
366
|
+
summaries.push({
|
|
367
|
+
agent,
|
|
368
|
+
candidates,
|
|
369
|
+
healthy: [],
|
|
370
|
+
best: null,
|
|
371
|
+
bestUsedPercent: null,
|
|
372
|
+
exclusionReasons: [...counts.entries()].map(([reason, n]) => `${n} ${reason}`),
|
|
373
|
+
});
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const sorted = dedupeAndSortCandidates(eligible);
|
|
377
|
+
const { picked: best } = preferVerified(sorted, nowMs, (from) => from[0]);
|
|
378
|
+
summaries.push({
|
|
379
|
+
agent,
|
|
380
|
+
candidates,
|
|
381
|
+
healthy: sorted,
|
|
382
|
+
best,
|
|
383
|
+
bestUsedPercent: getRoutingUsedPercent(best.usageSnapshot),
|
|
384
|
+
exclusionReasons: [],
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return summaries;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Pick a harness for `agents run auto` using weighted random by best-account
|
|
391
|
+
* headroom (RUSH-2132).
|
|
392
|
+
*
|
|
393
|
+
* A harness's capacity is `100 − min(routingUsed% across its healthy accounts)`
|
|
394
|
+
* — its best account's headroom. The pick reuses `weightedRandomByCapacity` on
|
|
395
|
+
* the representative best accounts, so host/harness/account layers all share
|
|
396
|
+
* one sampling behavior. Harnesses with zero healthy accounts are EXCLUDED,
|
|
397
|
+
* not down-weighted. Returns null when no harness has any healthy account;
|
|
398
|
+
* call `classifyHarnessCandidates` for the exclusion detail to message with.
|
|
399
|
+
*/
|
|
400
|
+
export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
|
|
401
|
+
const summaries = classifyHarnessCandidates(byHarness, nowMs);
|
|
402
|
+
const healthy = summaries.filter((s) => s.best !== null);
|
|
403
|
+
const excluded = summaries.filter((s) => s.best === null);
|
|
404
|
+
if (healthy.length === 0)
|
|
405
|
+
return null;
|
|
406
|
+
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
|
|
407
|
+
const picked = healthy.find((s) => s.best === pickedBest);
|
|
408
|
+
return { picked, healthy, excluded };
|
|
409
|
+
}
|
|
410
|
+
/** One-line banner naming the auto-picked harness and why (headroom). */
|
|
411
|
+
export function formatHarnessPickBanner(result) {
|
|
412
|
+
const { picked, healthy, excluded } = result;
|
|
413
|
+
const headroom = picked.bestUsedPercent === null
|
|
414
|
+
? 'best account headroom unknown'
|
|
415
|
+
: `best account ${Math.max(0, Math.round(100 - picked.bestUsedPercent))}% headroom`;
|
|
416
|
+
const ratio = `${healthy.length} of ${healthy.length + excluded.length} harnesses healthy`;
|
|
417
|
+
return `[agents] auto picked ${picked.agent} (${headroom}, ${ratio})`;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The earliest FUTURE window reset across these candidates' usage snapshots —
|
|
421
|
+
* when the first exhausted account becomes usable again. Null when no snapshot
|
|
422
|
+
* carries a reset timestamp.
|
|
423
|
+
*/
|
|
424
|
+
export function earliestResetAcross(candidates, nowMs = Date.now()) {
|
|
425
|
+
let earliest = null;
|
|
426
|
+
for (const c of candidates) {
|
|
427
|
+
for (const window of c.usageSnapshot?.windows ?? []) {
|
|
428
|
+
const t = window.resetsAt?.getTime();
|
|
429
|
+
if (t != null && t > nowMs && (earliest === null || t < earliest)) {
|
|
430
|
+
earliest = t;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return earliest === null ? null : new Date(earliest);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The `resets <summary>` fragment both zero-healthy errors share. ISO 8601 so
|
|
438
|
+
* a watchdog can parse the cooldown straight off the line; `unknown` when no
|
|
439
|
+
* snapshot carries a reset (a parser falls back to its default cooldown).
|
|
440
|
+
*/
|
|
441
|
+
function formatResetSummary(reset) {
|
|
442
|
+
return reset ? reset.toISOString() : 'unknown (no reset timestamps in any snapshot)';
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The zero-healthy-account error (RUSH-2132). EXACT contract — the Factory
|
|
446
|
+
* watchdog tail-detects this text: it must contain the literal `no healthy`
|
|
447
|
+
* and `resets <time>` (parsed for the rotate cooldown). Do not deviate.
|
|
448
|
+
*/
|
|
449
|
+
export function formatNoHealthyAccountError(agent, strategy, excluded, nowMs = Date.now()) {
|
|
450
|
+
const excludedStr = excluded.length === 0
|
|
451
|
+
? 'no installed versions'
|
|
452
|
+
: excluded.map((c) => {
|
|
453
|
+
const readiness = readinessFromCandidate(c);
|
|
454
|
+
const reason = readiness.ready ? 'ineligible' : readiness.reason;
|
|
455
|
+
return `${c.version} (${reason})`;
|
|
456
|
+
}).join(', ');
|
|
457
|
+
const resetSummary = formatResetSummary(earliestResetAcross(excluded, nowMs));
|
|
458
|
+
return `agents: no healthy ${agent} account under strategy '${strategy}' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Use --strategy pinned to force the default.`;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The zero-healthy-harness error for `agents run auto` — names each harness's
|
|
462
|
+
* exclusion reason plus the earliest reset across all snapshots.
|
|
463
|
+
*/
|
|
464
|
+
export function formatNoHealthyHarnessError(summaries, nowMs = Date.now()) {
|
|
465
|
+
const excludedStr = summaries.length === 0
|
|
466
|
+
? 'no installed harnesses'
|
|
467
|
+
: summaries.map((s) => {
|
|
468
|
+
const n = s.candidates.length;
|
|
469
|
+
const detail = s.exclusionReasons.length > 0 ? s.exclusionReasons.join(', ') : 'no accounts signed in';
|
|
470
|
+
return `${s.agent} (${n} account${n === 1 ? '' : 's'}: ${detail})`;
|
|
471
|
+
}).join(', ');
|
|
472
|
+
const resetSummary = formatResetSummary(earliestResetAcross(summaries.flatMap((s) => s.candidates), nowMs));
|
|
473
|
+
return `agents: no healthy harness for 'run auto' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Sign in an account or wait for a window to reset.`;
|
|
474
|
+
}
|
|
345
475
|
export async function collectRunCandidates(agent) {
|
|
346
476
|
const versions = listInstalledVersions(agent);
|
|
347
477
|
const rows = await Promise.all(versions.map(async (version) => {
|
|
@@ -400,6 +530,25 @@ export async function collectRunCandidates(agent) {
|
|
|
400
530
|
};
|
|
401
531
|
});
|
|
402
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Collect run candidates for every harness with ≥1 installed version — the
|
|
535
|
+
* probe `agents run auto` routes on (the same per-harness account probe
|
|
536
|
+
* `agents view` aggregates). Harnesses with nothing installed are absent from
|
|
537
|
+
* the map: not a candidate at all, rather than an excluded one.
|
|
538
|
+
*/
|
|
539
|
+
export async function collectHarnessCandidates(agentIds = ALL_AGENT_IDS) {
|
|
540
|
+
const entries = await Promise.all(agentIds.map(async (agent) => {
|
|
541
|
+
if (listInstalledVersions(agent).length === 0)
|
|
542
|
+
return null;
|
|
543
|
+
return [agent, await collectRunCandidates(agent)];
|
|
544
|
+
}));
|
|
545
|
+
const byHarness = new Map();
|
|
546
|
+
for (const entry of entries) {
|
|
547
|
+
if (entry)
|
|
548
|
+
byHarness.set(entry[0], entry[1]);
|
|
549
|
+
}
|
|
550
|
+
return byHarness;
|
|
551
|
+
}
|
|
403
552
|
/**
|
|
404
553
|
* Resolve an account identity to the installed version slot that holds it, over
|
|
405
554
|
* an already-collected candidate list. Pure — no I/O — so it is unit-tested
|
|
@@ -478,14 +627,15 @@ function readRotationStamp(agent) {
|
|
|
478
627
|
catch { /* missing or corrupt — treat as no stamp */ }
|
|
479
628
|
return null;
|
|
480
629
|
}
|
|
481
|
-
export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
|
|
630
|
+
export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
|
|
482
631
|
const fallback = resolveVersion(agent, cwd);
|
|
483
632
|
if (strategy === 'pinned') {
|
|
484
633
|
return { version: fallback, rotation: null };
|
|
485
634
|
}
|
|
635
|
+
const candidates = await collect(agent);
|
|
486
636
|
const rotation = strategy === 'available'
|
|
487
|
-
?
|
|
488
|
-
:
|
|
637
|
+
? pickAvailableCandidate(candidates, fallback)
|
|
638
|
+
: pickBalancedCandidate(candidates);
|
|
489
639
|
if (rotation) {
|
|
490
640
|
// `available` is sticky to the pinned default when healthy. Use the 60s
|
|
491
641
|
// anti-collision stamp to nudge parallel callers off the same version.
|
|
@@ -503,7 +653,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
|
|
|
503
653
|
emit('rotation.resolved', { module: 'rotate', agent, version: rotation.picked.version, strategy, healthy: rotation.healthy.length, excluded: rotation.excluded.length });
|
|
504
654
|
return { version: rotation.picked.version, rotation };
|
|
505
655
|
}
|
|
506
|
-
return { version: fallback, rotation: null };
|
|
656
|
+
return { version: fallback, rotation: null, exhausted: candidates.length > 0 ? candidates : undefined };
|
|
507
657
|
}
|
|
508
658
|
/**
|
|
509
659
|
* Cap on the number of healthy accounts a single run will re-dispatch through
|
package/dist/lib/routines.js
CHANGED
|
@@ -16,6 +16,7 @@ import { atomicWriteFileSync } from './fs-atomic.js';
|
|
|
16
16
|
import { ALL_AGENT_IDS } from './agents.js';
|
|
17
17
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
18
18
|
import { resolveActor } from './actor.js';
|
|
19
|
+
import { percentile } from './percentile.js';
|
|
19
20
|
export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
|
|
20
21
|
/** Canonical set of accepted GitHub trigger events — single source for validation. */
|
|
21
22
|
export const GITHUB_TRIGGER_EVENTS = [
|
|
@@ -964,20 +965,6 @@ export function getLatestCompletedRun(jobName) {
|
|
|
964
965
|
}
|
|
965
966
|
return null;
|
|
966
967
|
}
|
|
967
|
-
/** Percentile of a sorted-ascending array (linear interpolation). p in [0,100]. */
|
|
968
|
-
function percentile(sorted, p) {
|
|
969
|
-
if (sorted.length === 0)
|
|
970
|
-
return 0;
|
|
971
|
-
if (sorted.length === 1)
|
|
972
|
-
return sorted[0];
|
|
973
|
-
const rank = (p / 100) * (sorted.length - 1);
|
|
974
|
-
const lo = Math.floor(rank);
|
|
975
|
-
const hi = Math.ceil(rank);
|
|
976
|
-
if (lo === hi)
|
|
977
|
-
return sorted[lo];
|
|
978
|
-
const frac = rank - lo;
|
|
979
|
-
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
980
|
-
}
|
|
981
968
|
/**
|
|
982
969
|
* Fold a job's run history (`listRuns`) into a duration + outcome summary.
|
|
983
970
|
* `missed` fires (no process ever ran) carry no `duration` and are excluded
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { JobConfig, RunMeta } from './routines.js';
|
|
|
17
17
|
import type { AgentId } from './types.js';
|
|
18
18
|
import { type FallbackEntry } from './exec.js';
|
|
19
19
|
import type { LoopDeps } from './loop.js';
|
|
20
|
-
import { type RotateResult } from './rotate.js';
|
|
20
|
+
import { resolveRunVersion, type RotateResult } from './rotate.js';
|
|
21
21
|
/** Result of a completed job execution, including metadata and optional report. */
|
|
22
22
|
export interface RunResult {
|
|
23
23
|
meta: RunMeta;
|
|
@@ -49,7 +49,9 @@ export interface RoutineLaunchPlan {
|
|
|
49
49
|
*
|
|
50
50
|
* Workflows are left alone — `agents run <workflow>` owns selection.
|
|
51
51
|
*/
|
|
52
|
-
export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string
|
|
52
|
+
export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string, deps?: {
|
|
53
|
+
resolveRunVersion?: typeof resolveRunVersion;
|
|
54
|
+
}): Promise<RoutineLaunchPlan>;
|
|
53
55
|
/**
|
|
54
56
|
* Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.
|
|
55
57
|
* Bypasses the bare-name shim so a sandboxed HOME / missing default pin cannot
|
package/dist/lib/runner.js
CHANGED
|
@@ -29,7 +29,7 @@ import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
|
|
|
29
29
|
import { backgroundSpawnOptions } from './platform/process.js';
|
|
30
30
|
import { walkForFiles } from './fs-walk.js';
|
|
31
31
|
import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
32
|
-
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
|
|
32
|
+
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
|
|
33
33
|
import { readAuthHealth, isDeadVerdict } from './auth-health.js';
|
|
34
34
|
import { machineId } from './machine-id.js';
|
|
35
35
|
import { isSelfUpdatingAgent } from './agents.js';
|
|
@@ -66,7 +66,14 @@ const ROUTINE_TRANSCRIPT_SPECS = {
|
|
|
66
66
|
gemini: [{ root: ['.gemini', 'tmp'], ext: '.json' }],
|
|
67
67
|
antigravity: [{ root: ['.gemini', 'antigravity-cli', 'conversations'], ext: '.db' }],
|
|
68
68
|
droid: [{ root: ['.factory', 'sessions'], ext: '.jsonl' }],
|
|
69
|
-
|
|
69
|
+
// Kimi splits a session across two files (session/discover.ts:4382-4384):
|
|
70
|
+
// state.json (title/timestamps) and agents/main/wire.jsonl (the actual
|
|
71
|
+
// conversation). Both extensions are needed — .json alone archives only
|
|
72
|
+
// the metadata shell and silently drops every message.
|
|
73
|
+
kimi: [
|
|
74
|
+
{ root: ['.kimi-code', 'sessions'], ext: '.json' },
|
|
75
|
+
{ root: ['.kimi-code', 'sessions'], ext: '.jsonl' },
|
|
76
|
+
],
|
|
70
77
|
grok: [{ root: ['.grok', 'sessions'], ext: '.json' }],
|
|
71
78
|
};
|
|
72
79
|
/** Stable working directory for routine children, independent of the daemon's launch cwd. */
|
|
@@ -323,7 +330,7 @@ function readCommandExitCode(runDir) {
|
|
|
323
330
|
*
|
|
324
331
|
* Workflows are left alone — `agents run <workflow>` owns selection.
|
|
325
332
|
*/
|
|
326
|
-
export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
333
|
+
export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {}) {
|
|
327
334
|
if (config.workflow) {
|
|
328
335
|
return { chain: [], rotation: null, pinned: false };
|
|
329
336
|
}
|
|
@@ -357,10 +364,12 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
357
364
|
const strategy = getConfiguredRunStrategy(agent, cwd);
|
|
358
365
|
let version;
|
|
359
366
|
let rotation = null;
|
|
367
|
+
let exhausted;
|
|
360
368
|
try {
|
|
361
|
-
const resolved = await resolveRunVersion(agent, strategy, cwd);
|
|
369
|
+
const resolved = await (deps.resolveRunVersion ?? resolveRunVersion)(agent, strategy, cwd);
|
|
362
370
|
version = resolved.version ?? undefined;
|
|
363
371
|
rotation = resolved.rotation;
|
|
372
|
+
exhausted = resolved.exhausted;
|
|
364
373
|
if (rotation) {
|
|
365
374
|
const label = rotation.picked.email
|
|
366
375
|
? `${rotation.picked.email} · ${agent}@${rotation.picked.version}`
|
|
@@ -378,7 +387,7 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
378
387
|
process.stderr.write(`[agents] routine ${config.name}: skipped ${reasons}\n`);
|
|
379
388
|
}
|
|
380
389
|
}
|
|
381
|
-
else if (!version) {
|
|
390
|
+
else if (!version && !exhausted) {
|
|
382
391
|
process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} found no usable ${agent} version; ` +
|
|
383
392
|
`falling back to default pin\n`);
|
|
384
393
|
}
|
|
@@ -386,6 +395,13 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
386
395
|
catch (err) {
|
|
387
396
|
process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} skipped: ${err.message}\n`);
|
|
388
397
|
}
|
|
398
|
+
// Zero healthy accounts is NOT a "fall back to the default pin" case — that
|
|
399
|
+
// pin is exactly the exhausted account an unattended routine would hammer
|
|
400
|
+
// every tick (RUSH-2132). Throwing fails the job run (nonzero), and the
|
|
401
|
+
// message text is the contract the Factory watchdog tail-detects.
|
|
402
|
+
if (exhausted) {
|
|
403
|
+
throw new Error(formatNoHealthyAccountError(agent, strategy, exhausted));
|
|
404
|
+
}
|
|
389
405
|
if (!version) {
|
|
390
406
|
version = resolveVersion(agent, cwd) ?? undefined;
|
|
391
407
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -64,6 +64,7 @@ const TOOL_REGISTRY = {
|
|
|
64
64
|
mv: { category: 'shell', signal: 'low', action: 'moving files' },
|
|
65
65
|
cp: { category: 'shell', signal: 'low', action: 'copying files' },
|
|
66
66
|
mkdir: { category: 'shell', signal: 'low', action: 'making directories' },
|
|
67
|
+
rmdir: { category: 'shell', signal: 'low', action: 'removing directories' },
|
|
67
68
|
touch: { category: 'shell', signal: 'low', action: 'touching files' },
|
|
68
69
|
echo: { category: 'shell', signal: 'low', action: 'echoing' },
|
|
69
70
|
printf: { category: 'shell', signal: 'low', action: 'printing' },
|
|
@@ -115,6 +116,13 @@ const VALUE_FLAGS = {
|
|
|
115
116
|
kubectl: new Set(['-n', '--namespace', '--kubeconfig', '--context', '--cluster', '--user', '-s', '--server', '--as', '--token', '--cache-dir', '--request-timeout']),
|
|
116
117
|
rush: new Set(),
|
|
117
118
|
openclaw: new Set(),
|
|
119
|
+
// The repo's own toolchain — heavy in real transcripts (`agents` alone was the
|
|
120
|
+
// top unrecognized token). Two-level so they bucket by subcommand
|
|
121
|
+
// (`agents sessions`, `linear list`) instead of one flat `other` pile (#1830).
|
|
122
|
+
// `ag` is deliberately NOT here — it is the silver searcher in TOOL_REGISTRY,
|
|
123
|
+
// not the agents alias, in this classifier's world.
|
|
124
|
+
agents: new Set(['-H', '--host', '--device']),
|
|
125
|
+
linear: new Set(),
|
|
118
126
|
};
|
|
119
127
|
/** Tools whose bucket key includes the second token (subcommand). */
|
|
120
128
|
const TWO_LEVEL_TOOLS = new Set(Object.keys(VALUE_FLAGS));
|
|
@@ -141,8 +149,8 @@ export function unwrapCommand(cmd) {
|
|
|
141
149
|
const prefix = s.match(/^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$/);
|
|
142
150
|
if (prefix)
|
|
143
151
|
return unwrapCommand(prefix[1]);
|
|
144
|
-
// cd foo && command
|
|
145
|
-
const cd = s.match(/^cd\s+\S+\s
|
|
152
|
+
// cd foo && command (also `cd foo; command` and newline-separated `cd foo\ncommand`)
|
|
153
|
+
const cd = s.match(/^cd\s+\S+\s*(?:&&|;|\n)\s*([\s\S]+)$/);
|
|
146
154
|
if (cd)
|
|
147
155
|
return unwrapCommand(cd[1]);
|
|
148
156
|
// npx / bunx
|
|
@@ -191,7 +199,11 @@ function splitOnOperators(cmd) {
|
|
|
191
199
|
i += 2;
|
|
192
200
|
continue;
|
|
193
201
|
}
|
|
194
|
-
|
|
202
|
+
// A newline separates commands the same way `;` does (an unquoted, un-escaped
|
|
203
|
+
// line break), so `cd X\ncmd` splits into two segments instead of reading as
|
|
204
|
+
// one `cd` command — the top source of `other` classifications (#1830). A
|
|
205
|
+
// `\`-continued line never reaches here (handled by the escape branch above).
|
|
206
|
+
if (ch === '|' || ch === ';' || ch === '\n') {
|
|
195
207
|
if (current.trim())
|
|
196
208
|
parts.push(current.trim());
|
|
197
209
|
current = '';
|
|
@@ -247,22 +259,61 @@ function scanSubcommand(tokens, tool) {
|
|
|
247
259
|
}
|
|
248
260
|
return '';
|
|
249
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* The classifier reads only the executable and the first non-flag token, both at
|
|
264
|
+
* the very head of the first simple command. Tokenizing the *entire* command —
|
|
265
|
+
* every pipeline segment, multi-KB heredoc bodies included — to reach the first
|
|
266
|
+
* word cost up to ~1ms per call (a 7.8KB `cat <<HEREDOC …` classified on the word
|
|
267
|
+
* `cat`). Tokenize only this much of the head instead: enough for the executable
|
|
268
|
+
* plus a subcommand and its flags, never a heredoc tail (#1830, ~3.3x faster).
|
|
269
|
+
*/
|
|
270
|
+
const CLASSIFY_HEAD_LIMIT = 200;
|
|
271
|
+
/**
|
|
272
|
+
* Tokens of the first simple command only, tokenizing just the head of the
|
|
273
|
+
* unwrapped string. Mirrors {@link tokenizeBash}'s first-segment result for
|
|
274
|
+
* short commands but skips the cost of tokenizing the whole command; the full
|
|
275
|
+
* multi-segment tokenizer stays available for callers that need every segment.
|
|
276
|
+
*/
|
|
277
|
+
function firstSimpleCommandTokens(command) {
|
|
278
|
+
const unwrapped = unwrapCommand(command);
|
|
279
|
+
const head = unwrapped.length > CLASSIFY_HEAD_LIMIT ? unwrapped.slice(0, CLASSIFY_HEAD_LIMIT) : unwrapped;
|
|
280
|
+
const firstSegment = splitOnOperators(head)[0] ?? '';
|
|
281
|
+
if (!firstSegment)
|
|
282
|
+
return [];
|
|
283
|
+
try {
|
|
284
|
+
return shlexSplit(firstSegment);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// The head cut a quote mid-string (e.g. a long quoted flag value like
|
|
288
|
+
// `git -c http.extraheader="Authorization: Bearer …" fetch`), so shlex threw
|
|
289
|
+
// on the unbalanced quote. A whitespace split of the truncated head would
|
|
290
|
+
// mis-read the value as the subcommand, so re-tokenize the FULL first
|
|
291
|
+
// segment instead — only the rare throw path pays that cost.
|
|
292
|
+
const fullFirst = splitOnOperators(unwrapped)[0] ?? firstSegment;
|
|
293
|
+
try {
|
|
294
|
+
return shlexSplit(fullFirst);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// Genuinely unbalanced quoting even in the full segment — a whitespace
|
|
298
|
+
// split of the full segment still yields the leading executable.
|
|
299
|
+
return fullFirst.split(/\s+/).filter(Boolean);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
250
303
|
/**
|
|
251
304
|
* Classify the first simple command in a Bash string. Returns coarse metadata
|
|
252
305
|
* (tool name, category, subcommand, human action) used for summaries and
|
|
253
306
|
* activity logging. Unknown executables fall back to `other`.
|
|
254
307
|
*/
|
|
255
308
|
export function classifyBashCommand(command) {
|
|
256
|
-
const
|
|
257
|
-
if (!simpleCommands.length) {
|
|
258
|
-
return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
|
|
259
|
-
}
|
|
260
|
-
const tokens = simpleCommands[0];
|
|
309
|
+
const tokens = firstSimpleCommandTokens(command);
|
|
261
310
|
if (!tokens.length) {
|
|
262
311
|
return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
|
|
263
312
|
}
|
|
264
313
|
const first = tokens[0];
|
|
265
|
-
|
|
314
|
+
// Reduce a path executable to its basename so `~/.agents/skills/linear/scripts/linear`,
|
|
315
|
+
// `/usr/bin/git`, and `./tool` all resolve by tool name, not the full path (#1830).
|
|
316
|
+
const baseRaw = first.replace(/^.*\//, '').toLowerCase();
|
|
266
317
|
const base = baseRaw.endsWith('.exe') ? baseRaw.slice(0, -4) : baseRaw;
|
|
267
318
|
const canonical = ALIAS_MAP.get(base);
|
|
268
319
|
const info = canonical ? TOOL_REGISTRY[canonical] : undefined;
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import Database from '../sqlite.js';
|
|
10
10
|
import type { SessionAgentId, SessionMeta } from './types.js';
|
|
11
|
+
import { type IndexedToolCall } from './tool-calls.js';
|
|
11
12
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
12
13
|
* assert against the constant instead of hardcoding a number that every bump
|
|
13
14
|
* then has to chase (docs/05-sessions.md calls the constant the source of truth). */
|
|
14
|
-
export declare const SCHEMA_VERSION =
|
|
15
|
+
export declare const SCHEMA_VERSION = 30;
|
|
15
16
|
/** Raw row shape returned from the sessions table. */
|
|
16
17
|
export interface SessionRow {
|
|
17
18
|
id: string;
|
|
@@ -54,6 +55,9 @@ export interface SessionRow {
|
|
|
54
55
|
linear_project_url: string | null;
|
|
55
56
|
actor: string | null;
|
|
56
57
|
initiated_by: string | null;
|
|
58
|
+
/** NULL means "not yet computed" (a row scanned before this field existed) — see rowToMeta. */
|
|
59
|
+
used_browser: number | null;
|
|
60
|
+
used_computer: number | null;
|
|
57
61
|
}
|
|
58
62
|
/** File stat snapshot used to detect changes between scan runs. */
|
|
59
63
|
export interface ScanStamp {
|
|
@@ -88,6 +92,20 @@ export interface QueryOptions {
|
|
|
88
92
|
* with NULLs sorted last so unpriced rows never crowd out real data.
|
|
89
93
|
*/
|
|
90
94
|
sortBy?: 'timestamp' | 'cost' | 'duration';
|
|
95
|
+
/** Internal warm-cache path; callers must validate the small final result set. */
|
|
96
|
+
skipExistenceCheck?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Only sessions that invoked this skill (#12), joined against
|
|
99
|
+
* session_resource_usage.kind='skill'. Matches either the full stored name
|
|
100
|
+
* (bare, or `plugin:name` for a plugin skill) or just the short name after
|
|
101
|
+
* the colon — `--skill design` finds a session that used `rush:design`.
|
|
102
|
+
*/
|
|
103
|
+
skill?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Only sessions that used a skill or slash-command owned by this plugin
|
|
106
|
+
* (#12), joined against session_resource_usage.plugin.
|
|
107
|
+
*/
|
|
108
|
+
plugin?: string;
|
|
91
109
|
}
|
|
92
110
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
93
111
|
export declare function getDB(): Database.Database;
|
|
@@ -189,6 +207,9 @@ export declare function upsertSessionsBatch(entries: Array<{
|
|
|
189
207
|
scan?: ScanStamp;
|
|
190
208
|
parserState?: string;
|
|
191
209
|
contentText?: string;
|
|
210
|
+
toolCalls?: IndexedToolCall[];
|
|
211
|
+
toolScan?: ScanStamp;
|
|
212
|
+
toolIndexMode?: 'replace' | 'append';
|
|
192
213
|
}>): void;
|
|
193
214
|
/**
|
|
194
215
|
* Sync labels for a set of sessions. For each id in the map, if the stored
|