@phnx-labs/agents-cli 1.22.30 → 1.22.32

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/daemon.js +52 -12
  5. package/dist/commands/doctor.d.ts +19 -0
  6. package/dist/commands/doctor.js +119 -17
  7. package/dist/commands/focus.d.ts +4 -1
  8. package/dist/commands/focus.js +19 -4
  9. package/dist/commands/routines.js +164 -36
  10. package/dist/commands/secrets.d.ts +20 -0
  11. package/dist/commands/secrets.js +59 -93
  12. package/dist/commands/{sessions-favorite.d.ts → sessions-bookmark.d.ts} +7 -7
  13. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +38 -38
  14. package/dist/commands/sessions-browser.d.ts +10 -8
  15. package/dist/commands/sessions-browser.js +61 -32
  16. package/dist/commands/sessions-stats.js +1 -1
  17. package/dist/commands/sessions.d.ts +11 -9
  18. package/dist/commands/sessions.js +114 -65
  19. package/dist/commands/update.d.ts +2 -0
  20. package/dist/commands/update.js +148 -0
  21. package/dist/index.js +4 -2
  22. package/dist/lib/catchup.js +4 -1
  23. package/dist/lib/daemon.d.ts +17 -0
  24. package/dist/lib/daemon.js +69 -3
  25. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  26. package/dist/lib/devices/doctor-findings.js +53 -2
  27. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  28. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  29. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  30. package/dist/lib/devices/fleet-divergence.js +6 -0
  31. package/dist/lib/devices/fleet-inventory.js +16 -2
  32. package/dist/lib/drift.d.ts +6 -1
  33. package/dist/lib/drift.js +9 -0
  34. package/dist/lib/hooks/cache.js +20 -1
  35. package/dist/lib/hooks.d.ts +91 -1
  36. package/dist/lib/hooks.js +289 -3
  37. package/dist/lib/hosts/passthrough.js +3 -0
  38. package/dist/lib/installations/index.d.ts +14 -0
  39. package/dist/lib/installations/index.js +14 -0
  40. package/dist/lib/installations/resolve.d.ts +43 -0
  41. package/dist/lib/installations/resolve.js +93 -0
  42. package/dist/lib/installations/store.d.ts +56 -0
  43. package/dist/lib/installations/store.js +196 -0
  44. package/dist/lib/installations/strategies.d.ts +73 -0
  45. package/dist/lib/installations/strategies.js +293 -0
  46. package/dist/lib/installations/types.d.ts +78 -0
  47. package/dist/lib/installations/types.js +8 -0
  48. package/dist/lib/installations/update.d.ts +40 -0
  49. package/dist/lib/installations/update.js +131 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/migrate.d.ts +27 -0
  53. package/dist/lib/migrate.js +118 -2
  54. package/dist/lib/picker.d.ts +6 -3
  55. package/dist/lib/picker.js +7 -2
  56. package/dist/lib/routine-context.d.ts +144 -0
  57. package/dist/lib/routine-context.js +268 -0
  58. package/dist/lib/routine-readiness.d.ts +47 -0
  59. package/dist/lib/routine-readiness.js +239 -0
  60. package/dist/lib/routines.d.ts +97 -1
  61. package/dist/lib/routines.js +107 -1
  62. package/dist/lib/runner.d.ts +18 -4
  63. package/dist/lib/runner.js +291 -98
  64. package/dist/lib/scheduler.d.ts +7 -1
  65. package/dist/lib/scheduler.js +5 -2
  66. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  68. package/dist/lib/secrets/agent.d.ts +0 -1
  69. package/dist/lib/secrets/agent.js +0 -4
  70. package/dist/lib/secrets/session-store.d.ts +0 -4
  71. package/dist/lib/secrets/session-store.js +0 -5
  72. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  73. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  74. package/dist/lib/self-heal/registry.js +5 -2
  75. package/dist/lib/self-heal/types.d.ts +1 -1
  76. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +14 -14
  77. package/dist/lib/session/{favorites.js → bookmarks.js} +22 -22
  78. package/dist/lib/session/state.js +4 -1
  79. package/dist/lib/startup/command-registry.d.ts +1 -0
  80. package/dist/lib/startup/command-registry.js +2 -0
  81. package/dist/lib/versions.d.ts +24 -0
  82. package/dist/lib/versions.js +49 -16
  83. package/package.json +2 -2
