@phnx-labs/agents-cli 1.22.62 → 1.22.64

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/dist/lib/exec.js CHANGED
@@ -14,7 +14,7 @@ import { parseTimeout } from './scheduling/routines.js';
14
14
  import { compareVersions, getBinaryPath, getVersionHomePath, isVersionInstalled, listInstalledVersions, resolveVersion } from './installations/versions.js';
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { isTierToken, resolveTier } from './model-tiers.js';
17
- import { createTimer, redactPrompt, redactArgs } from './feed/events.js';
17
+ import { emit, createTimer, redactPrompt, redactArgs } from './feed/events.js';
18
18
  import { sanitizeProcessEnv } from './secrets/bundles.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
20
  import { expandLocalHome } from './project-root.js';
@@ -1151,6 +1151,12 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1151
1151
  // buildExecEnv — `installations/shims.ts` generateShimScript).
1152
1152
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto', env: { AGENT_LAUNCH_ID: launchId } });
1153
1153
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
1154
+ // Pre-launch marker for the SECOND live launch path: the Windows generated
1155
+ // `.cmd` shim delegates here and spawns the harness directly, so without this
1156
+ // a pinned, logged-out version launched via the shim would be invisible —
1157
+ // exactly the blind spot this event closes. Best-effort; re-derives the
1158
+ // signed-in verdict from the pinned version home (no rotated pick here).
1159
+ await emitRunLaunch({ agent, version, resolvedVia: 'shim' });
1154
1160
  return new Promise((resolve) => {
1155
1161
  const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
1156
1162
  // Record the launch so `ag sessions --active` can attribute the agent
@@ -1633,15 +1639,88 @@ function emitResolvedSessionId(options, launchId, childPid) {
1633
1639
  process.stdout.write(sessionIdMarkerLine(sessionId));
1634
1640
  }
1635
1641
  /**
1636
- * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
1642
+ * Build the `run.launch` event payload. Pure and exported so the signedIn ->
1643
+ * launchedLoggedOut mapping is unit-testable without spawning. Mirrors the
1644
+ * buildRotationDecisionEvent / emitRotationDecision split in rotate.ts.
1637
1645
  *
1638
- * Stderr is always piped so the caller can inspect it (e.g., for rate-limit
1639
- * detection) while also forwarding every chunk to process.stderr in real time --
1640
- * the user sees the same output they would with stdio: 'inherit'. Stdout keeps
1641
- * the original behavior: 'pipe' when downstream output is piped (so `agents
1642
- * run ... | ...` composes cleanly), otherwise 'inherit' so TTY output is
1643
- * unbuffered.
1646
+ * `launchedLoggedOut` is the headline flag — true ONLY when `signedIn` was
1647
+ * resolved to false (a launch into a logged-out version, the yosemite-m3 2.1.219
1648
+ * incident). An UNKNOWN verdict (`signedIn === null`) is never treated as
1649
+ * logged out.
1644
1650
  */
1651
+ export function buildRunLaunchPayload(input) {
1652
+ return {
1653
+ module: 'run',
1654
+ agent: input.agent,
1655
+ ...(input.harnessName ? { harnessName: input.harnessName } : {}),
1656
+ // Omitted only when no version could be resolved at all — the typed `version`
1657
+ // field can't carry null, and an absent version is honest.
1658
+ version: input.version,
1659
+ strategy: input.strategy ?? null,
1660
+ signedIn: input.signedIn,
1661
+ launchedLoggedOut: input.signedIn === false,
1662
+ email: input.email,
1663
+ ...(input.resolvedVia ? { resolvedVia: input.resolvedVia } : {}),
1664
+ };
1665
+ }
1666
+ /**
1667
+ * Emit the pre-launch `run.launch` observability event just before the harness
1668
+ * child is spawned. Best-effort by construction — the whole point is that it can
1669
+ * NEVER break a launch, so every step (the signed-in probe, the emit itself) is
1670
+ * wrapped and a failure is swallowed. Mirrors the non-fatal pattern of
1671
+ * recordDispatchedRun and emitRotationDecision.
1672
+ *
1673
+ * The `signedIn` verdict is for the SPECIFIC launched version on THIS device, so
1674
+ * `launchedLoggedOut` catches a launch into a logged-out version that
1675
+ * `--device auto` readiness let through (the yosemite-m3 2.1.219 incident). It is
1676
+ * REUSED from `ctx.launchSignedIn` when the caller already computed it via the
1677
+ * identical gate (the rotated pick — no double fs read, no disagreement window),
1678
+ * and only otherwise re-derived via {@link isVersionLaunchableHere} (dynamically
1679
+ * imported so the spawn path takes on no static dependency on the accounting
1680
+ * layer).
1681
+ */
1682
+ async function emitRunLaunch(ctx) {
1683
+ try {
1684
+ let signedIn;
1685
+ let email;
1686
+ if (ctx.launchSignedIn !== undefined) {
1687
+ // Caller already computed the verdict for this exact version via the same
1688
+ // gate — reuse it verbatim.
1689
+ signedIn = ctx.launchSignedIn;
1690
+ email = ctx.launchEmail ?? null;
1691
+ }
1692
+ else {
1693
+ // Pinned-default / shim paths: probe the version home now. Without a
1694
+ // concrete version we cannot probe, so leave the verdict unknown (null)
1695
+ // rather than guess.
1696
+ signedIn = null;
1697
+ email = null;
1698
+ if (ctx.version) {
1699
+ try {
1700
+ const { isVersionLaunchableHere } = await import('./accounting/rotate.js');
1701
+ const state = await isVersionLaunchableHere(ctx.agent, ctx.version);
1702
+ signedIn = state.launchable;
1703
+ email = state.email;
1704
+ }
1705
+ catch {
1706
+ /* probe unavailable — record the launch without the signed-in verdict */
1707
+ }
1708
+ }
1709
+ }
1710
+ emit('run.launch', buildRunLaunchPayload({
1711
+ agent: ctx.agent,
1712
+ harnessName: ctx.harnessName,
1713
+ version: ctx.version,
1714
+ strategy: ctx.strategy,
1715
+ signedIn,
1716
+ email,
1717
+ resolvedVia: ctx.resolvedVia,
1718
+ }));
1719
+ }
1720
+ catch {
1721
+ /* observability must never break a launch */
1722
+ }
1723
+ }
1645
1724
  async function spawnAgent(options) {
1646
1725
  // Assign a known session id up front for agents that accept one, so the
1647
1726
  // launcher can record an EXACT pid -> session mapping (see pid-registry) —
@@ -1758,6 +1837,23 @@ async function spawnAgent(options) {
1758
1837
  timer.end({ exitCode: 1, status: 'failed', error: 'remote interactive run has no tmux for durability' });
1759
1838
  return { exitCode: 1, stdout: '', stderr: msg };
1760
1839
  }
1840
+ // Pre-launch marker (`run.launch`), fired on THIS device right before the
1841
+ // harness child is spawned — for BOTH the tmux-wrapped and bare paths below,
1842
+ // and only once the undurable refusal above has passed (a refused run never
1843
+ // spawns). Unlike `run.dispatched` (recordDispatchedRun, at FINALIZE), this
1844
+ // records a launch that then sits stuck at a login screen and never finalizes,
1845
+ // so a launch into a logged-out version is visible instead of silent. The
1846
+ // signed-in verdict is REUSED from the rotated pick when the command threaded
1847
+ // it (options.launchSignedIn), else re-derived from the version home.
1848
+ await emitRunLaunch({
1849
+ agent: options.agent,
1850
+ harnessName: options.harnessName,
1851
+ version: options.version ?? resolveVersion(options.agent, options.cwd || process.cwd()) ?? undefined,
1852
+ strategy: options.strategy,
1853
+ resolvedVia: options.resolvedVia,
1854
+ launchSignedIn: options.launchSignedIn,
1855
+ launchEmail: options.launchEmail,
1856
+ });
1761
1857
  if (tmuxWrap.kind === 'wrap') {
1762
1858
  timer.mark('startup');
1763
1859
  try {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ActorKind } from '../actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'run.launch' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
18
  export declare const EVENT_TYPES: readonly EventType[];
19
19
  /** Runtime guard for an event kind arriving from outside this process. */
@@ -121,6 +121,7 @@ const FILE_MODE = 0o600;
121
121
  const EVENT_TYPE_TABLE = {
122
122
  'agent.run.start': true, 'agent.run.end': true, 'agent.spawn.start': true, 'agent.spawn.end': true,
123
123
  'run.dispatched': true,
124
+ 'run.launch': true,
124
125
  'daemon.start': true, 'daemon.stop': true, 'daemon.error': true, 'daemon.info': true,
125
126
  'routine.start': true, 'routine.end': true,
126
127
  'watchdog.action': true,
@@ -158,6 +159,10 @@ export function isEventType(value) {
158
159
  const AUDIT_EVENTS = new Set([
159
160
  'command.start', 'command.end',
160
161
  'run.dispatched',
162
+ // Sibling of run.dispatched, and the MORE reliable signal for a stuck /
163
+ // logged-out launch (it fires pre-spawn, before an agent can hang at a login
164
+ // screen and never finalize). Must surface under --level audit --include runs.
165
+ 'run.launch',
161
166
  'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view', 'secrets.lease-denied', 'secrets.lease-expire',
162
167
  'secrets.set', 'secrets.delete', 'secrets.rename',
163
168
  'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
@@ -21,6 +21,13 @@ export interface FeedSinkConfig {
21
21
  channel?: string;
22
22
  /** Recipient for a `channel` sink. Required unless `channel` is the `owner` alias. */
23
23
  to?: string;
24
+ /**
25
+ * Optional channel body template. Uses the same placeholders as `command`
26
+ * argv (`{message}`, `{ticket}`, `{project}`, ...). Defaults to `{message}`.
27
+ * A missing placeholder skips the sink, which lets a `{ticket}` template
28
+ * declare that only ticket-backed posts belong in that destination.
29
+ */
30
+ message?: string;
24
31
  /** Lowest post level that reaches this sink. Defaults to `milestone` (all posts). */
25
32
  minLevel?: FeedPostLevel;
26
33
  }
@@ -150,6 +157,8 @@ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): stri
150
157
  * would otherwise comment on nothing.
151
158
  */
152
159
  export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
160
+ /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
161
+ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext): string | undefined;
153
162
  /**
154
163
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
155
164
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
@@ -38,6 +38,7 @@ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } fro
38
38
  import { lookupTransport } from './channels/resolve.js';
39
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
40
  import { sendToOwner } from './notify.js';
41
+ import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
41
42
  const LEVEL_RANK = { milestone: 0, important: 1 };
42
43
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
43
44
  export function parseFeedPostLevel(raw) {
@@ -324,6 +325,23 @@ export function renderSinkArgv(template, ctx) {
324
325
  }
325
326
  return argv.length > 0 ? argv : undefined;
326
327
  }
328
+ /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
329
+ export function renderSinkMessage(template, ctx) {
330
+ const vars = templateVars(ctx);
331
+ let missing = false;
332
+ const rendered = template.replace(PLACEHOLDER, (whole, key) => {
333
+ const value = vars[key];
334
+ if (value === undefined || value === '') {
335
+ missing = true;
336
+ return whole;
337
+ }
338
+ return value;
339
+ });
340
+ if (missing)
341
+ return undefined;
342
+ const text = rendered.trim();
343
+ return text || undefined;
344
+ }
327
345
  /**
328
346
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
329
347
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
@@ -350,11 +368,14 @@ export function planFeedBroadcast(config, ctx) {
350
368
  // placeholder below).
351
369
  if (!isOwnerAlias(channel) && !sink.to?.trim())
352
370
  continue;
371
+ const text = renderSinkMessage(sink.message ?? '{message}', ctx);
372
+ if (!text)
373
+ continue;
353
374
  planned.push({
354
375
  name,
355
376
  channel,
356
377
  to: isOwnerAlias(channel) ? undefined : sink.to.trim(),
357
- text: composeBroadcastMessage(ctx),
378
+ text,
358
379
  });
359
380
  continue;
360
381
  }
@@ -480,6 +501,14 @@ async function runChannelSink(sink, meta) {
480
501
  const result = await deliverEnvelope(resolved.envelope, meta);
481
502
  if (result.ok)
482
503
  return { name, ok: true };
504
+ // Explicit rush-backed channel sinks need the same cross-device handoff as
505
+ // the owner alias. Linux workers do not carry the macOS Keychain-bound Rush
506
+ // transport, but feed posts originate on those workers routinely. Keep the
507
+ // destination explicit so the peer delivers exactly this sink once instead
508
+ // of expanding the owner's multi-channel policy.
509
+ const forwarded = await forwardOwnerNotifyToPeer(resolved.envelope.text, resolved.envelope.channel, resolved.envelope.to, meta);
510
+ if (forwarded?.ok)
511
+ return { name, ok: true };
483
512
  return { name, ok: false, error: result.error };
484
513
  }
485
514
  /**
@@ -17,11 +17,12 @@
17
17
  /**
18
18
  * Where the account backend lives. Config, never a literal at a call site.
19
19
  *
20
- * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
21
- * rather than a vanity hostname because no custom domain is attached yet — and
22
- * a default naming an unregistered domain is worse than an ugly one: every
23
- * `agents auth login` would fail DNS with nothing to point at.
20
+ * The default is Phoenix ID's branded custom domain. The legacy workers.dev
21
+ * hostname remains live for already-released clients, but new clients and
22
+ * managed Workers must share this canonical base so token verification cannot
23
+ * drift across the release boundary.
24
24
  */
25
+ export declare const DEFAULT_PHOENIX_ID_BASE = "https://id.byphoenix.com";
25
26
  export declare const PHOENIX_ID_BASE: string;
26
27
  /** Our own session file. agents-cli never reads another product's credentials. */
27
28
  export declare function sessionFilePath(): string;
@@ -20,12 +20,13 @@ import { getRuntimeStateDir } from '../state.js';
20
20
  /**
21
21
  * Where the account backend lives. Config, never a literal at a call site.
22
22
  *
23
- * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
24
- * rather than a vanity hostname because no custom domain is attached yet — and
25
- * a default naming an unregistered domain is worse than an ugly one: every
26
- * `agents auth login` would fail DNS with nothing to point at.
23
+ * The default is Phoenix ID's branded custom domain. The legacy workers.dev
24
+ * hostname remains live for already-released clients, but new clients and
25
+ * managed Workers must share this canonical base so token verification cannot
26
+ * drift across the release boundary.
27
27
  */
28
- export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? 'https://phoenix-id.muqsitnawaz.workers.dev';
28
+ export const DEFAULT_PHOENIX_ID_BASE = 'https://id.byphoenix.com';
29
+ export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? DEFAULT_PHOENIX_ID_BASE;
29
30
  /** Our own session file. agents-cli never reads another product's credentials. */
30
31
  export function sessionFilePath() {
31
32
  return path.join(getRuntimeStateDir(), 'phoenix-session.json');
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Resource inventory — the single chokepoint for "what does this agent@version
3
- * have" (RUSH-2238, parent RUSH-2236; spec: openspec/specs/resource-inventory).
3
+ * have" (RUSH-2238, parent RUSH-2236). Contract: cli/docs/specifications.md.
4
4
  *
5
5
  * One API answers four orthogonal questions per (agent, version, kind):
6
6
  *
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Resource inventory — the single chokepoint for "what does this agent@version
3
- * have" (RUSH-2238, parent RUSH-2236; spec: openspec/specs/resource-inventory).
3
+ * have" (RUSH-2238, parent RUSH-2236). Contract: cli/docs/specifications.md.
4
4
  *
5
5
  * One API answers four orthogonal questions per (agent, version, kind):
6
6
  *
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
59
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -205,6 +205,15 @@ export interface ActiveSession {
205
205
  spawnedTeam?: string;
206
206
  /** Files/screenshots attached to the session prompt. */
207
207
  attachments?: SessionAttachment[];
208
+ /** Total tokens and elapsed duration from the indexed transcript. */
209
+ tokenCount?: number;
210
+ durationMs?: number;
211
+ /** Number of subagents launched, persisted by the index scanner. */
212
+ subAgentCount?: number;
213
+ /** Durable documents created in the live transcript tail. */
214
+ artifacts?: import('./highlights.js').ProducedArtifact[];
215
+ /** Created plan document singled out for plan-first consumers. */
216
+ planFile?: string;
208
217
  sessionFile?: string;
209
218
  startedAtMs?: number;
210
219
  /**
@@ -164,6 +164,12 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
164
164
  s.routineName = m.routineName;
165
165
  if (!s.harness && m.harness)
166
166
  s.harness = m.harness;
167
+ if (s.tokenCount == null && m.tokenCount != null)
168
+ s.tokenCount = m.tokenCount;
169
+ if (s.durationMs == null && m.durationMs != null)
170
+ s.durationMs = m.durationMs;
171
+ if (s.subAgentCount == null && m.subAgentCount != null)
172
+ s.subAgentCount = m.subAgentCount;
167
173
  }
168
174
  }
169
175
  function loadBackfillMetaFor(sessions) {
@@ -851,6 +857,8 @@ function applyState(base, state, fallbackFile, pidAlive) {
851
857
  createdTickets: state.createdTickets,
852
858
  spawnedTeam: state.spawnedTeam,
853
859
  attachments: state.attachments,
860
+ artifacts: state.artifacts,
861
+ planFile: state.planFile,
854
862
  rateLimited: state.rateLimited,
855
863
  };
856
864
  }
@@ -98,9 +98,15 @@ export function classifyFileChanges(events) {
98
98
  deleted.delete(p); // a write after a delete recreates the file
99
99
  }
100
100
  else if (EDIT_TOOLS.has(tool)) {
101
- modified.add(p);
101
+ if (args.patch_op === 'Add')
102
+ created.add(p);
103
+ else if (args.patch_op === 'Delete')
104
+ deleted.add(p);
105
+ else
106
+ modified.add(p);
102
107
  seen.add(p);
103
- deleted.delete(p);
108
+ if (args.patch_op !== 'Delete')
109
+ deleted.delete(p);
104
110
  }
105
111
  }
106
112
  const out = [];
@@ -70,6 +70,10 @@ export declare function parseCodex(filePath: string): SessionEvent[];
70
70
  * Returns every path in order (a multi-file patch emits multiple paths so
71
71
  * artifact discovery sees each file — RUSH-1410). Empty when unparseable.
72
72
  */
73
+ export declare function applyPatchTargets(input: string): Array<{
74
+ path: string;
75
+ op: 'Add' | 'Update' | 'Delete';
76
+ }>;
73
77
  export declare function applyPatchTargetPaths(input: string): string[];
74
78
  /**
75
79
  * Parse Codex JSONL *content* (already read into a string) into normalized
@@ -555,15 +555,18 @@ export function parseCodex(filePath) {
555
555
  * Returns every path in order (a multi-file patch emits multiple paths so
556
556
  * artifact discovery sees each file — RUSH-1410). Empty when unparseable.
557
557
  */
558
- export function applyPatchTargetPaths(input) {
559
- const paths = [];
560
- const re = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm;
558
+ export function applyPatchTargets(input) {
559
+ const targets = [];
560
+ const re = /^\*\*\* (Update|Add|Delete) File: (.+)$/gm;
561
561
  for (const m of input.matchAll(re)) {
562
- const p = m[1].trim();
562
+ const p = m[2].trim();
563
563
  if (p)
564
- paths.push(p);
564
+ targets.push({ path: p, op: m[1] });
565
565
  }
566
- return paths;
566
+ return targets;
567
+ }
568
+ export function applyPatchTargetPaths(input) {
569
+ return applyPatchTargets(input).map((target) => target.path);
567
570
  }
568
571
  /** @deprecated Prefer applyPatchTargetPaths — kept for single-file call sites. */
569
572
  function applyPatchTargetPath(input) {
@@ -724,13 +727,15 @@ export function parseCodexContent(content) {
724
727
  const execCommand = rawName === 'exec' ? codexExecCommand(input) : undefined;
725
728
  // Multi-file patches: one tool_use per file so artifact discovery sees
726
729
  // every path (RUSH-1410). Single-file / non-patch keep one event.
727
- const patchPaths = isApplyPatch ? applyPatchTargetPaths(input) : [];
730
+ const patchTargets = isApplyPatch ? applyPatchTargets(input) : [];
728
731
  const tool = isApplyPatch ? 'Edit' : rawName;
729
732
  const truncatedInput = input.length > 500 ? input.slice(0, 497) + '...' : input;
730
- const emitOne = (patchPath) => {
733
+ const emitOne = (patchPath, patchOp) => {
731
734
  const args = { input: truncatedInput };
732
735
  if (patchPath)
733
736
  args.file_path = patchPath;
737
+ if (patchOp)
738
+ args.patch_op = patchOp;
734
739
  if (execCommand)
735
740
  args.command = execCommand;
736
741
  const callId = payload.call_id || payload.id;
@@ -747,9 +752,9 @@ export function parseCodexContent(content) {
747
752
  path: patchPath,
748
753
  });
749
754
  };
750
- if (isApplyPatch && patchPaths.length > 0) {
751
- for (const p of patchPaths)
752
- emitOne(p);
755
+ if (isApplyPatch && patchTargets.length > 0) {
756
+ for (const target of patchTargets)
757
+ emitOne(target.path, target.op);
753
758
  }
754
759
  else {
755
760
  emitOne(undefined);
@@ -15,6 +15,7 @@
15
15
  * shape + mtime — same function, driven off the normalized events.
16
16
  */
17
17
  import type { SessionAttachment, SessionEvent, TodoItem, TodoProgress } from './types.js';
18
+ import { type ProducedArtifact } from './highlights.js';
18
19
  export type { TodoItem, TodoProgress };
19
20
  export type SessionActivity = 'working' | 'waiting_input' | 'idle';
20
21
  export type AwaitingReason = 'question' | 'plan_review' | 'permission';
@@ -95,6 +96,10 @@ export interface SessionState {
95
96
  * checklist, notably for remote/device-dispatched agents with no local stream.
96
97
  */
97
98
  todos?: TodoProgress;
99
+ /** Durable documents created in the bounded transcript slice. */
100
+ artifacts?: ProducedArtifact[];
101
+ /** First created plan document, for consumers that give plans special treatment. */
102
+ planFile?: string;
98
103
  /** Last few assistant turns (most-recent last), one line each — panel context. */
99
104
  tail?: string[];
100
105
  lastActivityMs?: number;
@@ -17,6 +17,8 @@
17
17
  import * as path from 'path';
18
18
  import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './parse.js';
19
19
  import { isShellExecTool } from './shell-programs.js';
20
+ import { classifyFileChanges } from './digest.js';
21
+ import { extractArtifacts } from './highlights.js';
20
22
  /**
21
23
  * Detect per-session rate-limit / usage-limit signals in assistant or error
22
24
  * text (RUSH-1523). Matches the same shapes the ext's prewarm detectBlockingPrompt
@@ -626,6 +628,8 @@ export function inferSessionState(events, ctx = {}) {
626
628
  const state = inferActivity(events, ctx);
627
629
  const { pr, ticket, createdTickets, spawnedTeam, attachments } = detectDurableSignals(events);
628
630
  const worktree = detectWorktree(ctx.cwd, ctx.gitBranch);
631
+ const artifacts = extractArtifacts(classifyFileChanges(events));
632
+ const planFile = artifacts.find((artifact) => artifact.bucket === 'plans')?.path;
629
633
  // Rate-limit: scan the most recent assistant messages + tool errors (tail-first).
630
634
  let rateLimited = false;
631
635
  for (let i = events.length - 1; i >= 0 && i >= events.length - 12; i--) {
@@ -651,6 +655,8 @@ export function inferSessionState(events, ctx = {}) {
651
655
  createdTickets,
652
656
  spawnedTeam,
653
657
  attachments,
658
+ artifacts: artifacts.length > 0 ? artifacts : undefined,
659
+ planFile,
654
660
  rateLimited: rateLimited || undefined,
655
661
  };
656
662
  }