@aragon-agent/cli 0.6.29 → 0.6.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +2223 -2103
  2. package/README.md +2957 -2759
  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/project-guidance-prompt.js +20 -20
  8. package/dist/agent/reducer.d.ts +21 -1
  9. package/dist/agent/reducer.js +7 -0
  10. package/dist/agent/system-prompt.d.ts +12 -0
  11. package/dist/agent/system-prompt.js +2 -0
  12. package/dist/cli.js +399 -62
  13. package/dist/commands/builtins.js +27 -8
  14. package/dist/commands/perf.d.ts +79 -2
  15. package/dist/commands/perf.js +51 -3
  16. package/dist/commands/registry.d.ts +6 -0
  17. package/dist/config/cli-commands.d.ts +27 -0
  18. package/dist/config/cli-commands.js +54 -1
  19. package/dist/config/env.js +42 -0
  20. package/dist/config/load.d.ts +3 -1
  21. package/dist/config/load.js +61 -1
  22. package/dist/config/schema.d.ts +100 -0
  23. package/dist/config/schema.js +43 -0
  24. package/dist/config/store.js +14 -1
  25. package/dist/config/ui-state.d.ts +9 -0
  26. package/dist/config/ui-state.js +9 -0
  27. package/dist/exec/events.d.ts +29 -3
  28. package/dist/exec/events.js +3 -12
  29. package/dist/exec/runner.d.ts +24 -0
  30. package/dist/exec/runner.js +79 -0
  31. package/dist/input/cursor-report.d.ts +61 -0
  32. package/dist/input/cursor-report.js +97 -0
  33. package/dist/input/stdin-filter.d.ts +22 -0
  34. package/dist/input/stdin-filter.js +48 -3
  35. package/dist/proc/limits.d.ts +76 -2
  36. package/dist/proc/limits.js +83 -2
  37. package/dist/proc/log-mirror.d.ts +59 -0
  38. package/dist/proc/log-mirror.js +203 -0
  39. package/dist/proc/log-ring.d.ts +22 -0
  40. package/dist/proc/log-ring.js +20 -1
  41. package/dist/proc/notices.d.ts +57 -0
  42. package/dist/proc/notices.js +101 -0
  43. package/dist/proc/progress.d.ts +40 -0
  44. package/dist/proc/progress.js +135 -0
  45. package/dist/proc/prompt.d.ts +24 -2
  46. package/dist/proc/prompt.js +51 -2
  47. package/dist/proc/supervisor.d.ts +35 -2
  48. package/dist/proc/supervisor.js +142 -43
  49. package/dist/proc/types.d.ts +89 -1
  50. package/dist/proc/wait.d.ts +57 -0
  51. package/dist/proc/wait.js +133 -0
  52. package/dist/session/persist.js +3 -1
  53. package/dist/session/validate-session.js +17 -0
  54. package/dist/tools/bash-tool.d.ts +12 -1
  55. package/dist/tools/bash-tool.js +100 -13
  56. package/dist/tools/index.d.ts +26 -2
  57. package/dist/tools/index.js +43 -2
  58. package/dist/tools/job-tools.d.ts +54 -0
  59. package/dist/tools/job-tools.js +329 -0
  60. package/dist/tools/proc-tools.d.ts +1 -1
  61. package/dist/tools/proc-tools.js +49 -17
  62. package/dist/ui/ActivityLine.js +7 -0
  63. package/dist/ui/App.d.ts +11 -1
  64. package/dist/ui/App.js +181 -63
  65. package/dist/ui/ScrollbackShell.d.ts +54 -0
  66. package/dist/ui/ScrollbackShell.js +15 -0
  67. package/dist/ui/StatusBar.d.ts +5 -0
  68. package/dist/ui/StatusBar.js +1 -0
  69. package/dist/ui/Transcript.d.ts +14 -0
  70. package/dist/ui/Transcript.js +14 -2
  71. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  72. package/dist/ui/entries/ServiceCard.js +39 -4
  73. package/dist/ui/frame-differ.d.ts +12 -0
  74. package/dist/ui/layout/status-layout.d.ts +13 -1
  75. package/dist/ui/layout/status-layout.js +10 -0
  76. package/dist/ui/layout/virtual-window.js +14 -1
  77. package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
  78. package/dist/ui/overlays/HelpOverlay.js +21 -3
  79. package/dist/ui/run-status-row.d.ts +12 -0
  80. package/dist/ui/screen.d.ts +29 -0
  81. package/dist/ui/screen.js +40 -0
  82. package/dist/ui/scrollback/LiveTail.d.ts +64 -0
  83. package/dist/ui/scrollback/LiveTail.js +27 -0
  84. package/dist/ui/scrollback/band.d.ts +105 -0
  85. package/dist/ui/scrollback/band.js +109 -0
  86. package/dist/ui/scrollback/budget.d.ts +72 -0
  87. package/dist/ui/scrollback/budget.js +81 -0
  88. package/dist/ui/scrollback/canvas.d.ts +99 -0
  89. package/dist/ui/scrollback/canvas.js +113 -0
  90. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  91. package/dist/ui/scrollback/cursor-probe.js +133 -0
  92. package/dist/ui/scrollback/drain.d.ts +32 -0
  93. package/dist/ui/scrollback/drain.js +64 -0
  94. package/dist/ui/scrollback/exit-summary.d.ts +23 -0
  95. package/dist/ui/scrollback/exit-summary.js +25 -0
  96. package/dist/ui/scrollback/limits.d.ts +74 -0
  97. package/dist/ui/scrollback/limits.js +74 -0
  98. package/dist/ui/scrollback/live-preview.d.ts +26 -0
  99. package/dist/ui/scrollback/live-preview.js +106 -0
  100. package/dist/ui/scrollback/renderer.d.ts +38 -0
  101. package/dist/ui/scrollback/renderer.js +158 -0
  102. package/dist/ui/scrollback/scrollback-writer.d.ts +162 -0
  103. package/dist/ui/scrollback/scrollback-writer.js +549 -0
  104. package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
  105. package/dist/ui/scrollback/settled-boundary.js +106 -0
  106. package/dist/ui/scrollback/use-scrollback-view.d.ts +103 -0
  107. package/dist/ui/scrollback/use-scrollback-view.js +294 -0
  108. package/dist/ui/scrollback/writer-shapes.d.ts +127 -0
  109. package/dist/ui/scrollback/writer-shapes.js +159 -0
  110. package/dist/ui/stdout-frame-writer.js +12 -2
  111. package/dist/ui/terminal-plan.d.ts +52 -0
  112. package/dist/ui/terminal-plan.js +49 -0
  113. package/dist/ui/transcript-text.d.ts +11 -0
  114. package/dist/ui/transcript-text.js +21 -7
  115. package/dist/ui/use-startup-notices.d.ts +12 -1
  116. package/dist/ui/use-startup-notices.js +28 -2
  117. package/package.json +3 -3
  118. package/skills/project-indexer/SKILL.md +163 -163
  119. package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
  120. package/skills/project-indexer/templates/cleancode-template.md +94 -94
  121. package/skills/project-indexer/templates/config-template.md +69 -69
  122. package/skills/project-indexer/templates/index-template.md +129 -129
  123. package/skills/skill-creator/SKILL.md +96 -96
