@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -13,9 +13,10 @@
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
- import { getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, ensureAgentsDir, } from './state.js';
16
+ import { getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, ensureAgentsDir, getRuntimeStateDir, } from './state.js';
17
17
  import { agentConfigDirName } from './agents.js';
18
18
  import { supports } from './capabilities.js';
19
+ import { claudeProjectDirName } from './project-key.js';
19
20
  /** User-layer memory root (~/.agents/memory/). */
20
21
  export function getUserMemoryDir() {
21
22
  return path.join(getUserAgentsDir(), 'memory');
@@ -273,3 +274,81 @@ export function syncMemoryToVersionHome(agent, versionHome, cwd = process.cwd())
273
274
  catch { /* best-effort */ }
274
275
  return written;
275
276
  }
277
+ /**
278
+ * Canonical shared dir for Claude Code's NATIVE per-project auto-memory —
279
+ * `<versionHome>/.claude/projects/<project-key>/memory/*.md`, the freeform
280
+ * notes Claude writes for itself during a session. Distinct from the layered
281
+ * `memory` resource above (~/.agents/memory/ facts synced into
282
+ * `.claude/memory/`): this dir is keyed by project (via
283
+ * {@link claudeProjectDirName}), not by agent version, and Claude Code itself
284
+ * decides what goes in it — agents-cli only makes the directory
285
+ * version-independent, never writes into it.
286
+ */
287
+ export function getClaudeProjectMemoryDir(cwd) {
288
+ const projectKey = claudeProjectDirName(path.resolve(cwd));
289
+ return path.join(getRuntimeStateDir(), 'claude-project-memory', projectKey);
290
+ }
291
+ /**
292
+ * Make Claude Code's native per-project memory dir version-independent by
293
+ * symlinking `<versionHome>/.claude/projects/<project-key>/memory/` into the
294
+ * one canonical dir every installed Claude version's home shares for this
295
+ * project (PHNX-2817). Without this, `getVersionHomePath` gives every
296
+ * installed version its own isolated HOME, so a note written under one
297
+ * version is invisible under another — the directory is just empty there.
298
+ *
299
+ * Idempotent and safe to call on every sync: a dir already linked to the
300
+ * canonical target is left alone; a PRE-EXISTING real directory with content
301
+ * (the common case today, since this bug has always left one behind) has its
302
+ * files migrated into the canonical dir first — never discarded — before
303
+ * being replaced by the symlink.
304
+ */
305
+ export function syncClaudeProjectMemoryDir(versionHome, cwd = process.cwd()) {
306
+ const projectKey = claudeProjectDirName(path.resolve(cwd));
307
+ const canonicalDir = path.join(getRuntimeStateDir(), 'claude-project-memory', projectKey);
308
+ const projectDir = path.join(versionHome, agentConfigDirName('claude'), 'projects', projectKey);
309
+ const nativeMemoryDir = path.join(projectDir, 'memory');
310
+ fs.mkdirSync(canonicalDir, { recursive: true, mode: 0o700 });
311
+ let existing;
312
+ try {
313
+ existing = fs.lstatSync(nativeMemoryDir);
314
+ }
315
+ catch { /* nothing there yet */ }
316
+ if (existing?.isSymbolicLink()) {
317
+ let currentTarget;
318
+ try {
319
+ currentTarget = fs.readlinkSync(nativeMemoryDir);
320
+ }
321
+ catch { /* dangling link */ }
322
+ if (currentTarget === canonicalDir)
323
+ return; // already wired correctly
324
+ fs.unlinkSync(nativeMemoryDir); // stale/foreign link — replace below
325
+ }
326
+ else if (existing?.isDirectory()) {
327
+ // Migrate first (never clobber content already promoted to canonical by
328
+ // an earlier-synced version home), then remove the now-redundant copy.
329
+ fs.cpSync(nativeMemoryDir, canonicalDir, { recursive: true, force: false, errorOnExist: false });
330
+ fs.rmSync(nativeMemoryDir, { recursive: true, force: true });
331
+ }
332
+ else if (existing) {
333
+ return; // an unexpected file at this path — leave it alone rather than destroy it
334
+ }
335
+ fs.mkdirSync(projectDir, { recursive: true });
336
+ try {
337
+ fs.symlinkSync(canonicalDir, nativeMemoryDir, process.platform === 'win32' ? 'junction' : undefined);
338
+ }
339
+ catch (err) {
340
+ // A concurrent sync (e.g. two `agents run claude` launches racing on a
341
+ // first-ever project) can win this exact link between our lstat above
342
+ // and this call. If it landed the same canonical target, that's the
343
+ // outcome we wanted — treat it as success rather than throwing.
344
+ if (err?.code !== 'EEXIST')
345
+ throw err;
346
+ let racedTarget;
347
+ try {
348
+ racedTarget = fs.readlinkSync(nativeMemoryDir);
349
+ }
350
+ catch { /* not even a symlink — fall through to rethrow */ }
351
+ if (racedTarget !== canonicalDir)
352
+ throw err;
353
+ }
354
+ }
@@ -84,6 +84,17 @@ export interface ActionConfig {
84
84
  notifyChannel?: string;
85
85
  /** webhook-out: URL to POST the event to. */
86
86
  url?: string;
87
+ /**
88
+ * Shell command that must exit 0 after a `run`/`routine` action settles.
89
+ * Asserts the stated effect actually happened (PHNX-2842) — e.g.
90
+ * `gh pr view 1682 --json state --jq .state | grep -qx MERGED`. `{event}` is
91
+ * replaced with the fired event summary, same as the prompt. Evaluated once
92
+ * the dispatched run is no longer `running`; a failed check makes the fire
93
+ * `ok: false` with effect `none` (`postcondition not met`), not a healthy
94
+ * `completed`. Notify/webhook-out already have a synchronous ok and refuse
95
+ * this field.
96
+ */
97
+ postcondition?: string;
87
98
  }