@@ -0,0 +1,40 @@
1
+ import { type UpdateStrategy } from './strategies.js';
2
+ import type { Installation, UpdateOutcome } from './types.js';
3
+ export interface UpdateInstallationOptions {
4
+ /** `latest` (default), `oldest`, or a concrete release. */
5
+ to?: string;
6
+ onProgress?: (message: string) => void;
7
+ /**
8
+ * Replace the registry-selected strategy. The seam exists so the transaction
9
+ * below can be exercised against a real filesystem without a vendor fetch, and
10
+ * so a track that installs a harness differently (per-installation Cursor
11
+ * isolation) can reuse this orchestration instead of re-implementing it.
12
+ * Omitted in every normal call — `selectUpdateStrategy` is the default.
13
+ */
14
+ strategy?: UpdateStrategy;
15
+ }
16
+ /**
17
+ * Move one frozen installation to a new vendor release, preserving its identity.
18
+ *
19
+ * The transaction is stage → verify → commit → record, with rollback on any
20
+ * failure after the swap:
21
+ *
22
+ * 1. **stage** — fetch the target release somewhere that is not yet live.
23
+ * 2. **verify** — launch the STAGED binary. This is the gate that makes the
24
+ * update safe: a release that cannot start is discarded while
25
+ * the working one is still in place, so the failure mode is
26
+ * "nothing changed", not "the agent no longer runs".
27
+ * 3. **commit** — swap it in, keeping the displaced release until step 4.
28
+ * 4. **record** — re-verify in place, then write the new release into the
29
+ * installation record and drop the rollback material.
30
+ *
31
+ * The installation's `id` and `label` are never touched, so the global default,
32
+ * an isolated default, a project pin, a routine's `version:`, and a profile's
33
+ * `host.version` all keep resolving to this installation across the update —
34
+ * that reference preservation is the whole point of freezing the label.
35
+ *
36
+ * Strategies whose vendor artifact is global (a self-updating binary) report
37
+ * `transactional: false`; for those, step 3 is a no-op and a failed verify is
38
+ * surfaced as a failed update rather than pretended to be reversible.
39
+ */
40
+ export declare function updateInstallation(installation: Installation, options?: UpdateInstallationOptions): Promise<UpdateOutcome>;
@@ -0,0 +1,131 @@
1
+ import * as fs from 'fs';
2
+ import { AGENTS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
3
+ import { emit } from '../events.js';
4
+ import { getBinaryPath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, verifyBinaryLaunches, } from '../versions.js';
5
+ import { assertValidRelease, selectUpdateStrategy, } from './strategies.js';
6
+ import { listInstallations, recordRelease } from './store.js';
7
+ /**
8
+ * Move one frozen installation to a new vendor release, preserving its identity.
9
+ *
10
+ * The transaction is stage → verify → commit → record, with rollback on any
11
+ * failure after the swap:
12
+ *
13
+ * 1. **stage** — fetch the target release somewhere that is not yet live.
14
+ * 2. **verify** — launch the STAGED binary. This is the gate that makes the
15
+ * update safe: a release that cannot start is discarded while
16
+ * the working one is still in place, so the failure mode is
17
+ * "nothing changed", not "the agent no longer runs".
18
+ * 3. **commit** — swap it in, keeping the displaced release until step 4.
19
+ * 4. **record** — re-verify in place, then write the new release into the
20
+ * installation record and drop the rollback material.
21
+ *
22
+ * The installation's `id` and `label` are never touched, so the global default,
23
+ * an isolated default, a project pin, a routine's `version:`, and a profile's
24
+ * `host.version` all keep resolving to this installation across the update —
25
+ * that reference preservation is the whole point of freezing the label.
26
+ *
27
+ * Strategies whose vendor artifact is global (a self-updating binary) report
28
+ * `transactional: false`; for those, step 3 is a no-op and a failed verify is
29
+ * surfaced as a failed update rather than pretended to be reversible.
30
+ */
31
+ export async function updateInstallation(installation, options = {}) {
32
+ const agent = installation.agent;
33
+ if (isAgentHardDeprecated(agent))
34
+ throw new Error(hardDeprecationError(agent));
35
+ const requested = options.to ?? 'latest';
36
+ assertValidRelease(requested);
37
+ const strategy = options.strategy ?? selectUpdateStrategy(agent);
38
+ const ctx = { agent, installation, requested, onProgress: options.onProgress };
39
+ const target = await strategy.resolveTarget(ctx);
40
+ if (target === installation.releaseVersion) {
41
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${target}; nothing to update.`);
42
+ return {
43
+ installation,
44
+ strategy: strategy.id,
45
+ fromRelease: installation.releaseVersion,
46
+ toRelease: target,
47
+ unchanged: true,
48
+ alsoUpdated: [],
49
+ };
50
+ }
51
+ let staged = null;
52
+ try {
53
+ staged = await strategy.stage(ctx, target);
54
+ const stagedHealth = await verifyBinaryLaunches(staged.binary, staged.home);
55
+ if (!stagedHealth.ok) {
56
+ throw new Error(`${AGENTS[agent].name} release ${staged.release} was fetched but its binary failed to launch`
57
+ + `${stagedHealth.detail ? ` (${stagedHealth.detail})` : ''}. `
58
+ + `${installation.label} is unchanged and still on ${installation.releaseVersion}.`);
59
+ }
60
+ // The installer may have reported a release the installation already has
61
+ // (a self-updating binary that was already current). Recording it would
62
+ // claim a change that did not happen and append a bogus history entry.
63
+ if (staged.release === installation.releaseVersion) {
64
+ options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${staged.release}; nothing to update.`);
65
+ // Deliberately NOT committed: there is no new release to make live, and
66
+ // for a strategy that swaps the version dir a commit here would displace
67
+ // a working tree, discard its rollback material, and skip the live probe —
68
+ // all while reporting that nothing changed. The `finally` clears staging.
69
+ return {
70
+ installation,
71
+ strategy: strategy.id,
72
+ fromRelease: installation.releaseVersion,
73
+ toRelease: staged.release,
74
+ unchanged: true,
75
+ alsoUpdated: [],
76
+ };
77
+ }
78
+ const handles = await strategy.commit(ctx, staged);
79
+ try {
80
+ // Probe what will actually execute — `getBinaryPath` is the same resolver
81
+ // the shims and `agents run` use — not the staging copy probed above.
82
+ const liveBinary = getBinaryPath(agent, installation.label);
83
+ const liveHealth = await verifyBinaryLaunches(liveBinary, staged.home);
84
+ if (!liveHealth.ok) {
85
+ throw new Error(`${AGENTS[agent].name} release ${staged.release} failed to launch after being installed`
86
+ + `${liveHealth.detail ? ` (${liveHealth.detail})` : ''}.`);
87
+ }
88
+ }
89
+ catch (err) {
90
+ // Undo unconditionally. `transactional` describes whether the VENDOR
91
+ // artifact can be put back, not whether this directory can — gating the
92
+ // undo on it left an installer-driven harness with the broken tree live
93
+ // AND the previous one orphaned in rollback material nothing deletes.
94
+ // A strategy with nothing to restore returns a no-op undo.
95
+ handles.undo();
96
+ throw new Error(strategy.transactional
97
+ ? `${err.message} Rolled back to ${installation.releaseVersion}.`
98
+ : `${err.message} The version directory was restored, but ${AGENTS[agent].name}'s installer `
99
+ + `had already replaced the binary it manages globally — repair it with: agents add ${agent}@latest`);
100
+ }
101
+ handles.finalize();
102
+ const updated = recordRelease(installation, staged.release);
103
+ // Several installations of a global-binary harness point at the same file,
104
+ // so the one we just replaced is live for all of them. Recording the release
105
+ // only on the target would leave the others claiming a release that is no
106
+ // longer on disk.
107
+ const alsoUpdated = strategy.sharedBinary
108
+ ? listInstallations(agent)
109
+ .filter((other) => other.label !== installation.label && other.releaseVersion !== staged.release)
110
+ .map((other) => recordRelease(other, staged.release))
111
+ : [];
112
+ invalidateInstalledVersionsCache(agent);
113
+ invalidateLiveVersionCache(agent);
114
+ // A release really was installed, so this is the right event; `installation`
115
+ // says WHICH frozen install received it, since the label no longer equals
116
+ // the release.
117
+ emit('version.install', { agent, version: staged.release, installation: installation.label });
118
+ return {
119
+ installation: updated,
120
+ strategy: strategy.id,
121
+ fromRelease: installation.releaseVersion,
122
+ toRelease: staged.release,
123
+ unchanged: false,
124
+ alsoUpdated,
125
+ };
126
+ }
127
+ finally {
128
+ if (staged?.stagingDir)
129
+ fs.rmSync(staged.stagingDir, { recursive: true, force: true });
130
+ }
131
+ }
@@ -117,6 +117,33 @@ export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string):
117
117
  * Params default to the real routines dir; injectable for tests.