@@ -39,6 +39,17 @@ export declare function isThemeName(v: unknown): v is ThemeName;
39
39
  * and would not match `clampThinkingLevel` / `clampTranscriptWindow`.
40
40
  */
41
41
  export declare function clampTheme(v: unknown, fallback: ThemeName): ThemeName;
42
+ export declare const SCROLL_MODES: readonly ["native", "app"];
43
+ /**
44
+ * How the interactive TUI scrolls (tui-native-terminal-scroll D1).
45
+ *
46
+ * `native` prints settled conversation into the host terminal's own scrollback and
47
+ * keeps only a bounded live region on screen; `app` is the original full-screen
48
+ * alternate-screen layout with application-owned scrolling.
49
+ */
50
+ export type ScrollMode = (typeof SCROLL_MODES)[number];
51
+ export declare const DEFAULT_SCROLL_MODE: ScrollMode;
52
+ export declare function clampScrollMode(v: unknown, fallback: ScrollMode): ScrollMode;
42
53
  export declare const DENSITY_MODES: readonly ["comfortable", "compact"];
43
54
  export type DensityMode = (typeof DENSITY_MODES)[number];
44
55
  export declare function clampDensity(v: unknown, fallback: DensityMode): DensityMode;
@@ -784,6 +795,62 @@ export declare const DEFAULT_BASH_CONFIG: BashConfig;
784
795
  * A garbage `startupSettleMs` resolves to 4000 and throws nothing.
785
796
  */
786
797
  export declare function clampBashConfig(raw: unknown): BashConfig;
