@aragon-agent/cli 0.6.31 → 0.6.33

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 (95) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +2970 -2826
  3. package/dist/agent/controller.d.ts +46 -0
  4. package/dist/agent/controller.js +175 -7
  5. package/dist/agent/job-notices.d.ts +143 -0
  6. package/dist/agent/job-notices.js +182 -0
  7. package/dist/agent/reducer.d.ts +21 -1
  8. package/dist/agent/reducer.js +7 -0
  9. package/dist/agent/system-prompt.d.ts +12 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +236 -8
  12. package/dist/commands/builtins.js +23 -8
  13. package/dist/commands/perf.d.ts +77 -1
  14. package/dist/commands/perf.js +37 -5
  15. package/dist/config/cli-commands.d.ts +27 -0
  16. package/dist/config/cli-commands.js +54 -1
  17. package/dist/config/env.js +38 -0
  18. package/dist/config/load.js +50 -1
  19. package/dist/config/schema.d.ts +73 -0
  20. package/dist/config/schema.js +34 -0
  21. package/dist/config/store.js +14 -1
  22. package/dist/exec/events.d.ts +29 -3
  23. package/dist/exec/events.js +3 -12
  24. package/dist/exec/runner.d.ts +24 -0
  25. package/dist/exec/runner.js +79 -0
  26. package/dist/input/cursor-report.d.ts +61 -0
  27. package/dist/input/cursor-report.js +97 -0
  28. package/dist/input/stdin-filter.d.ts +22 -0
  29. package/dist/input/stdin-filter.js +48 -3
  30. package/dist/proc/limits.d.ts +76 -2
  31. package/dist/proc/limits.js +83 -2
  32. package/dist/proc/log-mirror.d.ts +59 -0
  33. package/dist/proc/log-mirror.js +203 -0
  34. package/dist/proc/log-ring.d.ts +22 -0
  35. package/dist/proc/log-ring.js +20 -1
  36. package/dist/proc/notices.d.ts +57 -0
  37. package/dist/proc/notices.js +101 -0
  38. package/dist/proc/progress.d.ts +40 -0
  39. package/dist/proc/progress.js +135 -0
  40. package/dist/proc/prompt.d.ts +24 -2
  41. package/dist/proc/prompt.js +51 -2
  42. package/dist/proc/supervisor.d.ts +35 -2
  43. package/dist/proc/supervisor.js +142 -43
  44. package/dist/proc/types.d.ts +89 -1
  45. package/dist/proc/wait.d.ts +57 -0
  46. package/dist/proc/wait.js +133 -0
  47. package/dist/session/persist.js +3 -1
  48. package/dist/session/validate-session.js +17 -0
  49. package/dist/tools/bash-tool.d.ts +12 -1
  50. package/dist/tools/bash-tool.js +100 -13
  51. package/dist/tools/index.d.ts +26 -2
  52. package/dist/tools/index.js +43 -2
  53. package/dist/tools/job-tools.d.ts +54 -0
  54. package/dist/tools/job-tools.js +329 -0
  55. package/dist/tools/proc-tools.d.ts +1 -1
  56. package/dist/tools/proc-tools.js +49 -17
  57. package/dist/ui/ActivityLine.js +7 -0
  58. package/dist/ui/App.js +98 -12
  59. package/dist/ui/ScrollbackShell.d.ts +62 -11
  60. package/dist/ui/ScrollbackShell.js +11 -3
  61. package/dist/ui/StatusBar.d.ts +5 -0
  62. package/dist/ui/StatusBar.js +1 -0
  63. package/dist/ui/Transcript.d.ts +14 -0
  64. package/dist/ui/Transcript.js +14 -2
  65. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  66. package/dist/ui/entries/ServiceCard.js +39 -4
  67. package/dist/ui/layout/status-layout.d.ts +13 -1
  68. package/dist/ui/layout/status-layout.js +10 -0
  69. package/dist/ui/layout/virtual-window.js +14 -1
  70. package/dist/ui/run-status-row.d.ts +12 -0
  71. package/dist/ui/screen.d.ts +17 -4
  72. package/dist/ui/screen.js +14 -6
  73. package/dist/ui/scrollback/LiveTail.d.ts +79 -0
  74. package/dist/ui/scrollback/LiveTail.js +32 -0
  75. package/dist/ui/scrollback/band.d.ts +114 -0
  76. package/dist/ui/scrollback/band.js +130 -0
  77. package/dist/ui/scrollback/budget.d.ts +51 -10
  78. package/dist/ui/scrollback/budget.js +69 -20
  79. package/dist/ui/scrollback/canvas.d.ts +99 -0
  80. package/dist/ui/scrollback/canvas.js +113 -0
  81. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  82. package/dist/ui/scrollback/cursor-probe.js +133 -0
  83. package/dist/ui/scrollback/limits.d.ts +61 -4
  84. package/dist/ui/scrollback/limits.js +61 -4
  85. package/dist/ui/scrollback/live-preview.d.ts +18 -10
  86. package/dist/ui/scrollback/live-preview.js +14 -11
  87. package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
  88. package/dist/ui/scrollback/scrollback-writer.js +368 -82
  89. package/dist/ui/scrollback/tail-window.d.ts +60 -0
  90. package/dist/ui/scrollback/tail-window.js +73 -0
  91. package/dist/ui/scrollback/use-scrollback-view.d.ts +34 -15
  92. package/dist/ui/scrollback/use-scrollback-view.js +110 -59
  93. package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
  94. package/dist/ui/scrollback/writer-shapes.js +53 -8
  95. package/package.json +3 -3