118
118
  */
119
119
  export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
120
+ /**
121
+ * Fold the legacy host-placement `remoteCwd` field into the canonical portable
122
+ * `cwd` (RUSH-2290). Host dispatch used to read `remoteCwd` while a local run
123
+ * inferred its cwd from `repo` — two path semantics for one concept. The runner
124
+ * now resolves every placement from `cwd`, so this idempotently rewrites the
125
+ * field:
126
+ *
127
+ * - `remoteCwd` present, no `cwd` → rename to `cwd`.
128
+ * - both present and equal → drop the duplicate `remoteCwd`.
129
+ * - both present and DIFFERENT → conflict: leave BOTH fields untouched so
130
+ * the migration never silently chooses one; `validateJob`/`doctor` then flag the
131
+ * pair and the routine stays paused rather than running against a guessed path.
132
+ *
133
+ * Idempotent: a file with only `cwd` (already migrated) is skipped.
134
+ */
135
+ export declare function migrateRoutineRemoteCwdToCwd(routinesDir?: string): void;
136
+ /**
137
+ * Pause every currently-active routine whose execution context no longer
138
+ * resolves ready (RUSH-2290). An agent/workflow routine with no project/cwd, a
139
+ * missing directory, or a non-portable path used to fire and fail every tick —
140
+ * the mass auth_failed / untrusted-home storm this ticket exists to stop. After
141
+ * the fold, such a routine is deactivated on THIS device (only), preventing it
142
+ * from being scheduled until `agents routines doctor --all --fix` (or a repair +
143
+ * `resume`) makes it ready. Never materializes a device manifest that does not
144
+ * yet exist, and never touches command routines (they run in the target home).
145
+ */
146
+ export declare function pauseUnreadyEnabledRoutines(): void;
120
147
  /**
121
148
  * Fold the legacy watchdog enable sentinel into the watchdog routine.
122
149
  *
@@ -13,8 +13,9 @@ import { atomicWriteFileSync } from './fs-atomic.js';
13
13
  import { machineId } from './machine-id.js';
14
14
  import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
15
15
  import { createLink } from './platform/index.js';
16
- import { migrateLegacyRoutineActivation, setJobEnabled } from './routines.js';
17
- import { addEnabledRoutinesOnUpgrade } from './routine-activation.js';
16
+ import { migrateLegacyRoutineActivation, setJobEnabled, listJobs, validateJob } from './routines.js';
17
+ import { addEnabledRoutinesOnUpgrade, enabledRoutineNames, replaceEnabledRoutines } from './routine-activation.js';
18
+ import { evaluateActivationReadiness } from './routine-readiness.js';
18
19
  import { DAEMON_TICK_ROUTINE_NAMES } from './daemon-ticks.js';
19
20
  const HOME = process.env.HOME ?? os.homedir();
20
21
  const USER_DIR = path.join(HOME, '.agents');
@@ -1015,6 +1016,11 @@ function migrateRuntimeToHistory() {
1015
1016
  catch { /* best-effort */ }