798
+ export interface JobsConfig {
799
+ /**
800
+ * Register `job_wait` / `job_list`, splice the `<background_jobs>` block,
801
+ * mirror job logs. Default TRUE.
802
+ *
803
+ * READ ONCE AT CONSTRUCTION, the `bash.background` / `team.enabled` rule. Its
804
+ * job is to be the single switch under which the tool array, the tool
805
+ * descriptions and the system prompt are BYTE-IDENTICAL to the pre-feature
806
+ * build (I-3) - which is why it is a registration switch and not a behaviour
807
+ * flag.
808
+ */
809
+ enabled: boolean;
810
+ /**
811
+ * Seconds one `job_wait` blocks by default. Default 30, range 5..60.
812
+ *
813
+ * ALSO CONVERGED AGAINST THE TOOL CEILING at load time and again at call time
814
+ * (I-4): a wait that outlives the tool call hosting it would turn every long
815
+ * job into `Command timed out`, which is the defect this feature removes.
816
+ */
817
+ waitSeconds: number;
818
+ /**
819
+ * Seconds without output before a job is reported `[stalled]`. Default 300,
820
+ * range 30..3600.
821
+ *
822
+ * A REPORT, NEVER AN ACTION: nothing kills or retries on this (R-13). The
823
+ * default is deliberately generous, because a training epoch that prints every
824
+ * five minutes is healthy.
825
+ */
826
+ stallSeconds: number;
827
+ /**
828
+ * Mirror each job's full output to `<home>/jobs/<session>/<id>.log`. Default
829
+ * TRUE; `false` creates no directory at all, for privacy- or IO-sensitive
830
+ * environments (D-6).
831
+ */
832
+ persistLog: boolean;
833
+ /**
834
+ * Whether a completion notice may STEER the running turn. Default TRUE.
835
+ *
836
+ * NOT A NOTICE SWITCH (§6.5). With this off, notices still arrive at the end of
837
+ * the next tool result and before the next user turn; only the active
838
+ * interruption stops. Turning notifications off entirely would require running
839
+ * no jobs.
840
+ */
841
+ noticeSteer: boolean;
842
+ }
843
+ export declare const DEFAULT_JOBS_CONFIG: JobsConfig;
844
+ /**
845
+ * THE single gate for every `jobs` read AND write, mirroring `clampBashConfig`
846
+ * and the four sections before it.
847
+ *
848
+ * THE ELEVENTH SECTION, AND THE FIRST ONE THAT `config set` CAN REACH BY
849
+ * NESTED KEY (§6.5). Every key is a SCALAR and the section is ONE level deep,
850
+ * because `runConfigGet` splits on a single dot - a `jobs.mirror.enabled` would
851
+ * resolve to `undefined` and the setting would silently never apply.
852
+ */
853
+ export declare function clampJobsConfig(raw: unknown): JobsConfig;
787
854
  /**
788
855
  * Parse `0.9`, `90%` or `90` into a ratio, or `null` when it is neither.
789
856
  *
@@ -864,6 +931,8 @@ export interface PersistedConfig {
864
931
  maxRenderIntervalMs: number;
865
932
  /** Repaint only the frame rows that changed (tui-input-flicker-fix §5.1). */
866
933
  diffRender: boolean;
934
+ /** Native terminal scrolling vs the full-screen app layout (tui-native-terminal-scroll D1). */
935
+ scrollMode: ScrollMode;
867
936
  /** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
868
937
  syncOutput: boolean;
869
938
  confirmTools: boolean;
@@ -942,6 +1011,8 @@ export interface PersistedConfig {
942
1011
  compaction: CompactionConfig;
943
1012
  /** Background services. The TENTH nested object; scalars only (see BashConfig). */
944
1013
  bash: BashConfig;
1014
+ /** Background jobs and bounded waiting. The ELEVENTH; scalars only (see JobsConfig). */
1015
+ jobs: JobsConfig;
945
1016
  }
946
1017
  export declare const DEFAULT_CONFIG: PersistedConfig;
947
1018
  /**
@@ -1068,6 +1139,20 @@ export interface CliConfig {
1068
1139
  * with no compile error and no runtime error.
1069
1140
  */
1070
1141
  diffRender: boolean;
1142
+ /**
1143
+ * Native terminal scrolling vs the full-screen app layout
1144
+ * (tui-native-terminal-scroll D1).
1145
+ *
1146
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
1147
+ * `diffRender` above records: `runInteractive` reads `config.scrollMode`, where
1148
+ * `config` is a `CliConfig`, so a key added to the persisted shape alone would
1149
+ * round-trip through `config set` and never reach the screen plan.
1150
+ *
1151
+ * OPTIONAL ON THIS SHAPE ONLY so the many hand-built `CliConfig` fixtures in the
1152
+ * test suite stay valid without edits (I-S9); `loadConfig` always sets it and
1153
+ * the one reader (`runInteractive`) falls back to `DEFAULT_SCROLL_MODE`.
1154
+ */
1155
+ scrollMode?: ScrollMode;
1071
1156
  /** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
1072
1157
  syncOutput: boolean;
1073
1158
  confirmTools: boolean;
@@ -1235,6 +1320,21 @@ export interface CliConfig {
1235
1320
  * either of them.
1236
1321
  */
1237
1322
  bash: BashConfig;