@@ -246,6 +246,31 @@ export declare class AgentController {
246
246
  /** Session changes must not make a delayed receipt match a new message. */
247
247
  private readonly steeringPrefix;
248
248
  private steeringSequence;
249
+ /**
250
+ * OPEN TOOL CALLS IN THIS BATCH (long-task-jobs D-5).
251
+ *
252
+ * COUNTED HERE, NOT BORROWED FROM `FastWiring`'s `TurnFrameRing`: `fast` can be
253
+ * switched off entirely, and a completion notice must not go quiet with it. The
254
+ * cost is a second counter over the same two events, which AC-19 pins with a
255
+ * replay rather than with trust.
256
+ */
257
+ private activeToolCalls;
258
+ /** The completion-notice queue both the push and the pull channel take from. */
259
+ private readonly jobNotices;
260
+ /** The optional on-disk log mirror; `undefined` when `jobs.persistLog` is false. */
261
+ private jobMirror;
262
+ /** The relay: terminal events in, ledger across `steering_accepted`, safe-point push. */
263
+ private jobRelay;
264
+ /** `cfg.jobs.enabled` AND `cfg.bash.background`, fixed at construction. */
265
+ private jobsRegistered;
266
+ /**
267
+ * One mirror directory per PROCESS.
268
+ *
269
+ * The logs are a debugging aid, not a record with an identity: two sessions
270
+ * writing to one directory would interleave the tails of two different runs, and
271
+ * the retention sweep in `log-mirror.ts` keeps the newest few directories.
272
+ */
273
+ private readonly jobSessionTag;
249
274
  constructor(config: CliConfig, deps?: ControllerDeps);
250
275
  private resolveKey;
251
276
  /**
@@ -308,6 +333,16 @@ export declare class AgentController {
308
333
  private waitForEngineIdle;
309
334
  /** Prepare before clearing the plan, then enter the engine without yielding. */
310
335
  private startPrompt;
336
+ /**
337
+ * Deliver undelivered job notices, if any and if it is safe to steer.
338
+ *
339
+ * ONE METHOD, TWO CALL SITES, AND NOT INSIDE `prepareUserTurn` (RV-P1-7).
340
+ * `prepareUserTurn` is called from `startPrompt` AND from `continue` (with the
341
+ * CONCATENATED queued text), and it does three specific things for the engine's
342
+ * benefit. Hanging a delivery path off it would make `fast.setGoal`'s input
343
+ * semantics depend on whether a notice happened to be pending.
344
+ */
345
+ private flushPendingNotices;
311
346
  private prepareUserTurn;
312
347
  /** Cancel pending startup as well as any engine run already in progress. */
313
348
  abort(): void;
@@ -403,6 +438,17 @@ export declare class AgentController {
403
438
  hasPendingUserMessages(): boolean;
404
439
  private compactionBusyOutcome;
405
440
  isRunning(): boolean;
441
+ /**
442
+ * The one condition under which automated text may be injected (I-5).
443
+ *
444
+ * `activeToolCalls === 0` is the whole safety argument: `steer()` has exactly
445
+ * one safe checkpoint - the top of the loop, before the LLM call - so injecting
446
+ * text in the middle of a tool batch replaces the remaining `tool_use` blocks
447
+ * with "skipped due to steering interrupt" and the next request is malformed.
448
+ */
449
+ private isAtSafeSteerPoint;
450
+ /** How many completion notices are waiting for the model. */
451
+ pendingJobNotices(): number;
406
452
  getAgentMode(): AgentMode;
407
453
  /**
408
454
  * Request a mode change and return what was ACTUALLY ADOPTED.
@@ -1,5 +1,6 @@
1
1
  import { createModelSettingsDraft, saveModelSettings, reloadModelSettings, } from './model-profile-settings.js';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
3
4
  import { verifyCompactionIdentity } from '../compaction/memory-identity.js';
4
5
  import { COMPACTION_LIMITS } from '../compaction/limits.js';
5
6
  import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
@@ -20,9 +21,14 @@ import { resolveSubagentModel } from '../team/model.js';
20
21
  import { buildTeamBlock } from '../team/prompt.js';
21
22
  import { buildFastBlock } from '../fast/prompt.js';
22
23
  import { ProcSupervisor } from '../proc/supervisor.js';
23
- import { buildBackgroundServicesBlock } from '../proc/prompt.js';
24
+ import { buildBackgroundJobsBlock, buildBackgroundServicesBlock } from '../proc/prompt.js';
24
25
  import { PROC_LIMITS } from '../proc/limits.js';
26
+ import { JobLogMirror } from '../proc/log-mirror.js';
27
+ import { JobNoticeQueue } from '../proc/notices.js';
25
28
  import { makeBashKill, makeBashOutput } from '../tools/proc-tools.js';
29
+ import { makeJobList, makeJobWait } from '../tools/job-tools.js';
30
+ import { JobNoticeRelay } from './job-notices.js';
31
+ import { getHomeRoot } from '../config/app-paths.js';
26
32
  import { addSignalHook } from '../logging/install.js';
27
33
  import { describeFastTierProblem, fastProviderOf, resolveFastTier } from '../fast/resolve.js';
28
34
  import { FastWiring, offFastStatus } from '../fast/wiring.js';
@@ -271,6 +277,31 @@ export class AgentController {
271
277
  /** Session changes must not make a delayed receipt match a new message. */
272
278
  steeringPrefix = randomUUID();
273
279
  steeringSequence = 0;
280
+ /**
281
+ * OPEN TOOL CALLS IN THIS BATCH (long-task-jobs D-5).
282
+ *
283
+ * COUNTED HERE, NOT BORROWED FROM `FastWiring`'s `TurnFrameRing`: `fast` can be
284
+ * switched off entirely, and a completion notice must not go quiet with it. The
285
+ * cost is a second counter over the same two events, which AC-19 pins with a
286
+ * replay rather than with trust.
287
+ */
288
+ activeToolCalls = 0;
289
+ /** The completion-notice queue both the push and the pull channel take from. */
290
+ jobNotices = new JobNoticeQueue();
291
+ /** The optional on-disk log mirror; `undefined` when `jobs.persistLog` is false. */
292
+ jobMirror;
293
+ /** The relay: terminal events in, ledger across `steering_accepted`, safe-point push. */
294
+ jobRelay;
295
+ /** `cfg.jobs.enabled` AND `cfg.bash.background`, fixed at construction. */
296
+ jobsRegistered = false;
297
+ /**
298
+ * One mirror directory per PROCESS.
299
+ *
300
+ * The logs are a debugging aid, not a record with an identity: two sessions
301
+ * writing to one directory would interleave the tails of two different runs, and
302
+ * the retention sweep in `log-mirror.ts` keeps the newest few directories.
303
+ */
304
+ jobSessionTag = Date.now().toString(36);
274
305
  constructor(config, deps = {}) {
275
306
  this.config = config;
276
307
  this.cwd = config.cwd;
@@ -411,7 +442,22 @@ export class AgentController {
411
442
  // comment. `readyTimeoutMs` is read through a closure rather than captured,
412
443
  // so a live config change reaches the next service rather than the next
413
444
  // launch of the CLI.
414
- this.procs = new ProcSupervisor({ readyTimeoutMs: () => this.config.bash.readyTimeoutMs });
445
+ // `jobs` is the FIRST section whose registration depends on two keys: the
446
+ // tools need a supervisor to talk to (`bash.background`) and their own switch.
447
+ // With either off, no `job_*` tool, no prompt block, no mirror - which is what
448
+ // makes I-3 (byte-identical when off) measurable rather than asserted.
449
+ this.jobsRegistered = config.jobs.enabled && config.bash.background;
450
+ this.jobMirror =
451
+ this.jobsRegistered && config.jobs.persistLog
452
+ ? new JobLogMirror({ root: () => join(getHomeRoot(), 'jobs', this.jobSessionTag) })
453
+ : undefined;
454
+ this.procs = new ProcSupervisor({
455
+ readyTimeoutMs: () => this.config.bash.readyTimeoutMs,
456
+ // Live closures, not captured values: a `config set` must reach the card of
457
+ // the job already running.
458
+ stallMs: () => this.config.jobs.stallSeconds * 1000,
459
+ ...(this.jobMirror ? { mirror: this.jobMirror } : {}),
460
+ });
415
461
  this.notifyHostFn = deps.notify;
416
462
  this.bashBackgroundRegistered = config.bash.background;
417
463
  // THE EXIT REAPER (G5 / P1-2 / R-10). An APPEND-ONLY hook rather than a
@@ -420,6 +466,22 @@ export class AgentController {
420
466
  // that leaves the user staring at a blank alternate screen. `reapSync` is
421
467
  // the only entry point it calls, and it is synchronous end to end (I-9).
422
468
  this.releaseSignalHook = addSignalHook(() => this.procs.reapSync());
469
+ // THE RELAY IS BUILT HERE, BEFORE THE TOOLS, because the tools take its
470
+ // `drain` as the PULL port and the supervisor's stream as their source. Its
471
+ // `steer` is the same receipt-less path `FastWiring` already uses, and its
472
+ // safe-point test is this class's own batch counter (D-5).
473
+ this.jobRelay = new JobNoticeRelay({
474
+ queue: this.jobNotices,
475
+ steer: (text) => this.enqueueSteering(text, false),
476
+ isAtSafeSteerPoint: () => this.isAtSafeSteerPoint(),
477
+ // The FILL channel precondition: no tool batch open, nothing being aborted -
478
+ // and deliberately no isRunning(), because this path runs just BEFORE
479
+ // agent.prompt() / agent.continue(), where the engine is idle by contract.
480
+ // The dep documents why that one missing clause is the whole difference.
481
+ canSteerBeforeTurn: () => this.activeToolCalls === 0 && !this.abortRequested,
482
+ steerEnabled: () => this.config.jobs.noticeSteer,
483
+ });
484
+ this.jobRelay.attach((listener) => this.procs.subscribe(listener));
423
485
  this.tools = createBuiltinTools({
424
486
  getCwd: () => this.cwd,
425
487
  // OWNED HERE, NOT IN `cli.tsx` (D-18): `createBuiltinTools` is called from
@@ -503,6 +565,35 @@ export class AgentController {
503
565
  procTools: this.bashBackgroundRegistered
504
566
  ? [makeBashOutput({ supervisor: this.procs }), makeBashKill({ supervisor: this.procs })]
505
567
  : [],
568
+ // THROUGH THE FACTORY for the reason above, and GATED ON BOTH KEYS:
569
+ // `job_wait` with no supervisor to read is a tool that can only say "no
570
+ // jobs", and `jobs.enabled: false` must leave the array byte-identical to
571
+ // the baseline (I-3).
572
+ jobTools: this.jobsRegistered
573
+ ? [
574
+ makeJobWait({
575
+ supervisor: this.procs,
576
+ waitSeconds: () => this.config.jobs.waitSeconds,
577
+ stallSeconds: () => this.config.jobs.stallSeconds,
578
+ toolTimeoutMs: () => this.config.toolTimeoutMs,
579
+ maxConsecutiveWaits: PROC_LIMITS.maxConsecutiveWaits,
580
+ terminalRecord: (id) => this.jobRelay?.terminalRecord(id),
581
+ }),
582
+ makeJobList({
583
+ supervisor: this.procs,
584
+ waitSeconds: () => this.config.jobs.waitSeconds,
585
+ stallSeconds: () => this.config.jobs.stallSeconds,
586
+ toolTimeoutMs: () => this.config.toolTimeoutMs,
587
+ maxConsecutiveWaits: PROC_LIMITS.maxConsecutiveWaits,
588
+ terminalRecord: (id) => this.jobRelay?.terminalRecord(id),
589
+ }),
590
+ ]
591
+ : [],
592
+ // The PULL port. Absent when jobs are off, which leaves every tool object
593
+ // untouched and provable by identity.
594
+ ...(this.jobsRegistered
595
+ ? { jobNotices: { drain: () => this.jobRelay?.drainForTail() ?? [] } }
596
+ : {}),
506
597
  // THROUGH THE FACTORY, NEVER APPENDED AFTERWARDS (D-15 / P0-1).
507
598
  // `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals what
508
599
  // `createBuiltinTools` produces, so appending `task` here would turn that
@@ -720,13 +811,32 @@ export class AgentController {
720
811
  if (event.type === 'steering_accepted') {
721
812
  for (const id of event.ids)
722
813
  this.pendingUserSteering.delete(id);
814
+ // A receipt means Core took a batch out of the queue, which is the only
815
+ // proof a steered notice has that it was not discarded (§4.6.1).
816
+ this.jobRelay?.onSteeringAccepted();
723
817
  }
724
- // The authoritative result arrives with this event, so from this instant
725
- // the tail is superseded and its slot must be released (§3.1.1). Clearing
726
- // HERE rather than on read is what keeps the store's size a function of
727
- // CONCURRENT calls instead of total calls.
818
+ // THE BATCH COUNTER (D-5 / I-5). Decremented BEFORE the push attempt below,
819
+ // because "the batch is empty" is only true after the last one has ended.
820
+ if (event.type === 'tool_execution_start')
821
+ this.activeToolCalls += 1;
728
822
  if (event.type === 'tool_execution_end') {
729
- this.toolOutputs?.clear('lead', event.toolCallId);
823
+ this.activeToolCalls = Math.max(0, this.activeToolCalls - 1);
824
+ // THE ONE PUSH SITE. A notice that could not be pushed here is picked up
825
+ // by the next tool result (the pull channel) or by the next user turn
826
+ // (the fill channel), so nothing is lost by declining.
827
+ this.jobRelay?.tryPush();
828
+ }
829
+ if (event.type === 'turn_start') {
830
+ // A new turn's first checkpoint is a safe point, and anything still
831
+ // steered-but-unproven must go back in the queue first: `fast`'s
832
+ // `recoverStranded()` may have wiped it at the end of the last run.
833
+ this.jobRelay?.requeueUndelivered();
834
+ }
835
+ if (event.type === 'agent_end') {
836
+ // The same reconciliation at the other end of a run. Both ends are
837
+ // required: `turn_start` covers the next turn of the same run, and this
838
+ // covers a run that ended with a notice stranded in the queue.
839
+ this.jobRelay?.requeueUndelivered();
730
840
  }
731
841
  });
732
842
  if (this.fast) {
@@ -818,6 +928,11 @@ export class AgentController {
818
928
  // `todo.enabled` rule). So the block is present exactly when the tools are,
819
929
  // and can never advertise a capability the model does not have.
820
930
  backgroundBlock: this.bashBackgroundRegistered ? buildBackgroundServicesBlock() : '',
931
+ // ONE FLAG, like `backgroundBlock` and for the same reason: `jobs.enabled`
932
+ // is read once at construction, so the block is present exactly when the
933
+ // tools that make its promises achievable are registered. With the flag off
934
+ // the prompt is byte-identical to the pre-feature text (I-3).
935
+ jobsBlock: this.jobsRegistered ? buildBackgroundJobsBlock() : '',
821
936
  // Read from the FIELD on every rebuild, for the reason the field's own
822
937
  // comment gives. Empty everywhere but `aragon exec`, and `buildSystemPrompt`
823
938
  // splices it conditionally, so every existing prompt is byte-identical.
@@ -977,9 +1092,30 @@ export class AgentController {
977
1092
  return { status: 'not-started', reason: 'cancelled' };
978
1093
  }
979
1094
  this.todos?.beginUserTurn(options.todoPolicy);
1095
+ // BEFORE the engine is entered, so the loop's first checkpoint (the top of
1096
+ // the loop, before any LLM call) merges them into history. Anything left over
1097
+ // from the previous run - a notice `fast` wiped, or one the model never saw
1098
+ // because it answered and stopped - is delivered here (RV-P1-7).
1099
+ this.flushPendingNotices();
980
1100
  await this.agent.prompt(text);
981
1101
  return { status: 'finished' };
982
1102
  }
1103
+ /**
1104
+ * Deliver undelivered job notices, if any and if it is safe to steer.
1105
+ *
1106
+ * ONE METHOD, TWO CALL SITES, AND NOT INSIDE `prepareUserTurn` (RV-P1-7).
1107
+ * `prepareUserTurn` is called from `startPrompt` AND from `continue` (with the
1108
+ * CONCATENATED queued text), and it does three specific things for the engine's
1109
+ * benefit. Hanging a delivery path off it would make `fast.setGoal`'s input
1110
+ * semantics depend on whether a notice happened to be pending.
1111
+ */
1112
+ flushPendingNotices() {
1113
+ // FLUSH, NOT PUSH. The push channel requires `isRunning()`, and the engine is
1114
+ // idle on this path by contract - reusing it here would leave every stranded
1115
+ // notice waiting for a tool batch that may never come, which is exactly the
1116
+ // failure AC-25 exists to prevent.
1117
+ this.jobRelay?.flushForTurn();
1118
+ }
983
1119
  prepareUserTurn(text) {
984
1120
  if (this.skillsEnabled)
985
1121
  this.skills.beginUserTurn();
@@ -1167,6 +1303,10 @@ export class AgentController {
1167
1303
  }
1168
1304
  this.todos?.beginUserTurn('new-task');
1169
1305
  }
1306
+ // The `continue()` path needs its own delivery for the reason RV-P1-7 gives:
1307
+ // a run that ended with a notice still queued resumes through here, and this
1308
+ // is the most common way a stranded notice gets its second chance.
1309
+ this.flushPendingNotices();
1170
1310
  await this.agent.continue();
1171
1311
  return { status: 'finished' };
1172
1312
  }
@@ -1179,6 +1319,21 @@ export class AgentController {
1179
1319
  isRunning() {
1180
1320
  return this.agent.state.isRunning;
1181
1321
  }
1322
+ /**
1323
+ * The one condition under which automated text may be injected (I-5).
1324
+ *
1325
+ * `activeToolCalls === 0` is the whole safety argument: `steer()` has exactly
1326
+ * one safe checkpoint - the top of the loop, before the LLM call - so injecting
1327
+ * text in the middle of a tool batch replaces the remaining `tool_use` blocks
1328
+ * with "skipped due to steering interrupt" and the next request is malformed.
1329
+ */
1330
+ isAtSafeSteerPoint() {
1331
+ return this.activeToolCalls === 0 && this.isRunning() && !this.abortRequested;
1332
+ }
1333
+ /** How many completion notices are waiting for the model. */
1334
+ pendingJobNotices() {
1335
+ return this.jobNotices.pending();
1336
+ }
1182
1337
  // -----------------------------------------------------------------------
1183
1338
  // Plan mode (§3.1 / §3.2)
1184
1339
  // -----------------------------------------------------------------------
@@ -1373,6 +1528,12 @@ export class AgentController {
1373
1528
  this.procs.reapSync();
1374
1529
  this.releaseSignalHook();
1375
1530
  this.procs.dispose();
1531
+ // THE MIRROR FLUSHES BEFORE THE RELAY DETACHES: whatever the last job wrote is
1532
+ // already on disk in the ring, and the file is the only copy that survives
1533
+ // this process.
1534
+ this.jobRelay?.detach();
1535
+ this.jobMirror?.dispose();
1536
+ this.jobNotices.clear();
1376
1537
  }
1377
1538
  // -----------------------------------------------------------------------
1378
1539
  // Todo planning (todo-plan-execution §3.6a)
@@ -1840,6 +2001,13 @@ export class AgentController {
1840
2001
  clearAllQueues() {
1841
2002
  this.agent.clearAllQueues();
1842
2003
  this.pendingUserSteering.clear();
2004
+ // A WIPE OF THE STEERING QUEUE MUST NOT TAKE A NOTICE WITH IT (I-11).
2005
+ // `fast/reviewer.ts` clears this queue when `userSteerCount() === 0`, on the
2006
+ // stated premise that `AgentController.steer()` is the only other writer - and
2007
+ // a steered job notice is invisible to that counter because it carries no
2008
+ // receipt id. Anything still unproven goes straight back into the notice
2009
+ // queue, so the wipe costs a delay instead of a fact the model never learns.
2010
+ this.jobRelay?.requeueUndelivered();
1843
2011
  }
1844
2012
  // -----------------------------------------------------------------------
1845
2013
  // Model / provider info
@@ -0,0 +1,143 @@
1
+ /**
2
+ * JobNoticeRelay — the controller-side owner of "a job finished, and the model
3
+ * has to be told exactly once" (long-task-jobs §4.6 / §4.6.1).
4
+ *
5
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THREE CHANNELS, ONE QUEUE, AND A LEDGER. The queue guarantees "at most once"
8
+ * because every channel takes from it with `drain()`. That alone is NOT enough,
9
+ * and this file exists because of the exception: Core's steering queue has a
10
+ * fourth consumer that DESTROYS rather than drains.
11
+ *
12
+ * `fast/reviewer.ts` calls `clearAllQueues()` when `userSteerCount() === 0`,
13
+ * and its stated safety argument is "`AgentController.steer()` is the only
14
+ * other writer in the package". A job notice queued through
15
+ * `enqueueSteering(text, false)` returns no receipt id, never enters
16
+ * `pendingUserSteering`, and is therefore INVISIBLE to that counter. With
17
+ * `fast` on, the last notice of a run could be wiped with no log line, no UI
18
+ * change and no failing test.
19
+ *
20
+ * THE FIX IS A LEDGER, NOT A COUPLING. A notice that has been steered is held in
21
+ * `pendingSteered` until a `steering_accepted` receipt proves the queue was
22
+ * drained; anything still held at `turn_start` or at the end of a run is pushed
23
+ * back into the queue. `JobNoticeQueue.push` is idempotent by id, so a re-queue
24
+ * can only ever cause a DELAY, never a duplicate.
25
+ *
26
+ * NOTHING HERE IS PUSHED WHILE A TOOL BATCH IS OPEN. The controller owns the
27
+ * batch count and calls `tryPush()` only from the one safe point; this class
28
+ * re-checks the same condition so a future call site cannot forget it (I-5).
29
+ */
30
+ import { type JobNotice, type JobNoticeQueue } from '../proc/notices.js';
31
+ import { type JobKind, type ProcEvent, type ServiceStatus } from '../proc/types.js';
32
+ export interface JobNoticeRelayDeps {
33
+ queue: JobNoticeQueue;
34
+ /** `enqueueSteering(text, false)` - the existing receipt-less path (§4.6). */
35
+ steer: (text: string) => void;
36
+ /** `activeToolCalls === 0 && isRunning() && !abortRequested` (I-5). */
37
+ isAtSafeSteerPoint: () => boolean;
38
+ /**
39
+ * `activeToolCalls === 0 && !abortRequested` - the FILL channel's precondition.
40
+ *
41
+ * DELIBERATELY NOT `isAtSafeSteerPoint()`, and the difference is one clause.
42
+ * The fill channel runs immediately BEFORE `agent.prompt()` / `agent.continue()`,
43
+ * where `isRunning()` is FALSE by contract (Core throws
44
+ * `Agent is already running` if it were true), so reusing the push predicate
45
+ * would make this channel dead - every stranded notice would wait for the next
46
+ * tool batch instead of going out with the user's message.
47
+ *
48
+ * What I-5 protects is "never inject text while a tool batch is open", and
49
+ * `activeToolCalls === 0` is exactly that clause; the `isRunning()` half of the
50
+ * push predicate exists for a different reason (a push is worthless if no run
51
+ * will ever drain it). Steering just before a run starts is the case the
52
+ * `steering_accepted` receipt was designed for: the loop's first checkpoint is
53
+ * the top of the loop, before any `tool_use` exists, so the text is merged into
54
+ * history structurally safely.
55
+ */
56
+ canSteerBeforeTurn: () => boolean;
57
+ /** `cfg.jobs.noticeSteer`, read LIVE: `false` disables the PUSH channel only. */
58
+ steerEnabled: () => boolean;
59
+ }
60
+ /** A terminal id this session has already reported (RV-P2-7's third state). */
61
+ interface RecalledJob {
62
+ kind: JobKind;
63
+ status: ServiceStatus;
64
+ }
65
+ export declare class JobNoticeRelay {
66
+ private readonly deps;
67
+ /**
68
+ * Notices that have been steered but not yet proven delivered, keyed by
69
+ * `${id}:${status}:${at}` - the same identity `JobNoticeQueue` uses.
70
+ */
71
+ private readonly pendingSteered;
72
+ /**
73
+ * Terminal ids this session has seen, so `job_wait` can answer "it finished and
74
+ * was reclaimed, do not re-run it" instead of "no such job".
75
+ *
76
+ * Bounded by `maxNotices`, oldest out: it answers a question about the recent
77
+ * past, and an unbounded map in a long session is a leak with a helpful name.
78
+ */
79
+ private readonly recalled;
80
+ private unsubscribe;
81
+ constructor(deps: JobNoticeRelayDeps);
82
+ /** Subscribe to the supervisor's stream. Returns the release function. */
83
+ attach(subscribe: (listener: (event: ProcEvent) => void) => () => void): void;
84
+ detach(): void;
85
+ /** Terminal ids this session has already reported, for `job_wait`'s message. */
86
+ terminalRecord(id: string): RecalledJob | undefined;
87
+ /**
88
+ * Move everything queued into the steering queue, if this is a safe moment.
89
+ *
90
+ * Returns whether anything was pushed. Called from exactly one place in the
91
+ * controller - the `tool_execution_end` listener, after the batch count reaches
92
+ * zero.
93
+ */
94
+ tryPush(): boolean;
95
+ /**
96
+ * Deliver the queue just BEFORE a run starts (the FILL channel, §4.6).
97
+ *
98
+ * TWO DIFFERENCES FROM `tryPush()`, both deliberate:
99
+ *
100
+ * - the precondition is `canSteerBeforeTurn()` (no `isRunning()` term), because
101
+ * this runs before `agent.prompt()` / `agent.continue()`, where the engine is
102
+ * not running yet - see the dep's own comment;
103
+ * - `noticeSteer` does NOT gate it. That key switches the PUSH channel only
104
+ * ("noticeSteer=false is not a notification off switch", §6.5), so a user who
105
+ * declines mid-turn interruptions must still learn that the job finished
106
+ * before their next message is sent.
107
+ */
108
+ flushForTurn(): boolean;
109
+ /** The shared half: take the queue, record the ledger, steer one block. */
110
+ private steerQueued;
111
+ /**
112
+ * A receipt means Core accepted a batch of steering text, which means the queue
113
+ * it came from has been drained.
114
+ *
115
+ * Notices carry no receipt id (`steer(text, id?)`'s `id` is optional and the
116
+ * receipt-less path passes none), so an arriving receipt is the only available
117
+ * proof. Treating "a receipt arrived" as "everything we steered was delivered"
118
+ * is correct in the only direction that matters: an over-eager release costs a
119
+ * MISSING notice, so the release is deferred to the next `steering_accepted`
120
+ * cycle rather than assumed at `steer()` time.
121
+ */
122
+ onSteeringAccepted(): void;
123
+ /**
124
+ * Put undelivered notices back at `turn_start` and at the end of a run.
125
+ *
126
+ * THIS IS WHAT TURNS "swallowed by clearAllQueues()" INTO "delivered one turn
127
+ * later". `push` deduplicates by id, so a notice that WAS delivered and is also
128
+ * re-queued can only be re-rendered once - and it is not re-queued at all,
129
+ * because a delivered notice was released by its receipt.
130
+ */
131
+ requeueUndelivered(): void;
132
+ /**
133
+ * Drain for the PULL channel.
134
+ *
135
+ * NOTHING IS RECORDED HERE, and that is the point: `drain()` hands ownership to
136
+ * the tool result that is about to carry the block, so there is no pending
137
+ * state to reconcile - the text is in the conversation as soon as the result
138
+ * returns.
139
+ */
140
+ drainForTail(): JobNotice[];
141
+ private onProcEvent;
142
+ }
143
+ export {};
@@ -0,0 +1,182 @@
1
+ /**
2
+ * JobNoticeRelay — the controller-side owner of "a job finished, and the model
3
+ * has to be told exactly once" (long-task-jobs §4.6 / §4.6.1).
4
+ *
5
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THREE CHANNELS, ONE QUEUE, AND A LEDGER. The queue guarantees "at most once"
8
+ * because every channel takes from it with `drain()`. That alone is NOT enough,
9
+ * and this file exists because of the exception: Core's steering queue has a
10
+ * fourth consumer that DESTROYS rather than drains.
11
+ *
12
+ * `fast/reviewer.ts` calls `clearAllQueues()` when `userSteerCount() === 0`,
13
+ * and its stated safety argument is "`AgentController.steer()` is the only
14
+ * other writer in the package". A job notice queued through
15
+ * `enqueueSteering(text, false)` returns no receipt id, never enters
16
+ * `pendingUserSteering`, and is therefore INVISIBLE to that counter. With
17
+ * `fast` on, the last notice of a run could be wiped with no log line, no UI
18
+ * change and no failing test.
19
+ *
20
+ * THE FIX IS A LEDGER, NOT A COUPLING. A notice that has been steered is held in
21
+ * `pendingSteered` until a `steering_accepted` receipt proves the queue was
22
+ * drained; anything still held at `turn_start` or at the end of a run is pushed
23
+ * back into the queue. `JobNoticeQueue.push` is idempotent by id, so a re-queue
24
+ * can only ever cause a DELAY, never a duplicate.
25
+ *
26
+ * NOTHING HERE IS PUSHED WHILE A TOOL BATCH IS OPEN. The controller owns the
27
+ * batch count and calls `tryPush()` only from the one safe point; this class
28
+ * re-checks the same condition so a future call site cannot forget it (I-5).
29
+ */
30
+ import { renderJobNotices } from '../proc/notices.js';
31
+ import { isTerminalStatus } from '../proc/types.js';
32
+ export class JobNoticeRelay {
33
+ deps;
34
+ /**
35
+ * Notices that have been steered but not yet proven delivered, keyed by
36
+ * `${id}:${status}:${at}` - the same identity `JobNoticeQueue` uses.
37
+ */
38
+ pendingSteered = new Map();
39
+ /**
40
+ * Terminal ids this session has seen, so `job_wait` can answer "it finished and
41
+ * was reclaimed, do not re-run it" instead of "no such job".
42
+ *
43
+ * Bounded by `maxNotices`, oldest out: it answers a question about the recent
44
+ * past, and an unbounded map in a long session is a leak with a helpful name.
45
+ */
46
+ recalled = new Map();
47
+ unsubscribe;
48
+ constructor(deps) {
49
+ this.deps = deps;
50
+ }
51
+ /** Subscribe to the supervisor's stream. Returns the release function. */
52
+ attach(subscribe) {
53
+ this.unsubscribe?.();
54
+ this.unsubscribe = subscribe((event) => this.onProcEvent(event));
55
+ }
56
+ detach() {
57
+ this.unsubscribe?.();
58
+ this.unsubscribe = undefined;
59
+ }
60
+ /** Terminal ids this session has already reported, for `job_wait`'s message. */
61
+ terminalRecord(id) {
62
+ return this.recalled.get(id);
63
+ }
64
+ /**
65
+ * Move everything queued into the steering queue, if this is a safe moment.
66
+ *
67
+ * Returns whether anything was pushed. Called from exactly one place in the
68
+ * controller - the `tool_execution_end` listener, after the batch count reaches
69
+ * zero.
70
+ */
71
+ tryPush() {
72
+ if (!this.deps.steerEnabled())
73
+ return false;
74
+ if (!this.deps.isAtSafeSteerPoint())
75
+ return false;
76
+ return this.steerQueued();
77
+ }
78
+ /**
79
+ * Deliver the queue just BEFORE a run starts (the FILL channel, §4.6).
80
+ *
81
+ * TWO DIFFERENCES FROM `tryPush()`, both deliberate:
82
+ *
83
+ * - the precondition is `canSteerBeforeTurn()` (no `isRunning()` term), because
84
+ * this runs before `agent.prompt()` / `agent.continue()`, where the engine is
85
+ * not running yet - see the dep's own comment;
86
+ * - `noticeSteer` does NOT gate it. That key switches the PUSH channel only
87
+ * ("noticeSteer=false is not a notification off switch", §6.5), so a user who
88
+ * declines mid-turn interruptions must still learn that the job finished
89
+ * before their next message is sent.
90
+ */
91
+ flushForTurn() {
92
+ if (!this.deps.canSteerBeforeTurn())
93
+ return false;
94
+ return this.steerQueued();
95
+ }
96
+ /** The shared half: take the queue, record the ledger, steer one block. */
97
+ steerQueued() {
98
+ const notices = this.deps.queue.drain();
99
+ if (notices.length === 0)
100
+ return false;
101
+ for (const notice of notices)
102
+ this.pendingSteered.set(noticeKey(notice), notice);
103
+ this.deps.steer(renderJobNotices(notices));
104
+ return true;
105
+ }
106
+ /**
107
+ * A receipt means Core accepted a batch of steering text, which means the queue
108
+ * it came from has been drained.
109
+ *
110
+ * Notices carry no receipt id (`steer(text, id?)`'s `id` is optional and the
111
+ * receipt-less path passes none), so an arriving receipt is the only available
112
+ * proof. Treating "a receipt arrived" as "everything we steered was delivered"
113
+ * is correct in the only direction that matters: an over-eager release costs a
114
+ * MISSING notice, so the release is deferred to the next `steering_accepted`
115
+ * cycle rather than assumed at `steer()` time.
116
+ */
117
+ onSteeringAccepted() {
118
+ this.pendingSteered.clear();
119
+ }
120
+ /**
121
+ * Put undelivered notices back at `turn_start` and at the end of a run.
122
+ *
123
+ * THIS IS WHAT TURNS "swallowed by clearAllQueues()" INTO "delivered one turn
124
+ * later". `push` deduplicates by id, so a notice that WAS delivered and is also
125
+ * re-queued can only be re-rendered once - and it is not re-queued at all,
126
+ * because a delivered notice was released by its receipt.
127
+ */
128
+ requeueUndelivered() {
129
+ if (this.pendingSteered.size === 0)
130
+ return;
131
+ const stranded = [...this.pendingSteered.values()];
132
+ this.pendingSteered.clear();
133
+ for (const notice of stranded)
134
+ this.deps.queue.push(notice);
135
+ }
136
+ /**
137
+ * Drain for the PULL channel.
138
+ *
139
+ * NOTHING IS RECORDED HERE, and that is the point: `drain()` hands ownership to
140
+ * the tool result that is about to carry the block, so there is no pending
141
+ * state to reconcile - the text is in the conversation as soon as the result
142
+ * returns.
143
+ */
144
+ drainForTail() {
145
+ return this.deps.queue.drain();
146
+ }
147
+ onProcEvent(event) {
148
+ const job = event.service;
149
+ if (!isTerminalStatus(job.status))
150
+ return;
151
+ const kind = job.kind;
152
+ this.recalled.set(job.id, { kind, status: job.status });
153
+ while (this.recalled.size > 32) {
154
+ const oldest = this.recalled.keys().next();
155
+ if (oldest.done === true)
156
+ break;
157
+ this.recalled.delete(oldest.value);
158
+ }
159
+ // One notice per terminal transition. `exited`/`stopped` are single events per
160
+ // job (the supervisor's own guards enforce that), so no de-duplication is
161
+ // needed here beyond the queue's own.
162
+ if (job.status !== 'exited' && job.status !== 'failed' && job.status !== 'stopped')
163
+ return;
164
+ this.deps.queue.push({
165
+ id: job.id,
166
+ kind,
167
+ status: job.status,
168
+ exitCode: job.exitCode,
169
+ signal: job.signal,
170
+ elapsedMs: (job.endedAt ?? Date.now()) - job.startedAt,
171
+ ...(job.progress?.text ? { progressText: job.progress.text } : {}),
172
+ // A FAILURE EARNS A TAIL; a clean exit does not (the block stays bounded).
173
+ tailRows: job.status === 'failed' || (job.exitCode !== null && job.exitCode !== 0)
174
+ ? job.rows.slice(-2)
175
+ : [],
176
+ at: Date.now(),
177
+ });
178
+ }
179
+ }
180
+ function noticeKey(notice) {
181
+ return `${notice.id}:${notice.status}:${notice.at}`;
182
+ }