88
99
  /** Full monitor configuration (persisted as YAML in ~/.agents/monitors/). */
89
100
  export interface MonitorConfig {
@@ -238,6 +238,14 @@ export function validateMonitor(config) {
238
238
  errors.push(`action.url must be an absolute URL (got ${JSON.stringify(action.url)})`);
239
239
  }
240
240
  }
241
+ if (action.postcondition !== undefined) {
242
+ if (action.type !== 'run' && action.type !== 'routine') {
243
+ errors.push("action.postcondition only applies to run or routine actions");
244
+ }
245
+ else if (typeof action.postcondition !== 'string' || action.postcondition.trim() === '') {
246
+ errors.push('action.postcondition must be a non-empty shell command');
247
+ }
248
+ }
241
249
  }
242
250
  // ─── PLACEMENT ───────────────────────────────────────────────────────────────
243
251
  if (config.device !== undefined && config.devices !== undefined) {
@@ -14,7 +14,7 @@
14
14
  import { listMonitors, monitorRunsOnThisDevice, parseInterval, setMonitorEnabled, } from './config.js';
15
15
  import { evaluateSource } from './sources/index.js';
16
16
  import { hasChanged, readState, writeState, recordFireTime, writeFireRecord, recordCheck, markDroughtNotified, } from './state.js';
17
- import { dispatchAction } from './dispatch.js';
17
+ import { dispatchAction, injectEvent } from './dispatch.js';
18
18
  import { sendToOwner } from '../notify.js';
19
19
  import { readRunMeta } from '../scheduling/routines.js';
20
20
  /** How often the engine wakes to check which monitors are due. */
@@ -282,12 +282,19 @@ export class MonitorEngine {
282
282
  // exactly the fires whose `ok` needs revisiting; `resolveFireOutcome`
283
283
  // (state.ts) never trusts this field — it re-reads the run fresh instead.
284
284
  const runStatusAtFire = result.runId ? readRunMeta(monitor.name, result.runId)?.status : undefined;
285
+ // Snapshot the postcondition with `{event}` already interpolated so a later
286
+ // `resolveFireOutcome` can assert the stated effect without re-reading YAML
287
+ // (PHNX-2842). Notify/webhook-out have no run to settle, so they skip this.
288
+ const postcondition = (result.kind === 'run' || result.kind === 'routine') && monitor.action.postcondition
289
+ ? injectEvent(monitor.action.postcondition, event)
290
+ : undefined;
285
291
  writeFireRecord(event, {
286
292
  ...(result.runId ? { runId: result.runId } : {}),
287
293
  action: result.kind,
288
294
  ok: result.ok,
289
295
  ...(result.error ? { error: result.error } : {}),
290
296
  ...(runStatusAtFire ? { runStatusAtFire } : {}),
297
+ ...(postcondition ? { postcondition } : {}),
291
298
  });
292
299
  writeState(monitor.name, decision.value, decision.dedupeKey, { lastFiredAt: event.firedAt, fireTimes });
293
300
  this.logFn(result.ok ? 'INFO' : 'ERROR', `monitor '${monitor.name}' fired → ${result.kind}` +
@@ -118,6 +118,16 @@ export interface FireRecord extends MonitorEvent {
118
118
  * run fresh on every call instead of trusting this snapshot.
119
119
  */
120
120
  runStatusAtFire?: RunMeta['status'];
121
+ /**
122
+ * The action's postcondition command, snapshotted at fire time with `{event}`
123
+ * already interpolated (PHNX-2842). `resolveFireOutcome` runs this once the
124
+ * dispatched run has settled `completed`.
125
+ */
126
+ postcondition?: string;
127
+ /** Result of the postcondition check, persisted after the first evaluation. */
128
+ postconditionOk?: boolean;
129
+ /** stderr/stdout snippet when `postconditionOk` is false. */
130
+ postconditionError?: string;
121
131
  }
122
132
  /** List a monitor's fire history, chronologically ascending. */
123
133
  export declare function listFires(name: string): FireRecord[];
@@ -127,10 +137,28 @@ export interface ReconciledFireOutcome {
127
137
  ok: boolean;
128
138
  /** The run's live terminal status, when a runId is present and resolvable. */
129
139
  runStatus?: RunMeta['status'];
140
+ /**
141
+ * Present when a `completed` run had a postcondition to assert (PHNX-2842).
142
+ * `met` = the command exited 0; `none` = ran but the intended effect did not
143
+ * happen (the fire must not read as `ok`).
144
+ */
145
+ effect?: 'met' | 'none';
146
+ /** Why the fire is not ok, when the postcondition failed. */
147
+ error?: string;
130
148
  }
149
+ /**
150
+ * Run a fire's postcondition command. Exit 0 means the intended effect happened;
151
+ * anything else (nonzero, timeout, spawn error, empty command) is "no effect".
152
+ * Real `/bin/sh -c` (or `cmd /c`) — the same seam command sources use.
153
+ */
154
+ export declare function evaluatePostcondition(command: string): {
155
+ ok: boolean;
156
+ error?: string;
157
+ };
131
158
  /**
132
159
  * Reconcile a fire's frozen `ok` against its dispatched run's REAL, current
133
- * status — the render-time fix for RUSH-2690.
160
+ * status — the render-time fix for RUSH-2690 — and, when the run has settled
161
+ * `completed`, against a declared postcondition (PHNX-2842).
134
162
  *
135
163
  * `writeFireRecord` (this module) persists `ok` once, at fire time, from
136
164
  * `dispatchAction`'s synchronous return. For a `run`/`routine` action that
@@ -146,5 +174,13 @@ export interface ReconciledFireOutcome {
146
174
  * A fire with no `runId` (a `notify`/`webhook-out` action, or a `run`/`routine`
147
175
  * dispatch that never got a runId at all) has nothing to reconcile against —
148
176
  * its frozen `ok` is the only signal and is returned as-is.
177
+ *
178
+ * `completed` is not success by itself. An agent that exits 0 without doing
179
+ * the job (merge-on-green that never merged) used to record `ok` because
180
+ * `OK_RUN_STATUSES` treated `completed` as healthy. When the fire carries a
181
+ * `postcondition` command, this function runs it once the run has settled and
182
+ * returns `ok: false, effect: 'none'` when it fails — distinguishing "ran but
183
+ * no effect" from a working fire. The result is persisted on the fire record
184
+ * so later listings do not re-exec the command.
149
185
  */
150
186
  export declare function resolveFireOutcome(jobName: string, fire: FireRecord): ReconciledFireOutcome;
@@ -13,6 +13,7 @@
13
13
  import * as fs from 'fs';
14
14
  import * as path from 'path';
15
15
  import { createHash } from 'crypto';
16
+ import { spawnSync } from 'child_process';
16
17
  import { getMonitorsHistoryDir, ensureAgentsDir } from '../state.js';
17
18
  import { safeJoin } from '../paths.js';
18
19
  import { readRunMeta } from '../scheduling/routines.js';
@@ -199,11 +200,50 @@ export function listFires(name) {
199
200
  }
200
201
  return fires;
201
202
  }
202
- /** Run statuses that read as a healthy fire: still in flight, or settled clean. */
203
- const OK_RUN_STATUSES = new Set(['running', 'completed']);
203
+ /** Cap a display-time postcondition so `monitors runs` cannot hang on a stuck command. */
204
+ const POSTCONDITION_TIMEOUT_MS = 15_000;
205
+ /**
206
+ * Run a fire's postcondition command. Exit 0 means the intended effect happened;
207
+ * anything else (nonzero, timeout, spawn error, empty command) is "no effect".
208
+ * Real `/bin/sh -c` (or `cmd /c`) — the same seam command sources use.
209
+ */
210
+ export function evaluatePostcondition(command) {
211
+ const trimmed = command.trim();
212
+ if (!trimmed)
213
+ return { ok: false, error: 'postcondition not met: empty command' };
214
+ const [bin, args] = process.platform === 'win32'
215
+ ? ['cmd', ['/c', trimmed]]
216
+ : ['/bin/sh', ['-c', trimmed]];
217
+ const result = spawnSync(bin, args, {
218
+ encoding: 'utf-8',
219
+ timeout: POSTCONDITION_TIMEOUT_MS,
220
+ maxBuffer: 8 * 1024 * 1024,
221
+ env: { ...process.env, CLICOLOR: '0', NO_COLOR: '1', FORCE_COLOR: '0' },
222
+ });
223
+ if (result.status === 0)
224
+ return { ok: true };
225
+ if (result.error) {
226
+ const err = result.error;
227
+ if (err.code === 'ETIMEDOUT') {
228
+ return { ok: false, error: `postcondition not met: timed out after ${POSTCONDITION_TIMEOUT_MS / 1000}s` };
229
+ }
230
+ return { ok: false, error: `postcondition not met: ${err.message}` };
231
+ }
232
+ const detail = (result.stderr || result.stdout || '').trim().replace(/\s+/g, ' ').slice(0, 200);
233
+ const exit = result.status ?? 'unknown';
234
+ return { ok: false, error: `postcondition not met${detail ? `: ${detail}` : ` (exit ${exit})`}` };
235
+ }
236
+ /** Persist the postcondition result onto the existing fire record. Frozen `ok` is left as the fire-time snapshot. */
237
+ function persistPostcondition(fire, result) {
238
+ writeFireRecord(fire, {
239
+ postconditionOk: result.ok,
240
+ ...(result.error ? { postconditionError: result.error } : {}),
241
+ });
242
+ }
204
243
  /**
205
244
  * Reconcile a fire's frozen `ok` against its dispatched run's REAL, current
206
- * status — the render-time fix for RUSH-2690.
245
+ * status — the render-time fix for RUSH-2690 — and, when the run has settled
246
+ * `completed`, against a declared postcondition (PHNX-2842).
207
247
  *
208
248
  * `writeFireRecord` (this module) persists `ok` once, at fire time, from
209
249
  * `dispatchAction`'s synchronous return. For a `run`/`routine` action that
@@ -219,6 +259,14 @@ const OK_RUN_STATUSES = new Set(['running', 'completed']);
219
259
  * A fire with no `runId` (a `notify`/`webhook-out` action, or a `run`/`routine`
220
260
  * dispatch that never got a runId at all) has nothing to reconcile against —
221
261
  * its frozen `ok` is the only signal and is returned as-is.
262
+ *
263
+ * `completed` is not success by itself. An agent that exits 0 without doing
264
+ * the job (merge-on-green that never merged) used to record `ok` because
265
+ * `OK_RUN_STATUSES` treated `completed` as healthy. When the fire carries a
266
+ * `postcondition` command, this function runs it once the run has settled and
267
+ * returns `ok: false, effect: 'none'` when it fails — distinguishing "ran but
268
+ * no effect" from a working fire. The result is persisted on the fire record
269
+ * so later listings do not re-exec the command.
222
270
  */
223
271
  export function resolveFireOutcome(jobName, fire) {
224
272
  if (!fire.runId)
@@ -226,5 +274,32 @@ export function resolveFireOutcome(jobName, fire) {
226
274
  const run = readRunMeta(jobName, fire.runId);
227
275
  if (!run)
228
276
  return { ok: fire.ok !== false };
229
- return { ok: OK_RUN_STATUSES.has(run.status), runStatus: run.status };
277
+ if (run.status === 'running')
278
+ return { ok: true, runStatus: run.status };
279
+ if (run.status !== 'completed')
280
+ return { ok: false, runStatus: run.status };
281
+ if (!fire.postcondition)
282
+ return { ok: true, runStatus: 'completed' };
283
+ if (fire.postconditionOk === true) {
284
+ return { ok: true, runStatus: 'completed', effect: 'met' };
285
+ }
286
+ if (fire.postconditionOk === false) {
287
+ return {
288
+ ok: false,
289
+ runStatus: 'completed',
290
+ effect: 'none',
291
+ ...(fire.postconditionError ? { error: fire.postconditionError } : {}),
292
+ };
293
+ }
294
+ const result = evaluatePostcondition(fire.postcondition);
295
+ persistPostcondition(fire, result);
296
+ fire.postconditionOk = result.ok;
297
+ if (result.error)
298
+ fire.postconditionError = result.error;
299
+ return {
300
+ ok: result.ok,
301
+ runStatus: 'completed',
302
+ effect: result.ok ? 'met' : 'none',
303
+ ...(result.error ? { error: result.error } : {}),
304
+ };
230
305
  }
@@ -7,6 +7,8 @@ export declare const KIRO_CAPABILITY_BY_TOOL: Record<string, string | undefined>
7
7
  export declare const CANONICAL_TO_OPENCLAW_TOOL: Record<string, string>;
8
8
  /** Canonical tool -> Antigravity's action namespace. */
9
9
  export declare const ANTIGRAVITY_ACTION_BY_TOOL: Record<string, string | undefined>;
10
+ /** Filename Codex stores generated deny rules under `.codex/rules/`. */
11
+ export declare const CODEX_RULES_FILENAME = "agents-deny.rules";
10
12
  /**
11
13
  * Strip JSON comments for JSONC parsing, only OUTSIDE string literals.
12
14
  *
@@ -97,6 +97,8 @@ const CANONICAL_BY_GROK_TOOL = invertFirstWins(GROK_TOOL_BY_CANONICAL);
97
97
  const CANONICAL_BY_KIRO_CAPABILITY = invertFirstWins(KIRO_CAPABILITY_BY_TOOL);
98
98
  const CANONICAL_BY_OPENCLAW_TOOL = invertFirstWins(CANONICAL_TO_OPENCLAW_TOOL);
99
99
  const CANONICAL_BY_ANTIGRAVITY_ACTION = invertFirstWins(ANTIGRAVITY_ACTION_BY_TOOL);
100
+ /** Filename Codex stores generated deny rules under `.codex/rules/`. */
101
+ export const CODEX_RULES_FILENAME = 'agents-deny.rules';
100
102
  /** Canonical TitleCase spelling for a lowercased canonical tool name. */
101
103
  const CANONICAL_TOOL_CASE = {
102
104
  bash: 'Bash',
@@ -248,6 +250,106 @@ function existingOr(preferred, alternate) {
248
250
  function stringList(value) {
249
251
  return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
250
252
  }
253
+ /**
254
+ * Invert `convertDenyToCodexRules` (permissions.ts). The writer emits
255
+ * `prefix_rule(pattern=["git", "reset"], decision="forbidden")` from a
256
+ * canonical `Bash(git reset:*)`. Prefix-match is the native form of `:*`,
257
+ * so the reverse always reconstructs `Bash(<parts>:*)`.
258
+ *
259
+ * Only `decision = "forbidden"` is a deny; anything else is skipped rather
260
+ * than guessed into an allow (Codex grants come from sandbox_mode).
261
+ */
262
+ function convertCodexRulesToDeny(content) {
263
+ const deny = [];
264
+ const marker = 'prefix_rule';
265
+ let searchFrom = 0;
266
+ while (searchFrom < content.length) {
267
+ const start = content.indexOf(marker, searchFrom);
268
+ if (start < 0)
269
+ break;
270
+ const open = content.indexOf('(', start + marker.length);
271
+ if (open < 0)
272
+ break;
273
+ const bodyEnd = findMatchingCloser(content, open, '(', ')');
274
+ if (bodyEnd < 0) {
275
+ searchFrom = open + 1;
276
+ continue;
277
+ }
278
+ const body = content.slice(open + 1, bodyEnd);
279
+ searchFrom = bodyEnd + 1;
280
+ const decision = body.match(/decision\s*=\s*"((?:\\.|[^"\\])*)"/);
281
+ if (!decision || decision[1] !== 'forbidden')
282
+ continue;
283
+ const patternKw = body.search(/pattern\s*=/);
284
+ if (patternKw < 0)
285
+ continue;
286
+ const arrayStart = body.indexOf('[', patternKw);
287
+ if (arrayStart < 0)
288
+ continue;
289
+ const arrayEnd = findMatchingCloser(body, arrayStart, '[', ']');
290
+ if (arrayEnd < 0)
291
+ continue;
292
+ let parsed;
293
+ try {
294
+ parsed = JSON.parse(body.slice(arrayStart, arrayEnd + 1));
295
+ }
296
+ catch {
297
+ continue;
298
+ }
299
+ if (!Array.isArray(parsed))
300
+ continue;
301
+ const parts = parsed.filter((p) => typeof p === 'string' && p.length > 0);
302
+ if (parts.length === 0)
303
+ continue;
304
+ deny.push(`Bash(${parts.join(' ')}:*)`);
305
+ }
306
+ return deny;
307
+ }
308
+ /** Index of the closer matching `source[openIdx]`, skipping quoted strings. */
309
+ function findMatchingCloser(source, openIdx, open, close) {
310
+ let depth = 0;
311
+ let inString = false;
312
+ let escape = false;
313
+ for (let i = openIdx; i < source.length; i++) {
314
+ const c = source[i];
315
+ if (inString) {
316
+ if (escape) {
317
+ escape = false;
318
+ continue;
319
+ }
320
+ if (c === '\\') {
321
+ escape = true;
322
+ continue;
323
+ }
324
+ if (c === '"')
325
+ inString = false;
326
+ continue;
327
+ }
328
+ if (c === '"') {
329
+ inString = true;
330
+ continue;
331
+ }
332
+ if (c === open)
333
+ depth++;
334
+ else if (c === close) {
335
+ depth--;
336
+ if (depth === 0)
337
+ return i;
338
+ }
339
+ }
340
+ return -1;
341
+ }
342
+ function readCodexDenyRules(configPath) {
343
+ const rulesPath = path.join(path.dirname(configPath), 'rules', CODEX_RULES_FILENAME);
344
+ if (!fs.existsSync(rulesPath))
345
+ return [];
346
+ try {
347
+ return convertCodexRulesToDeny(fs.readFileSync(rulesPath, 'utf-8'));
348
+ }
349
+ catch {
350
+ return [];
351
+ }
352
+ }
251
353
  /**
252
354
  * Single source of truth for where each allowlist-capable agent stores
253
355
  * permissions and how to read them back. Keys MUST equal
@@ -309,24 +411,24 @@ export const PERMISSION_TARGETS = {
309
411
  codex: {
310
412
  home: (h) => path.join(h, '.codex', 'config.toml'),
311
413
  project: (cwd) => path.join(cwd, '.codex', 'config.toml'),
312
- lossyBecause: 'Codex has no rule list — its sandbox mode is widened into representative blanket grants',
414
+ lossyBecause: 'sandbox mode widens into representative blanket grants; deny round-trips from agents-deny.rules as Bash(prefix:*)',
313
415
  toCanonical(configPath) {
314
416
  const config = readToml(configPath);
315
- if (!config)
316
- return null;
317
417
  const allow = [];
318
- const sandboxMode = config.sandbox_mode;
319
- if (config.approval_policy === 'never' || sandboxMode === 'danger-full-access') {
320
- allow.push('Bash(*)', 'Read(**)', 'Write(**)', 'Edit(**)');
321
- }
322
- else if (sandboxMode === 'workspace-write') {
323
- allow.push('Bash(*)', 'Read(**)');
324
- }
325
- const sw = config.sandbox_workspace_write;
326
- if (sw && typeof sw === 'object' && !Array.isArray(sw) && sw.network_access) {
327
- allow.push('WebSearch(*)', 'WebFetch(*)');
418
+ if (config) {
419
+ const sandboxMode = config.sandbox_mode;
420
+ if (config.approval_policy === 'never' || sandboxMode === 'danger-full-access') {
421
+ allow.push('Bash(*)', 'Read(**)', 'Write(**)', 'Edit(**)');
422
+ }
423
+ else if (sandboxMode === 'workspace-write') {
424
+ allow.push('Bash(*)', 'Read(**)');
425
+ }
426
+ const sw = config.sandbox_workspace_write;
427
+ if (sw && typeof sw === 'object' && !Array.isArray(sw) && sw.network_access) {
428
+ allow.push('WebSearch(*)', 'WebFetch(*)');
429
+ }
328
430
  }
329
- return permissionSet(allow, []);
431
+ return permissionSet(allow, readCodexDenyRules(configPath));
330
432
  },
331
433
  },
332
434
  cursor: {
@@ -1,6 +1,6 @@
1
1
  import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, CursorPermissions, OpenCodePermissions, CodexPermissions } from './types.js';
2
- /** Filename used for Codex Starlark deny-rules generated from permission groups. */
3
- export declare const CODEX_RULES_FILENAME = "agents-deny.rules";
2
+ import { CODEX_RULES_FILENAME } from './permissions-registry.js';
3
+ export { CODEX_RULES_FILENAME };
4
4
  export type ParsedRules = PermissionSet;
5
5
  export declare const COMPUTER_PERMISSION_RULE_PREFIX = "Computer";
6
6
  export declare const COMPUTER_APP_GATED_VERBS: readonly ["screenshot", "describe", "get-text", "launch", "raise", "click", "right-click", "type", "type-text", "key", "drag", "scroll", "ax-action", "focus", "wait"];
@@ -13,6 +13,9 @@ export declare function containsBroadGrants(rules: ParsedRules): {
13
13
  /**
14
14
  * Convert canonical deny rules to Codex Starlark .rules format.
15
15
  * E.g. "Bash(git reset:*)" -> prefix_rule(pattern=["git", "reset"], decision="forbidden")
16
+ *
17
+ * Inverse: `toCanonical` for `PERMISSION_TARGETS.codex` reads
18
+ * `.codex/rules/agents-deny.rules` back into `Bash(<parts>:*)`.
16
19
  */
17
20
  export declare function convertDenyToCodexRules(deny: string[]): string | null;
18
21
  /**
@@ -344,4 +347,3 @@ export declare function saveDefaultPermissionSet(set: PermissionSet): {
344
347
  success: boolean;
345
348
  error?: string;
346
349
  };
347
- export {};
@@ -20,16 +20,9 @@ import { updateGeminiSettings } from './gemini-settings.js';
20
20
  // The canonical<->native tool vocabularies live in the registry, which also owns
21
21
  // the reverse projections — so the serializers below and the readers there can
22
22
  // never disagree about what `fs_read` or `developer__shell` means.
23
- import { ANTIGRAVITY_ACTION_BY_TOOL, CANONICAL_TO_OPENCLAW_TOOL, stripJsonComments, GROK_TOOL_BY_CANONICAL, KIRO_CAPABILITY_BY_TOOL, PERMISSION_TARGETS, readCanonicalPermissions, } from './permissions-registry.js';
23
+ import { ANTIGRAVITY_ACTION_BY_TOOL, CANONICAL_TO_OPENCLAW_TOOL, CODEX_RULES_FILENAME, stripJsonComments, GROK_TOOL_BY_CANONICAL, KIRO_CAPABILITY_BY_TOOL, PERMISSION_TARGETS, readCanonicalPermissions, } from './permissions-registry.js';
24
+ export { CODEX_RULES_FILENAME };
24
25
  const HOME = os.homedir();
25
- // PERMISSIONS_CAPABLE_AGENTS removed — use `capableAgents('allowlist')`
26
- // from lib/capabilities.ts. The capability matrix on AgentConfig is the
27
- // single source of truth. (Per-agent native format details:
28
- // antigravity → ~/.gemini/antigravity-cli/settings.json `permissions.{allow,deny}`
29
- // grok → ~/.grok/config.toml `[permission].rules`
30
- // the writer in `applyPermissionsToVersion` handles the format dispatch.)
31
- /** Filename used for Codex Starlark deny-rules generated from permission groups. */
32
- export const CODEX_RULES_FILENAME = 'agents-deny.rules';
33
26
  export const COMPUTER_PERMISSION_RULE_PREFIX = 'Computer';
34
27
  export const COMPUTER_APP_GATED_VERBS = [
35
28
  'screenshot',
@@ -103,6 +96,9 @@ export function containsBroadGrants(rules) {
103
96
  /**
104
97
  * Convert canonical deny rules to Codex Starlark .rules format.
105
98
  * E.g. "Bash(git reset:*)" -> prefix_rule(pattern=["git", "reset"], decision="forbidden")
99
+ *
100
+ * Inverse: `toCanonical` for `PERMISSION_TARGETS.codex` reads
101
+ * `.codex/rules/agents-deny.rules` back into `Bash(<parts>:*)`.
106
102
  */
107
103
  export function convertDenyToCodexRules(deny) {
108
104
  const rules = [];
@@ -122,6 +118,24 @@ export function convertDenyToCodexRules(deny) {
122
118
  return null;
123
119
  return `# Auto-generated by agents-cli from deny permission groups.\n# Do not edit manually — re-run "agents use" to regenerate.\n\n${rules.join('\n\n')}\n`;
124
120
  }
121
+ /**
122
+ * Write `.codex/rules/agents-deny.rules` to match `deny`, or delete it when
123
+ * the current set has nothing to forbid. The reader (PHNX-2703) now surfaces
124
+ * this file, so a later apply with an empty deny must not leave a stale
125
+ * forbid behind — `agents permissions list codex` would keep reporting it.
126
+ */
127
+ function syncCodexDenyRules(configDir, deny) {
128
+ const rulesPath = path.join(configDir, 'rules', CODEX_RULES_FILENAME);
129
+ const rulesContent = deny && deny.length > 0 ? convertDenyToCodexRules(deny) : null;
130
+ if (rulesContent) {
131
+ fs.mkdirSync(path.dirname(rulesPath), { recursive: true });
132
+ fs.writeFileSync(rulesPath, rulesContent, 'utf-8');
133
+ return;
134
+ }
135
+ if (fs.existsSync(rulesPath)) {
136
+ fs.unlinkSync(rulesPath);
137
+ }
138
+ }
125
139
  /**
126
140
  * Ensure central permissions directory exists.
127
141
  */
@@ -1274,15 +1288,7 @@ function applyCodexPermissions(set, scope = 'user', cwd, merge = true) {
1274
1288
  : newPermissions.sandbox_workspace_write;
1275
1289
  }
1276
1290
  fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
1277
- // Write .rules file for deny permissions
1278
- if (set.deny && set.deny.length > 0) {
1279
- const rulesContent = convertDenyToCodexRules(set.deny);
1280
- if (rulesContent) {
1281
- const rulesDir = path.join(configDir, 'rules');
1282
- fs.mkdirSync(rulesDir, { recursive: true });
1283
- fs.writeFileSync(path.join(rulesDir, CODEX_RULES_FILENAME), rulesContent, 'utf-8');
1284
- }
1285
- }
1291
+ syncCodexDenyRules(configDir, set.deny);
1286
1292
  return { success: true };
1287
1293
  }
1288
1294
  catch (err) {
@@ -1393,15 +1399,7 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1393
1399
  : newPermissions.sandbox_workspace_write;
1394
1400
  }
1395
1401
  fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
1396
- // Write .rules file for deny permissions
1397
- if (set.deny && set.deny.length > 0) {
1398
- const rulesContent = convertDenyToCodexRules(set.deny);
1399
- if (rulesContent) {
1400
- const rulesDir = path.join(configDir, 'rules');
1401
- fs.mkdirSync(rulesDir, { recursive: true });
1402
- fs.writeFileSync(path.join(rulesDir, CODEX_RULES_FILENAME), rulesContent, 'utf-8');
1403
- }
1404
- }
1402
+ syncCodexDenyRules(configDir, set.deny);
1405
1403
  return { success: true };
1406
1404
  }
1407
1405
  if (agentId === 'antigravity') {
@@ -248,13 +248,14 @@ export interface ResolvedProfileRun {
248
248
  */
249
249
  tierNote?: string;
250
250
  /**
251
- * Set when `requestedModel` was a tier token AND this profile resolved it
252
- * against its own `models:` map. Callers that forward a `--model` value
253
- * downstream (e.g. as `ExecOptions.model`) should substitute this in place
254
- * of the original tier token — exec.ts's native tier block only knows how
255
- * to resolve a tier against the HOST agent's own catalog, which is the
256
- * wrong catalog for a profile's own harness identity. Undefined both when
257
- * no tier was requested and when tier resolution degraded (see `tierNote`).
251
+ * Concrete model id callers should forward as `ExecOptions.model`. Set when:
252
+ * - `requestedModel` was a cost-tier token resolved against this profile's
253
+ * `models:` map, OR
254
+ * - no `--model` was requested and this is an OpenCode host whose pin lives
255
+ * in `OPENCODE_MODEL` (OpenCode does not read that env var, so the pin
256
+ * has to become `--model` on the argv).
257
+ * Undefined when the caller already passed a concrete `--model`, or when
258
+ * tier resolution degraded (see `tierNote`).
258
259
  */
259
260
  resolvedModel?: string;
260
261
  }
@@ -164,6 +164,7 @@ const MODEL_ENV_KEYS = [
164
164
  'OPENAI_MODEL',
165
165
  'GEMINI_MODEL',
166
166
  'GROK_MODEL',
167
+ 'OPENCODE_MODEL',
167
168
  ];
168
169
  /** Return the configured model env value for display. */
169
170
  export function profileModelLabel(profile) {
@@ -583,6 +584,17 @@ export function resolveProfileForRun(name, requestedModel) {
583
584
  // the raw tier token downstream and handles the message -- this function
584
585
  // doesn't compete with that canonical fallback for the no-opt-in case.
585
586
  }
587
+ // OpenCode does not honor OPENCODE_MODEL (unlike claude/codex/gemini/grok,
588
+ // whose host CLIs read their MODEL env var). Copy the pin into resolvedModel
589
+ // so callers set ExecOptions.model and buildExecCommand emits `--model`.
590
+ // An explicit `--model` (including a cost-tier token the caller may still
591
+ // discard) wins and is left alone.
592
+ if (resolved.resolvedModel === undefined && !requestedModel && profile.host.agent === 'opencode') {
593
+ const envKey = profileModelEnvKey(profile) ?? modelEnvKeyForHost('opencode');
594
+ const pinned = env[envKey];
595
+ if (pinned)
596
+ resolved.resolvedModel = pinned;
597
+ }
586
598
  return resolved;
587
599
  }
588
600
  /**