1323
+ /**
1324
+ * Background jobs and bounded waiting (long-task-jobs §6.5). The ELEVENTH
1325
+ * nested object; five scalar keys, one level deep.
1326
+ *
1327
+ * ON `CliConfig` AS WELL AS `PersistedConfig`, for the reason every section
1328
+ * above records: `AgentController` reads `config.jobs.waitSeconds` through a
1329
+ * closure and `job_wait` reads it again at call time, so a key persisted and
1330
+ * never projected would round-trip through `config set`, show up in
1331
+ * `config list`, and change nothing.
1332
+ *
1333
+ * ONE LEVEL DEEP IS A HARD REQUIREMENT, NOT A PREFERENCE: `runConfigGet` splits
1334
+ * `jobs.waitSeconds` on a single dot, so a nested sub-object would resolve to
1335
+ * `undefined` and report an empty value for a setting that IS on disk.
1336
+ */
1337
+ jobs: JobsConfig;
1238
1338
  /**
1239
1339
  * Lifetime submit count driving the composer hint fade (`HINT_FADE_AFTER`).
1240
1340
  *
@@ -69,6 +69,14 @@ export function clampTheme(v, fallback) {
69
69
  // ---------------------------------------------------------------------------
70
70
  // Density / hints (v0.4.0)
71
71
  // ---------------------------------------------------------------------------
72
+ export const SCROLL_MODES = ['native', 'app'];
73
+ export const DEFAULT_SCROLL_MODE = 'native';
74
+ export function clampScrollMode(v, fallback) {
75
+ if (typeof v !== 'string')
76
+ return fallback;
77
+ const normalized = v.trim().toLowerCase();
78
+ return normalized === 'native' || normalized === 'app' ? normalized : fallback;
79
+ }
72
80
  export const DENSITY_MODES = ['comfortable', 'compact'];
73
81
  export function clampDensity(v, fallback) {
74
82
  return v === 'comfortable' || v === 'compact' ? v : fallback;
@@ -922,6 +930,34 @@ export function clampBashConfig(raw) {
922
930
  readyTimeoutMs: clampInt(src.readyTimeoutMs, DEFAULT_BASH_CONFIG.readyTimeoutMs, READY_TIMEOUT_RANGE),
923
931
  };
924
932
  }
933
+ export const DEFAULT_JOBS_CONFIG = {
934
+ enabled: true,
935
+ waitSeconds: 30,
936
+ stallSeconds: 300,
937
+ persistLog: true,
938
+ noticeSteer: true,
939
+ };
940
+ const WAIT_SECONDS_RANGE = { min: 5, max: 60 };
941
+ const STALL_SECONDS_RANGE = { min: 30, max: 3600 };
942
+ /**
943
+ * THE single gate for every `jobs` read AND write, mirroring `clampBashConfig`
944
+ * and the four sections before it.
945
+ *
946
+ * THE ELEVENTH SECTION, AND THE FIRST ONE THAT `config set` CAN REACH BY
947
+ * NESTED KEY (§6.5). Every key is a SCALAR and the section is ONE level deep,
948
+ * because `runConfigGet` splits on a single dot - a `jobs.mirror.enabled` would
949
+ * resolve to `undefined` and the setting would silently never apply.
950
+ */
951
+ export function clampJobsConfig(raw) {
952
+ const src = (raw && typeof raw === 'object' ? raw : {});
953
+ return {
954
+ enabled: bool(src.enabled, DEFAULT_JOBS_CONFIG.enabled),
955
+ waitSeconds: clampInt(src.waitSeconds, DEFAULT_JOBS_CONFIG.waitSeconds, WAIT_SECONDS_RANGE),
956
+ stallSeconds: clampInt(src.stallSeconds, DEFAULT_JOBS_CONFIG.stallSeconds, STALL_SECONDS_RANGE),
957
+ persistLog: bool(src.persistLog, DEFAULT_JOBS_CONFIG.persistLog),
958
+ noticeSteer: bool(src.noticeSteer, DEFAULT_JOBS_CONFIG.noticeSteer),
959
+ };
960
+ }
925
961
  /**
926
962
  * Parse `0.9`, `90%` or `90` into a ratio, or `null` when it is neither.
927
963
  *
@@ -970,6 +1006,7 @@ export const DEFAULT_CONFIG = {
970
1006
  renderGovernor: DEFAULT_RENDER_GOVERNOR,
971
1007
  maxRenderIntervalMs: DEFAULT_MAX_RENDER_INTERVAL_MS,
972
1008
  diffRender: DEFAULT_DIFF_RENDER,
1009
+ scrollMode: DEFAULT_SCROLL_MODE,
973
1010
  syncOutput: DEFAULT_SYNC_OUTPUT,
974
1011
  confirmTools: false,
975
1012
  toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
@@ -1019,6 +1056,12 @@ export const DEFAULT_CONFIG = {
1019
1056
  // `undefined` would arrive with background services OFF for every existing
1020
1057
  // user — the same direction the compaction line above must never fail in.
1021
1058
  bash: DEFAULT_BASH_CONFIG,
1059
+ // And one section later again, for the sixth time, and this one would fail
1060
+ // in the WORSE direction: every key in `jobs` is `true` or positive by
1061
+ // default, so a `config.json` predating this feature resolving `config.jobs`
1062
+ // to `undefined` would throw on `config.jobs.waitSeconds` at construction
1063
+ // rather than quietly disabling something.
1064
+ jobs: DEFAULT_JOBS_CONFIG,
1022
1065
  };
1023
1066
  /**
1024
1067
  * Runtime state that used to be stored in `config.json` by mistake
@@ -17,7 +17,7 @@ import { getConfigPath, getSessionsDir } from './app-paths.js';
17
17
  import { registerProfileSecrets, registerSecretsFrom } from '../logging/secret-registry.js';
18
18
  import { adaptLegacyConnectionPatch, ModelProfileConfigError, readModelSettingsDisk } from './model-profile-store.js';
19
19
  import { normalizeModelProfiles } from './model-profiles.js';
20
- import { clampBashConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
20
+ import { clampBashConfig, clampJobsConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
21
21
  /**
22
22
  * Normalize `maxTokens` for both directions of the config file.
23
23
  *
@@ -189,6 +189,12 @@ export function normalizePersistedConfig(input) {
189
189
  // and NOT merged over the defaults, which is verbatim the failure the
190
190
  // paragraph above says this line prevents.
191
191
  bash: clampBashConfig({ ...DEFAULT_CONFIG.bash, ...(partial.bash ?? {}) }),
192
+ // And the eleventh, on the same line and for the same reason. `jobs.enabled`
193
+ // is `true` by default, so a user whose `config.json` predates this feature
194
+ // must not resolve it to `undefined` — and `waitSeconds` would be `undefined`
195
+ // outright, which is a crash on the first `job_wait` rather than a silently
196
+ // wrong setting.
197
+ jobs: clampJobsConfig({ ...DEFAULT_CONFIG.jobs, ...(partial.jobs ?? {}) }),
192
198
  };
193
199
  }
194
200
  /**
@@ -289,6 +295,13 @@ export function mergePersistedConfig(current, patch) {
289
295
  // top level and write `background` absent, silently unregistering
290
296
  // `bash_output` / `bash_kill` from the next launch onward.
291
297
  bash: clampBashConfig({ ...current.bash, ...(patch.bash ?? {}) }),
298
+ // The eleventh section, and the sixth time this file records the same trap.
299
+ // `aragon config set jobs.waitSeconds 45` sends `{ jobs: { waitSeconds: 45 } }`;
300
+ // without this line the shallow top-level spread REPLACES the section and
301
+ // `persistLog` / `noticeSteer` / `enabled` / `stallSeconds` are written
302
+ // absent — with `noticeSteer`'s default masking it on the next load, so the
303
+ // user would never learn that their privacy switch had been turned back on.
304
+ jobs: clampJobsConfig({ ...current.jobs, ...(patch.jobs ?? {}) }),
292
305
  };
293
306
  return merged;
294
307
  }
@@ -84,6 +84,12 @@ export interface UiState {
84
84
  * older file, which is the behaviour wanted here anyway.
85
85
  */