1016
1017
  }
1017
1018
  }
1019
+ /** Rename the session marker store after the product vocabulary changed from
1020
+ * favorite to bookmark. The destination wins if a newer CLI already wrote it. */
1021
+ function migrateLegacySessionMarkersToBookmarks() {
1022
+ moveFileOnce(path.join(HISTORY_DIR, 'favorites.json'), path.join(HISTORY_DIR, 'bookmarks.json'));
1023
+ }
1018
1024
  /**
1019
1025
  * Restore plugins from the cache bucket back to the user-root.
1020
1026
  *
@@ -1982,6 +1988,108 @@ export function migrateRoutineDeviceToDevices(routinesDir) {
1982
1988
  console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: device → devices`);
1983
1989
  }
1984
1990
  }
1991
+ /**
1992
+ * Fold the legacy host-placement `remoteCwd` field into the canonical portable
1993
+ * `cwd` (RUSH-2290). Host dispatch used to read `remoteCwd` while a local run
1994
+ * inferred its cwd from `repo` — two path semantics for one concept. The runner
1995
+ * now resolves every placement from `cwd`, so this idempotently rewrites the
1996
+ * field:
1997
+ *
1998
+ * - `remoteCwd` present, no `cwd` → rename to `cwd`.
1999
+ * - both present and equal → drop the duplicate `remoteCwd`.
2000
+ * - both present and DIFFERENT → conflict: leave BOTH fields untouched so
2001
+ * the migration never silently chooses one; `validateJob`/`doctor` then flag the
2002
+ * pair and the routine stays paused rather than running against a guessed path.
2003
+ *
2004
+ * Idempotent: a file with only `cwd` (already migrated) is skipped.
2005
+ */
2006
+ export function migrateRoutineRemoteCwdToCwd(routinesDir) {
2007
+ const dir = routinesDir ?? path.join(USER_DIR, 'routines');
2008
+ if (!fs.existsSync(dir))
2009
+ return;
2010
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
2011
+ let migrated = 0;
2012
+ let conflicts = 0;
2013
+ for (const file of files) {
2014
+ const filePath = path.join(dir, file);
2015
+ const raw = fs.readFileSync(filePath, 'utf-8');
2016
+ let doc;
2017
+ try {
2018
+ doc = yaml.parse(raw);
2019
+ if (!doc || typeof doc !== 'object')
2020
+ continue;
2021
+ }
2022
+ catch {
2023
+ continue;
2024
+ }
2025
+ if (!('remoteCwd' in doc))
2026
+ continue;
2027
+ const remote = doc.remoteCwd;
2028
+ if (typeof remote !== 'string' || !remote.trim()) {
2029
+ // A malformed legacy value is not something to fold — drop it and move on.
2030
+ delete doc.remoteCwd;
2031
+ atomicWriteFileSync(filePath, yaml.stringify(doc));
2032
+ continue;
2033
+ }
2034
+ if ('cwd' in doc) {
2035
+ if (doc.cwd === remote) {
2036
+ delete doc.remoteCwd; // duplicate — dedupe to the canonical field
2037
+ atomicWriteFileSync(filePath, yaml.stringify(doc));
2038
+ migrated++;
2039
+ }
2040
+ else {
2041
+ conflicts++; // leave both fields; validateJob/doctor pause the conflict
2042
+ }
2043
+ continue;
2044
+ }
2045
+ delete doc.remoteCwd;
2046
+ doc.cwd = remote;
2047
+ atomicWriteFileSync(filePath, yaml.stringify(doc));
2048
+ migrated++;
2049
+ }
2050
+ if (migrated > 0) {
2051
+ console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: remoteCwd → cwd`);
2052
+ }
2053
+ if (conflicts > 0) {
2054
+ console.error(`${conflicts} routine${conflicts === 1 ? '' : 's'} have conflicting remoteCwd/cwd — left paused for manual repair (migration_conflict)`);
2055
+ }
2056
+ }
2057
+ /**
2058
+ * Pause every currently-active routine whose execution context no longer
2059
+ * resolves ready (RUSH-2290). An agent/workflow routine with no project/cwd, a
2060
+ * missing directory, or a non-portable path used to fire and fail every tick —
2061
+ * the mass auth_failed / untrusted-home storm this ticket exists to stop. After
2062
+ * the fold, such a routine is deactivated on THIS device (only), preventing it
2063
+ * from being scheduled until `agents routines doctor --all --fix` (or a repair +
2064
+ * `resume`) makes it ready. Never materializes a device manifest that does not
2065
+ * yet exist, and never touches command routines (they run in the target home).
2066
+ */
2067
+ export function pauseUnreadyEnabledRoutines() {
2068
+ const enabled = enabledRoutineNames();
2069
+ if (enabled === null)
2070
+ return; // no manifest yet — nothing activated to pause
2071
+ const enabledSet = new Set(enabled);
2072
+ const paused = [];
2073
+ for (const job of listJobs()) {
2074
+ if (!enabledSet.has(job.name))
2075
+ continue;
2076
+ let ready = true;
2077
+ try {
2078
+ ready = validateJob(job).length === 0 && evaluateActivationReadiness(job).ready;
2079
+ }
2080
+ catch {
2081
+ ready = true; // never pause a routine because readiness itself threw
2082
+ }
2083
+ if (!ready)
2084
+ paused.push(job.name);
2085
+ }
2086
+ if (paused.length === 0)
2087
+ return;
2088
+ const pausedSet = new Set(paused);
2089
+ replaceEnabledRoutines(enabled.filter((name) => !pausedSet.has(name)));
2090
+ console.error(`Paused ${paused.length} routine${paused.length === 1 ? '' : 's'} with an unresolved execution context ` +
2091
+ `(run 'agents routines doctor --all' to see why): ${paused.join(', ')}`);
2092
+ }
1985
2093
  /**
1986
2094
  * Fold the legacy watchdog enable sentinel into the watchdog routine.
1987
2095
  *
@@ -2178,6 +2286,7 @@ export async function runMigration() {
2178
2286
  migrateSplitDeviceLocalMeta();
2179
2287
  // Bucket moves: collapse runtime state into ~/.agents/.history and ~/.agents/.cache.
2180
2288
  migrateRuntimeToHistory();
2289
+ migrateLegacySessionMarkersToBookmarks();
2181
2290
  migrateRuntimeToCache();
2182
2291
  // Restore plugins (user-authored) from cache back to user-root. Runs AFTER
2183
2292
  // migrateRuntimeToCache so any legacy plugins/ still at the user-root from
@@ -2206,6 +2315,8 @@ export async function runMigration() {
2206
2315
  migrateExtrasExtrasToAgentsExtras();
2207
2316
  // Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
2208
2317
  migrateRoutineDeviceToDevices();
2318
+ // Fold legacy host-placement `remoteCwd` into the canonical portable `cwd`.
2319
+ migrateRoutineRemoteCwdToCwd();
2209
2320
  migrateLegacyRoutineActivation();
2210
2321
  // These routines replace daemon timers that were always active. Devices with
2211
2322
  // an existing activation manifest must retain that behavior after upgrade.
@@ -2214,6 +2325,11 @@ export async function runMigration() {
2214
2325
  // who opted in under the old build stays opted in after upgrading. After the
2215
2326
  // routine rewrites above so the routines dir is in its canonical shape.
2216
2327
  migrateWatchdogSentinelToRoutine();
2328
+ // Deactivate any routine whose execution context no longer resolves ready, so
2329
+ // an anchor-less agent/workflow routine cannot keep firing-and-failing after the
2330
+ // fold (RUSH-2290). Runs AFTER the tick/watchdog routines are added so those
2331
+ // (command/home) routines are evaluated in their final shape.
2332
+ pauseUnreadyEnabledRoutines();
2217
2333
  // Symlink repair runs LAST so it can find the post-move version homes.
2218
2334
  repairAgentConfigSymlinks();
2219
2335
  // Repair self-referential node_modules/.bin/<cli> symlinks (the droid
@@ -96,7 +96,7 @@ export declare function itemPicker<T>(config: PickerConfig<T>): Promise<PickedIt
96
96
  */
97
97
  export declare function multiItemPicker<T>(config: MultiPickerConfig<T>): Promise<T[] | null>;
98
98
  /** Configuration for the dynamic (async-refetch) picker prompt. */
99
- export interface DynamicPickerConfig<T, F> {
99
+ export interface DynamicPickerConfig<T, F, A = never> {
100
100
  message: string;
101
101
  /** The initial filter state. Changing it (via a keybinding) re-runs {@link load}. */
102
102
  initialFilter: F;
@@ -117,6 +117,8 @@ export interface DynamicPickerConfig<T, F> {
117
117
  * SAME reference is a no-op; a new object triggers a reload.
118
118
  */
119
119
  keyBindings?: Record<string, (filter: F) => F>;
120
+ /** Keys that submit the highlighted row with an alternate typed action. */
121
+ submitKeys?: Record<string, A>;
120
122
  /**
121
123
  * Side-effecting keys that don't change the filter (e.g. `y` copies a command).
122
124
  * Receives the live search `query` so the effect can be search-aware. Return a
@@ -160,9 +162,10 @@ export declare function hotkeyToken(key: {
160
162
  meta?: boolean;
161
163
  }): string;
162
164
  /** The result returned when the user selects a row: the item plus the live filter. */
163
- export interface DynamicPicked<T, F> {
165
+ export interface DynamicPicked<T, F, A = never> {
164
166
  item: T;
165
167
  filter: F;
168
+ action?: A;
166
169
  }
167
170
  /**
168
171
  * Async-refetch variant of {@link itemPicker}. Holds a `filter` object in state and
@@ -174,4 +177,4 @@ export interface DynamicPicked<T, F> {
174
177
  * Same render/pagination/preview machinery as the static pickers — only the data
175
178
  * source and keymap are dynamic.
176
179
  */
177
- export declare function dynamicPicker<T, F>(config: DynamicPickerConfig<T, F>): Promise<DynamicPicked<T, F> | null>;
180
+ export declare function dynamicPicker<T, F, A = never>(config: DynamicPickerConfig<T, F, A>): Promise<DynamicPicked<T, F, A> | null>;
@@ -490,11 +490,11 @@ export function dynamicPicker(config) {
490
490
  setActive(0);
491
491
  }, [results]);
492
492
  const selected = results[active];
493
- const finish = () => {
493
+ const finish = (action) => {
494
494
  if (!selected)
495
495
  return;
496
496
  setStatus('done');
497
- done({ item: selected.value, filter });
497
+ done({ item: selected.value, filter, ...(action === undefined ? {} : { action }) });
498
498
  };
499
499
  useKeypress((key, rl) => {
500
500
  if (isEnterKey(key)) {
@@ -575,6 +575,11 @@ export function dynamicPicker(config) {
575
575
  // existing single-letter hotkey: `R`/`C`/`A` used to reach their bindings
576
576
  // via `key.name`, and keying on the character alone would silently retire
577
577
  // them for anyone with caps lock on.
578
+ const submitAction = cfg.submitKeys?.[token] ?? cfg.submitKeys?.[key.name ?? ''];
579
+ if (submitAction !== undefined) {
580
+ finish(submitAction);
581
+ return;
582
+ }
578
583
  const binding = cfg.keyBindings?.[token] ?? cfg.keyBindings?.[key.name ?? ''];
579
584
  if (binding) {
580
585
  const next = binding(filter);
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Routine execution context + readiness resolution.
3
+ *
4
+ * A scheduled routine has to run *somewhere*. This module is the single,
5
+ * target-aware answer to "which directory does this routine's run land in, and
6
+ * is the chosen harness able to start there?" — computed for the eventual
7
+ * execution TARGET, never from the daemon process's own cwd.
8
+ *
9
+ * Two layers, both pure of global state (every input is injected, so a test
10
+ * exercises the real code path against real temp directories rather than a mock):
11
+ *
12
+ * - {@link resolveRoutineExecutionContext} — resolve the working directory from
13
+ * the routine's singular `project` anchor and/or portable `cwd`, following the
14
+ * locked resolution table (see below), and verify the structural + filesystem
15
+ * readiness of that directory (existence, portability, writability, cloud
16
+ * portability). This layer owns the *context* readiness codes.
17
+ * - {@link evaluateRoutineReadiness} — take a resolved context and layer the
18
+ * *harness/target* readiness codes (agent installed, Codex workspace trust,
19
+ * live auth, target reachability) via injected probes.
20
+ *
21
+ * Resolution table (target `$HOME` = the execution device's home):
22
+ *
23
+ * | project | cwd | resolved dir | readiness |
24
+ * |---------|----------------|-------------------------|-----------|
25
+ * | usable | — | project base | continue |
26
+ * | usable | relative | base + cwd (inside base)| continue if inside base + exists |
27
+ * | rootless| relative | $HOME + cwd | continue if exists |
28
+ * | — | relative | $HOME + cwd | continue if exists |
29
+ * | — | ~/… | $HOME-relative | continue if exists |
30
+ * | — | abs under home | normalized to ~/… | continue |
31
+ * | — | abs outside home| local-pinned only | pause (cwd_not_portable) for host/fleet/cloud |
32
+ * | named+unusable | — | no fallback | pause (project_path_missing) |
33
+ * | — | — (agent/workflow) | no implicit home | pause (execution_context_missing) |
34
+ * | — | — (command) | $HOME | continue (housekeeping) |
35
+ */
36
+ /** Stable, machine-readable readiness codes. A routine is activated only when ready. */
37
+ export type RoutineReadinessCode = 'project_not_found' | 'project_path_missing' | 'cwd_missing' | 'cwd_not_directory' | 'cwd_not_portable' | 'execution_context_missing' | 'cloud_context_unsupported' | 'workspace_not_writable' | 'codex_workspace_untrusted' | 'agent_unavailable' | 'agent_auth_failed' | 'target_unreachable' | 'placement_unsupported' | 'migration_conflict';
38
+ export interface RoutineReadiness {
39
+ code: RoutineReadinessCode;
40
+ /** Human-readable one-line explanation of the failing check. */
41
+ message: string;
42
+ /** A single safe command that repairs the blocker, when one exists. */
43
+ repair?: string;
44
+ }
45
+ /** Where the routine body executes — mirrors {@link HostStrategy} placement. */
46
+ export type PlacementMode = 'local' | 'host' | 'fleet' | 'cloud';
47
+ /**
48
+ * What the caller resolved about the routine's singular `project` anchor.
49
+ * `undefined` (the field on the input) means the routine names no project.
50
+ */
51
+ export type ProjectResolution = {
52
+ defined: false;
53
+ }
54
+ /** Defined project; `base` is its portable base dir (`~/…` or absolute), or
55
+ * undefined for a rootless Linear-imported project with no checkout. */
56
+ | {
57
+ defined: true;
58
+ base?: string;
59
+ };
60
+ export type RoutineKind = 'agent' | 'workflow' | 'command';
61
+ /** A filesystem probe against the execution TARGET. */
62
+ export interface ContextFsProbe {
63
+ exists(absPath: string): boolean;
64
+ isDirectory(absPath: string): boolean;
65
+ isWritable(absPath: string): boolean;
66
+ }
67
+ export interface ExecutionContextInput {
68
+ /** Routine name (for messages only). */
69
+ name?: string;
70
+ /** Singular execution anchor (`JobConfig.project`). */
71
+ project?: string;
72
+ /** Portable execution directory (`JobConfig.cwd`). */
73
+ cwd?: string;
74
+ /** Exactly one of agent/workflow/command determines the fallback rules. */
75
+ kind: RoutineKind;
76
+ /** Placement of the run — governs portability enforcement and cloud rules. */
77
+ mode: PlacementMode;
78
+ /** Execution target's absolute `$HOME`. Local: `os.homedir()`; remote: the target home. */
79
+ targetHome: string;
80
+ /** Resolution of the `project` anchor; omit when the routine names no project. */
81
+ projectResolution?: ProjectResolution;
82
+ /**
83
+ * Filesystem probe for the target, present only when this process can inspect
84
+ * it (a local run, or add/edit/doctor invoked on the target box). Absent for a
85
+ * remote/cloud target we cannot reach — then only structural + portability
86
+ * checks run (existence is deferred, never assumed).
87
+ */
88
+ probe?: ContextFsProbe;
89
+ }
90
+ export interface ResolvedExecutionContext {
91
+ project?: string;
92
+ /** `config.cwd` echoed for the run record. */
93
+ requestedCwd?: string;
94
+ /** Portable resolved cwd for the run record: `~/…` when under target home, else absolute. */
95
+ resolvedCwd?: string;
96
+ /** The resolved cwd expanded to an absolute path on the target. Undefined when unresolved. */
97
+ absoluteCwd?: string;
98
+ targetHome: string;
99
+ ready: boolean;
100
+ /** Present when `ready` is false. */
101
+ readiness?: RoutineReadiness;
102
+ }
103
+ /** Expand a leading `~`/`$HOME` against the target home; pass other values through. */
104
+ export declare function expandTargetHome(home: string, p: string): string;
105
+ /** Rewrite an absolute path under the target home to its portable `~/…` form; pass others through. */
106
+ export declare function toTargetPortable(home: string, abs: string): string;
107
+ /** True for a bare relative path (not absolute, not home-anchored). */
108
+ export declare function isBareRelative(p: string): boolean;
109
+ /**
110
+ * Resolve the working directory a routine's run lands in and verify its
111
+ * structural + filesystem readiness for the given placement. Pure of global
112
+ * state — every dependency (target home, project resolution, filesystem probe)
113
+ * is injected.
114
+ */
115
+ export declare function resolveRoutineExecutionContext(input: ExecutionContextInput): ResolvedExecutionContext;
116
+ /** Injected harness/target probes for {@link evaluateRoutineReadiness}. */
117
+ export interface HarnessReadinessProbes {
118
+ /** Is the resolved agent+version installed on the target? */
119
+ agentInstalled?(): boolean;
120
+ /** Is the absolute execution dir a trusted Codex workspace? (Codex agent only.) */
121
+ codexTrusted?(absoluteCwd: string): boolean;
122
+ /** Live auth verdict for the resolved account/version. `ok:false` → agent_auth_failed. */
123
+ authOk?(): {
124
+ ok: boolean;
125
+ reason?: string;
126
+ };
127
+ /** Is the execution target reachable? (host/fleet/cloud placement only.) */
128
+ targetReachable?(): boolean;
129
+ }
130
+ export interface RoutineReadinessResult {
131
+ context: ResolvedExecutionContext;
132
+ ready: boolean;
133
+ readiness?: RoutineReadiness;
134
+ }
135
+ /**
136
+ * Layer the harness/target readiness codes onto a resolved execution context.
137
+ * Context blockers short-circuit (no point probing auth for a routine that has
138
+ * no directory to run in). Every probe is optional and injected; an omitted
139
+ * probe is treated as "not applicable / passes" so a caller only pays for the
140
+ * checks it wires up.
141
+ */
142
+ export declare function evaluateRoutineReadiness(context: ResolvedExecutionContext, probes?: HarnessReadinessProbes, opts?: {
143
+ agent?: string;
144
+ }): RoutineReadinessResult;