86
86
  vtInputNoticeVersion: number;
87
+ /**
88
+ * Which revision of the "scrolling now uses your terminal" notice this user has
89
+ * been shown (`0` = none; tui-native-terminal-scroll D12). A scalar beside the
90
+ * others for the same reasons the two above record, and NOT a schema bump.
91
+ */
92
+ scrollModeNoticeVersion: number;
87
93
  }
88
94
  /** Read the state, caching it for the life of the process. Never throws. */
89
95
  export declare function loadUiState(): UiState;
@@ -98,6 +104,9 @@ export declare function setMouseNoticeVersion(version: number): void;
98
104
  /** `0` on a file written before the field existed — see the field's comment. */
99
105
  export declare function getVtInputNoticeVersion(): number;
100
106
  export declare function setVtInputNoticeVersion(version: number): void;
107
+ /** `0` on a file written before the field existed. */
108
+ export declare function getScrollModeNoticeVersion(): number;
109
+ export declare function setScrollModeNoticeVersion(version: number): void;
101
110
  /**
102
111
  * Migration-only: fill in fields that are still at their default, and never
103
112
  * overwrite a value already here. A second import — after a half-finished
@@ -37,6 +37,7 @@ const DEFAULT_UI_STATE = {
37
37
  mouseNoticeSeen: false,
38
38
  mouseNoticeVersion: 0,
39
39
  vtInputNoticeVersion: 0,
40
+ scrollModeNoticeVersion: 0,
40
41
  };
41
42
  let memory = null;
42
43
  /** Non-negative integer or `fallback`; shared by both numeric fields. */
@@ -69,6 +70,7 @@ function readFromDisk() {
69
70
  : DEFAULT_UI_STATE.mouseNoticeSeen,
70
71
  mouseNoticeVersion: coerceCount(parsed.mouseNoticeVersion, DEFAULT_UI_STATE.mouseNoticeVersion),
71
72
  vtInputNoticeVersion: coerceCount(parsed.vtInputNoticeVersion, DEFAULT_UI_STATE.vtInputNoticeVersion),
73
+ scrollModeNoticeVersion: coerceCount(parsed.scrollModeNoticeVersion, DEFAULT_UI_STATE.scrollModeNoticeVersion),
72
74
  };
73
75
  }
74
76
  catch {
@@ -142,6 +144,13 @@ export function getVtInputNoticeVersion() {
142
144
  export function setVtInputNoticeVersion(version) {
143
145
  update({ vtInputNoticeVersion: version });
144
146
  }
147
+ /** `0` on a file written before the field existed. */
148
+ export function getScrollModeNoticeVersion() {
149
+ return loadUiState().scrollModeNoticeVersion;
150
+ }
151
+ export function setScrollModeNoticeVersion(version) {
152
+ update({ scrollModeNoticeVersion: version });
153
+ }
145
154
  /**
146
155
  * Migration-only: fill in fields that are still at their default, and never
147
156
  * overwrite a value already here. A second import — after a half-finished
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * PURE TYPES AND BUILDERS. No I/O, no process access, no imports outside
11
11
  * `node:`-free type space, so a consumer of this module cannot accidentally
12
- * evaluate the runner.
13
- *
12
+ * evaluate the runner. The one import below is a TYPE from `proc/types.ts` for
13
+ * the same reason: a shape, erased at compile time.
14
+ */
15
+ import type { ServiceStatus } from '../proc/types.js';
16
+ /**
14
17
  * `EXEC_SCHEMA_VERSION` appears on `system` and on `result` and NOWHERE ELSE.
15
18
  * ADDING A FIELD OR AN EVENT TYPE NEVER BUMPS IT; renaming or removing one does.
16
19
  * That rule only holds if consumers keep their side of it, so the README states
@@ -370,7 +373,30 @@ export interface ExecResultEvent {
370
373
  todos: ExecTodoCounts | null;
371
374
  error: ExecErrorInfo | null;
372
375
  }
373
- export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTurnStateEvent | ExecProgressEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecFastTierEvent | ExecCompactionEvent | ExecRetryEvent | ExecErrorEvent | ExecResultEvent;
376
+ /**
377
+ * A background job's lifecycle (long-task-jobs §4.10).
378
+ *
379
+ * A PURE ADDITION to schema v1: a consumer that does not know this `type`
380
+ * ignores it, which is the forward-compatibility contract this file's header
381
+ * states. `progress` is emitted only when the extracted text CHANGES and at most
382
+ * once a second - a 40-minute training run would otherwise write thousands of
383
+ * lines to a machine consumer's stdout.
384
+ */
385
+ export interface ExecJobEvent {
386
+ type: 'job';
387
+ schemaVersion: number;
388
+ sessionId: string;
389
+ subtype: 'started' | 'progress' | 'exited' | 'stalled';
390
+ /** `t3` / `s1`. */
391
+ jobId: string;
392
+ kind: 'service' | 'task';
393
+ status: ServiceStatus;
394
+ elapsedMs: number;
395
+ /** Only on `subtype === 'progress'`, and only when the text changed. */
396
+ progress?: string;
397
+ exitCode?: number | null;
398
+ }
399
+ export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTurnStateEvent | ExecProgressEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecFastTierEvent | ExecCompactionEvent | ExecRetryEvent | ExecJobEvent | ExecErrorEvent | ExecResultEvent;
374
400
  /** Everything `system/init` needs that the runner cannot derive for itself. */
375
401
  export interface ExecInitParams {
376
402
  sessionId: string;
@@ -1,16 +1,4 @@
1
1
  /**
2
- * THE CONTRACT (cli-integration-surface section 5.1).
3
- *
4
- * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope
5
- * (`glyphs.test.ts::inScope`, R-3 / AC-25). Every string here can reach a
6
- * consumer's terminal through `--output-format text` fallbacks or a stderr
7
- * warning, and the whole point of the scanner is that a legacy `cmd.exe` shows
8
- * mojibake for anything else.
9
- *
10
- * PURE TYPES AND BUILDERS. No I/O, no process access, no imports outside
11
- * `node:`-free type space, so a consumer of this module cannot accidentally
12
- * evaluate the runner.
13
- *
14
2
  * `EXEC_SCHEMA_VERSION` appears on `system` and on `result` and NOWHERE ELSE.
15
3
  * ADDING A FIELD OR AN EVENT TYPE NEVER BUMPS IT; renaming or removing one does.
16
4
  * That rule only holds if consumers keep their side of it, so the README states
@@ -53,6 +41,9 @@ export const EXEC_CAPABILITIES = [
53
41
  'session-file',
54
42
  'turn-lifecycle',
55
43
  'execution-progress',
44
+ // Background job lifecycle events (long-task-jobs §4.10). A consumer that does
45
+ // not know the `job` type ignores it; this string is how it asks first.
46
+ 'background-jobs',
56
47
  ];
57
48
  export function buildInitEvent(params) {
58
49
  return {
@@ -20,6 +20,7 @@ import { type FollowThroughMode } from '../todo/follow-through.js';
20
20
  import type { ExecEmitter } from './emitter.js';
21
21
  import type { FastSnapshot } from '../fast/types.js';
22
22
  import type { ExecStopReason } from './events.js';
23
+ import type { ProcEvent } from '../proc/types.js';
23
24
  /**
24
25
  * The controller surface the runner needs.
25
26
  *
@@ -45,6 +46,14 @@ export interface ExecRunnerController extends HeadlessController {
45
46
  * it simply emits no `fast_tier` events.
46
47
  */
47
48
  fastSnapshot?(): FastSnapshot | null;
49
+ /**
50
+ * The CLI-local process stream, when this session supervises processes.
51
+ *
52
+ * OPTIONAL AND CALLED WITH `?.()`, exactly like the three optional streams
53
+ * above: this interface is minimal so a test can satisfy it with an object
54
+ * literal, and a host with no supervisor has nothing to report.
55
+ */
56
+ subscribeProc?(listener: (event: ProcEvent) => void): () => void;
48
57
  }
49
58
  /** Where the caller's messages come from. One turn, or an NDJSON stdin stream. */
50
59
  export interface ExecPromptSource {
@@ -97,6 +106,10 @@ export declare class ExecRunner {
97
106
  private readonly progress;
98
107
  private controller;
99
108
  private unsubscribers;
109
+ /** Per-job progress throttle state: the last text emitted, and when. */
110
+ private readonly jobProgressAt;
111
+ /** Per-job `lastOutputAt` at which `stalled` was last reported. */
112
+ private readonly jobStalledAt;
100
113
  private turns;
101
114
  private readonly usage;
102
115
  private compactionBilling;
@@ -123,6 +136,17 @@ export declare class ExecRunner {
123
136
  * multiple subscribers.
124
137
  */
125
138
  attach(controller: ExecRunnerController): void;
139
+ /**
140
+ * Map a supervisor event onto the `job` channel.
141
+ *
142
+ * THROTTLING IS THE WHOLE DISCIPLINE HERE. `output` is already coalesced to
143
+ * <=10 Hz inside the supervisor, which is still far too fast for a machine
144
+ * consumer reading a 40-minute training run; `progress` is therefore emitted
145
+ * only when the extracted TEXT CHANGES and never more than once a second. The
146
+ * other three subtypes are one-shot per job, so they pass straight through.
147
+ */
148
+ private onProcEvent;
149
+ private emitJob;
126
150
  /**
127
151
  * Drop the subscriptions and the timer.
128
152
  *
@@ -17,6 +17,7 @@
17
17
  import { formatStreamError } from '../agent/reducer.js';
18
18
  import { setSignalTerminator } from '../logging/install.js';
19
19
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
20
+ import { EXEC_SCHEMA_VERSION } from './events.js';
20
21
  import { ExecProgressReporter } from './progress-reporter.js';
21
22
  export function singlePrompt(text) {
22
23
  let served = false;
@@ -44,6 +45,10 @@ export class ExecRunner {
44
45
  progress;
45
46
  controller = null;
46
47
  unsubscribers = [];
48
+ /** Per-job progress throttle state: the last text emitted, and when. */
49
+ jobProgressAt = new Map();
50
+ /** Per-job `lastOutputAt` at which `stalled` was last reported. */
51
+ jobStalledAt = new Map();
47
52
  turns = 0;
48
53
  usage = { inputTokens: 0, outputTokens: 0 };
49
54
  compactionBilling;
@@ -218,8 +223,82 @@ export class ExecRunner {
218
223
  });
219
224
  if (compaction)
220
225
  this.unsubscribers.push(compaction);
226
+ // Background jobs (long-task-jobs §4.10). SUBSCRIBED HERE, IN `attach()`, NOT
227
+ // IN `start()`: the follow-through chain enters `run()` more than once, and a
228
+ // subscription made there would be re-made every time and never released —
229
+ // one leaked listener per turn for the life of the process.
230
+ //
231
+ // The four-stream shape is deliberate: `?:` optional call, `unsubscribers`
232
+ // push, `detach()` releases. Identical to `subscribeTeam` / `subscribeTodos` /
233
+ // `subscribeFast` / `subscribeCompaction`, so a host without a supervisor
234
+ // simply emits nothing.
235
+ const proc = controller.subscribeProc?.((event) => this.onProcEvent(event));
236
+ if (proc)
237
+ this.unsubscribers.push(proc);
221
238
  this.installTerminator();
222
239
  }
240
+ /**
241
+ * Map a supervisor event onto the `job` channel.
242
+ *
243
+ * THROTTLING IS THE WHOLE DISCIPLINE HERE. `output` is already coalesced to
244
+ * <=10 Hz inside the supervisor, which is still far too fast for a machine
245
+ * consumer reading a 40-minute training run; `progress` is therefore emitted
246
+ * only when the extracted TEXT CHANGES and never more than once a second. The
247
+ * other three subtypes are one-shot per job, so they pass straight through.
248
+ */
249
+ onProcEvent(event) {
250
+ const job = event.service;
251
+ if (event.type === 'started') {
252
+ this.emitJob(job, 'started');
253
+ return;
254
+ }
255
+ if (event.type === 'exited' || event.type === 'stopped') {
256
+ this.jobProgressAt.delete(job.id);
257
+ this.jobStalledAt.delete(job.id);
258
+ this.emitJob(job, 'exited');
259
+ return;
260
+ }
261
+ // `ready` is deliberately NOT mapped onto a second `started`: a service emits
262
+ // both, and a consumer that counted `started` events would see two. The
263
+ // readiness fact is already carried by `status` on any later event, and the
264
+ // four subtypes this channel publishes are one-shot per job.
265
+ if (event.type !== 'output')
266
+ return;
267
+ if (job.stalled === true) {
268
+ if (this.jobStalledAt.get(job.id) === job.lastOutputAt)
269
+ return;
270
+ this.jobStalledAt.set(job.id, job.lastOutputAt);
271
+ this.emitJob(job, 'stalled');
272
+ return;
273
+ }
274
+ const text = job.progress?.text;
275
+ if (text === undefined)
276
+ return;
277
+ if (this.jobProgressAt.get(job.id)?.text === text)
278
+ return;
279
+ const now = Date.now();
280
+ const last = this.jobProgressAt.get(job.id)?.at ?? 0;
281
+ // ONE SECOND IS THE FLOOR, and the first change of a job is always emitted
282
+ // (no previous entry), so a consumer sees the initial figure immediately.
283
+ if (last !== 0 && now - last < 1000)
284
+ return;
285
+ this.jobProgressAt.set(job.id, { text, at: now });
286
+ this.emitJob(job, 'progress', text);
287
+ }
288
+ emitJob(job, subtype, progress) {
289
+ this.emit({
290
+ type: 'job',
291
+ schemaVersion: EXEC_SCHEMA_VERSION,
292
+ sessionId: this.sessionId,
293
+ subtype,
294
+ jobId: job.id,
295
+ kind: job.kind,
296
+ status: job.status,
297
+ elapsedMs: Math.max(0, (job.endedAt ?? Date.now()) - job.startedAt),
298
+ ...(progress !== undefined ? { progress } : {}),
299
+ ...(subtype === 'exited' ? { exitCode: job.exitCode } : {}),
300
+ });
301
+ }
223
302
  /**
224
303
  * Drop the subscriptions and the timer.
225
304
  *
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cursor Position Report parsing (tui-bottom-anchored-live-region §4.5).
3
+ *
4
+ * The bottom anchor needs one number the writer cannot compute: how many rows are
5
+ * left below the cursor. `ESC[6n` is the only way to ask, and the terminal answers
6
+ * with `ESC[<row>;<col>R` (DECXCPR adds a `?`: `ESC[?<row>;<col>R`) on STDIN —
7
+ * the same stream the user types on. Two things follow, and both live here:
8
+ *
9
+ * 1. THE ANSWER MUST BE STRIPPED BEFORE INK SEES IT. A report that reaches Ink
10
+ * travels the keystroke path: `[` is 0x5B, so `PromptInput`'s control-sequence
11
+ * guard passes it through and the user's draft gets `[12;1R` typed into it (the
12
+ * same class of accident the mouse filter exists to prevent).
13
+ * 2. THE ANSWER CAN ARRIVE IN TWO CHUNKS. ConPTY, tmux and a loaded SSH session
14
+ * all split reads at arbitrary points, and a half report (`ESC[12`) is worse
15
+ * than a whole one: `parse-keypress` has no branch for it.
16
+ *
17
+ * Pure functions only, mirroring `mouse-events.ts`: the filter owns the buffering
18
+ * and the timers, this module owns the shapes.
19
+ */
20
+ /** The query for a Cursor Position Report (DSR 6). ASCII by construction. */
21
+ export declare const CURSOR_REPORT_QUERY = "\u001B[6n";
22
+ /** One parsed report. `row`/`col` are 1-based, exactly as the terminal sent them. */
23
+ export interface CursorReport {
24
+ row: number;
25
+ col: number;
26
+ }
27
+ export interface CursorReportSplit {
28
+ /** Text that survives stripping — everything that is not a report. */
29
+ text: string;
30
+ /** Reports found in this chunk, in order. */
31
+ reports: CursorReport[];
32
+ /** Trailing bytes that could still become a report; hand them to the next chunk. */
33
+ pending: string;
34
+ }
35
+ /** True when `s` is a non-empty proper prefix of a possible report. */
36
+ export declare function isCursorReportPrefix(s: string): boolean;
37
+ /**
38
+ * Split the reports out of one chunk.
39
+ *
40
+ * `pending` carries the tail that might still complete into a report. It is only
41
+ * ever the LAST fragment: the scan is left to right, so anything before a complete
42
+ * report is settled text.
43
+ */
44
+ export declare function splitCursorReports(text: string): CursorReportSplit;
45
+ /**
46
+ * Length of the trailing run of `text` that is a proper prefix of a report, or 0.
47
+ *
48
+ * LONGEST MATCH WINS, unlike the mouse family's shortest-wins rule. The families are
49
+ * combined with `max()`, so only the longest answer ever survives, and a longer hold
50
+ * is never wrong here: `ESC[1` can still become `ESC[12;1R`, while the shorter
51
+ * answer `ESC` could not describe it. The bound is the longest report the terminals
52
+ * we target emit (DECXCPR with a `?` and three-digit coordinates).
53
+ */
54
+ export declare function trailingCprPrefixLength(text: string): number;
55
+ /** The query bytes, as a function so call sites read as an action rather than a literal. */
56
+ export declare function formatCursorReportQuery(): string;
57
+ /**
58
+ * Rows left below a reported cursor row on a terminal of `rows` rows.
59
+ * `0` means the cursor is already on the last row; `null` when the height is unknown.
60
+ */
61
+ export declare function spaceBelowFromReport(report: CursorReport, rows: number | undefined): number | null;