@jmoyers/harness 0.1.11 → 0.1.20

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 (232) hide show
  1. package/README.md +31 -39
  2. package/package.json +31 -11
  3. package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
  4. package/packages/harness-ai/src/stream-text.ts +13 -91
  5. package/packages/harness-ui/src/frame-primitives.ts +158 -0
  6. package/packages/harness-ui/src/index.ts +18 -0
  7. package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
  8. package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
  9. package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
  10. package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
  11. package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
  12. package/packages/harness-ui/src/interaction/input.ts +420 -0
  13. package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
  14. package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
  15. package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
  16. package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
  17. package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
  18. package/packages/harness-ui/src/kit.ts +476 -0
  19. package/packages/harness-ui/src/layout.ts +238 -0
  20. package/packages/harness-ui/src/modal-manager.ts +222 -0
  21. package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
  22. package/packages/harness-ui/src/surface.ts +252 -0
  23. package/packages/harness-ui/src/text-layout.ts +210 -0
  24. package/packages/nim-core/src/contracts.ts +239 -0
  25. package/packages/nim-core/src/event-store.ts +299 -0
  26. package/packages/nim-core/src/events.ts +53 -0
  27. package/packages/nim-core/src/index.ts +9 -0
  28. package/packages/nim-core/src/provider-router.ts +129 -0
  29. package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
  30. package/packages/nim-core/src/runtime-factory.ts +49 -0
  31. package/packages/nim-core/src/runtime.ts +1797 -0
  32. package/packages/nim-core/src/session-store.ts +516 -0
  33. package/packages/nim-core/src/telemetry.ts +48 -0
  34. package/packages/nim-test-tui/src/index.ts +150 -0
  35. package/packages/nim-ui-core/src/index.ts +1 -0
  36. package/packages/nim-ui-core/src/projection.ts +87 -0
  37. package/scripts/codex-live-mux-runtime.ts +2 -3872
  38. package/scripts/control-plane-daemon.ts +11 -0
  39. package/scripts/harness-bin.js +5 -0
  40. package/scripts/harness-commands.ts +300 -0
  41. package/scripts/harness-runtime.ts +82 -0
  42. package/scripts/harness.ts +33 -3019
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/gateway/runtime.ts +1872 -0
  46. package/src/cli/parsing/flags.ts +23 -0
  47. package/src/cli/parsing/session.ts +42 -0
  48. package/src/cli/runtime/context.ts +193 -0
  49. package/src/cli/runtime-app/application.ts +392 -0
  50. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  51. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  52. package/src/cli/workflows/runtime.ts +965 -0
  53. package/src/clients/tui/left-rail-interactions.ts +519 -0
  54. package/src/clients/tui/main-pane-interactions.ts +509 -0
  55. package/src/clients/tui/modal-input-routing.ts +71 -0
  56. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  57. package/src/clients/web/synced-selectors.ts +132 -0
  58. package/src/codex/live-session.ts +82 -29
  59. package/src/config/config-core.ts +348 -8
  60. package/src/config/harness.config.template.jsonc +33 -0
  61. package/src/control-plane/agent-realtime-api.ts +82 -427
  62. package/src/control-plane/session-summary.ts +10 -81
  63. package/src/control-plane/status/reducer-base.ts +12 -12
  64. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  65. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  66. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  67. package/src/control-plane/stream-client.ts +12 -2
  68. package/src/control-plane/stream-command-parser.ts +83 -143
  69. package/src/control-plane/stream-protocol.ts +53 -37
  70. package/src/control-plane/stream-server-command.ts +376 -69
  71. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  72. package/src/control-plane/stream-server.ts +864 -70
  73. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  74. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  75. package/src/core/state/observed-stream-cursor.ts +43 -0
  76. package/src/core/state/synced-observed-state.ts +273 -0
  77. package/src/core/store/harness-synced-store.ts +81 -0
  78. package/src/diff/budget.ts +136 -0
  79. package/src/diff/build.ts +289 -0
  80. package/src/diff/chunker.ts +146 -0
  81. package/src/diff/git-invoke.ts +315 -0
  82. package/src/diff/git-parse.ts +472 -0
  83. package/src/diff/hash.ts +70 -0
  84. package/src/diff/index.ts +24 -0
  85. package/src/diff/normalize.ts +134 -0
  86. package/src/diff/types.ts +178 -0
  87. package/src/diff-ui/args.ts +346 -0
  88. package/src/diff-ui/commands.ts +123 -0
  89. package/src/diff-ui/finder.ts +94 -0
  90. package/src/diff-ui/highlight.ts +127 -0
  91. package/src/diff-ui/index.ts +2 -0
  92. package/src/diff-ui/model.ts +141 -0
  93. package/src/diff-ui/pager.ts +412 -0
  94. package/src/diff-ui/render.ts +337 -0
  95. package/src/diff-ui/runtime.ts +379 -0
  96. package/src/diff-ui/state.ts +224 -0
  97. package/src/diff-ui/types.ts +236 -0
  98. package/src/domain/workspace.ts +68 -5
  99. package/src/mux/control-plane-op-queue.ts +93 -7
  100. package/src/mux/conversation-rail.ts +28 -71
  101. package/src/mux/dual-pane-core.ts +13 -13
  102. package/src/mux/harness-core-ui.ts +313 -42
  103. package/src/mux/input-shortcuts.ts +13 -131
  104. package/src/mux/keybinding-catalog.ts +340 -0
  105. package/src/mux/keybinding-registry.ts +103 -0
  106. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  107. package/src/mux/live-mux/command-menu.ts +167 -4
  108. package/src/mux/live-mux/conversation-state.ts +13 -0
  109. package/src/mux/live-mux/directory-resolution.ts +1 -1
  110. package/src/mux/live-mux/git-snapshot.ts +33 -2
  111. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  112. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  113. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  114. package/src/mux/live-mux/input-forwarding.ts +59 -2
  115. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  116. package/src/mux/live-mux/left-nav.ts +35 -0
  117. package/src/mux/live-mux/link-click.ts +292 -0
  118. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  119. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  120. package/src/mux/live-mux/modal-input-reducers.ts +77 -12
  121. package/src/mux/live-mux/modal-overlays.ts +168 -34
  122. package/src/mux/live-mux/modal-pointer.ts +3 -7
  123. package/src/mux/live-mux/modal-prompt-handlers.ts +23 -2
  124. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  125. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  126. package/src/mux/live-mux/pointer-routing.ts +5 -2
  127. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  128. package/src/mux/live-mux/rail-layout.ts +33 -30
  129. package/src/mux/live-mux/release-notes.ts +383 -0
  130. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  131. package/src/mux/live-mux/repository-folding.ts +3 -0
  132. package/src/mux/live-mux/selection.ts +0 -4
  133. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  134. package/src/mux/project-pane-github-review.ts +271 -0
  135. package/src/mux/render-frame.ts +4 -0
  136. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  137. package/src/mux/task-composer.ts +21 -14
  138. package/src/mux/task-focused-pane.ts +118 -117
  139. package/src/mux/task-screen-keybindings.ts +10 -101
  140. package/src/mux/workspace-rail-model.ts +270 -104
  141. package/src/mux/workspace-rail.ts +45 -22
  142. package/src/pty/session-broker.ts +1 -1
  143. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  144. package/src/services/control-plane.ts +50 -32
  145. package/src/services/conversation-lifecycle.ts +118 -87
  146. package/src/services/conversation-startup-hydration.ts +20 -12
  147. package/src/services/directory-hydration.ts +21 -16
  148. package/src/services/event-persistence.ts +7 -0
  149. package/src/services/left-rail-pointer-handler.ts +329 -0
  150. package/src/services/mux-ui-state-persistence.ts +5 -1
  151. package/src/services/recording.ts +34 -26
  152. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  153. package/src/services/runtime-control-actions.ts +79 -61
  154. package/src/services/runtime-control-plane-ops.ts +122 -83
  155. package/src/services/runtime-conversation-actions.ts +40 -26
  156. package/src/services/runtime-conversation-activation.ts +73 -46
  157. package/src/services/runtime-conversation-starter.ts +53 -45
  158. package/src/services/runtime-conversation-title-edit.ts +91 -80
  159. package/src/services/runtime-envelope-handler.ts +107 -105
  160. package/src/services/runtime-git-state.ts +42 -29
  161. package/src/services/runtime-layout-resize.ts +3 -1
  162. package/src/services/runtime-left-rail-render.ts +99 -63
  163. package/src/services/runtime-nim-cli-session.ts +438 -0
  164. package/src/services/runtime-nim-session.ts +705 -0
  165. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  166. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  167. package/src/services/runtime-process-wiring.ts +29 -36
  168. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  169. package/src/services/runtime-render-flush.ts +63 -70
  170. package/src/services/runtime-render-lifecycle.ts +65 -64
  171. package/src/services/runtime-render-orchestrator.ts +55 -45
  172. package/src/services/runtime-render-pipeline.ts +106 -103
  173. package/src/services/runtime-render-state.ts +62 -49
  174. package/src/services/runtime-repository-actions.ts +97 -72
  175. package/src/services/runtime-right-pane-render.ts +80 -53
  176. package/src/services/runtime-shutdown.ts +38 -35
  177. package/src/services/runtime-stream-subscriptions.ts +35 -27
  178. package/src/services/runtime-task-composer-persistence.ts +71 -59
  179. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  180. package/src/services/runtime-task-editor-actions.ts +46 -29
  181. package/src/services/runtime-task-pane-actions.ts +220 -134
  182. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  183. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  184. package/src/services/runtime-workspace-observed-events.ts +33 -184
  185. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  186. package/src/services/session-diagnostics-store.ts +217 -0
  187. package/src/services/startup-background-resume.ts +26 -21
  188. package/src/services/startup-orchestrator.ts +16 -13
  189. package/src/services/startup-paint-tracker.ts +29 -21
  190. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  191. package/src/services/startup-settled-gate.ts +25 -15
  192. package/src/services/startup-shutdown.ts +18 -22
  193. package/src/services/startup-state-hydration.ts +44 -34
  194. package/src/services/startup-visibility.ts +12 -4
  195. package/src/services/task-pane-selection-actions.ts +89 -72
  196. package/src/services/task-planning-hydration.ts +24 -18
  197. package/src/services/task-planning-observed-events.ts +50 -52
  198. package/src/services/workspace-observed-events.ts +66 -63
  199. package/src/storage/storage-lifecycle-core.ts +438 -0
  200. package/src/store/control-plane-store-normalize.ts +33 -242
  201. package/src/store/control-plane-store-types.ts +1 -35
  202. package/src/store/control-plane-store.ts +360 -56
  203. package/src/store/event-store.ts +366 -8
  204. package/src/terminal/snapshot-oracle.ts +207 -94
  205. package/src/ui/mux-theme.ts +112 -8
  206. package/src/ui/panes/home-gridfire.ts +40 -31
  207. package/src/ui/panes/home.ts +10 -2
  208. package/src/ui/panes/nim.ts +315 -0
  209. package/src/mux/live-mux/actions-task.ts +0 -115
  210. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  211. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -85
  212. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  213. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  214. package/src/services/runtime-directory-actions.ts +0 -164
  215. package/src/services/runtime-input-pipeline.ts +0 -50
  216. package/src/services/runtime-input-router.ts +0 -195
  217. package/src/services/runtime-main-pane-input.ts +0 -230
  218. package/src/services/runtime-modal-input.ts +0 -137
  219. package/src/services/runtime-navigation-input.ts +0 -197
  220. package/src/services/runtime-rail-input.ts +0 -279
  221. package/src/services/runtime-task-pane.ts +0 -62
  222. package/src/services/runtime-workspace-actions.ts +0 -158
  223. package/src/ui/conversation-input-forwarder.ts +0 -114
  224. package/src/ui/conversation-selection-input.ts +0 -103
  225. package/src/ui/global-shortcut-input.ts +0 -89
  226. package/src/ui/input.ts +0 -269
  227. package/src/ui/kit.ts +0 -509
  228. package/src/ui/left-nav-input.ts +0 -80
  229. package/src/ui/left-rail-pointer-input.ts +0 -148
  230. package/src/ui/modals/manager.ts +0 -218
  231. package/src/ui/repository-fold-input.ts +0 -91
  232. package/src/ui/surface.ts +0 -224
@@ -0,0 +1,383 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { dirname, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { resolveHarnessWorkspaceDirectory } from '../../config/harness-paths.ts';
13
+
14
+ const RELEASE_NOTES_STATE_FILE_NAME = 'release-notes.json';
15
+ const DEFAULT_RELEASE_NOTES_URL = 'https://github.com/jmoyers/harness/releases';
16
+ const DEFAULT_RELEASE_NOTES_API_URL = 'https://api.github.com/repos/jmoyers/harness/releases';
17
+ const PACKAGE_JSON_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../../../package.json');
18
+
19
+ const RELEASE_NOTES_STATE_VERSION = 1;
20
+
21
+ interface ParsedSemver {
22
+ readonly major: number;
23
+ readonly minor: number;
24
+ readonly patch: number;
25
+ readonly prerelease: readonly (number | string)[] | null;
26
+ }
27
+
28
+ interface NormalizedGitHubRelease {
29
+ readonly tag: string;
30
+ readonly name: string;
31
+ readonly url: string;
32
+ readonly body: string;
33
+ }
34
+
35
+ export interface ReleaseNotesState {
36
+ readonly version: typeof RELEASE_NOTES_STATE_VERSION;
37
+ readonly neverShow: boolean;
38
+ readonly dismissedLatestTag: string | null;
39
+ }
40
+
41
+ interface ReleaseNotesPromptRelease {
42
+ readonly tag: string;
43
+ readonly name: string;
44
+ readonly url: string;
45
+ readonly previewLines: readonly string[];
46
+ readonly previewTruncated: boolean;
47
+ }
48
+
49
+ export interface ReleaseNotesPrompt {
50
+ readonly currentVersion: string;
51
+ readonly latestTag: string;
52
+ readonly releases: readonly ReleaseNotesPromptRelease[];
53
+ readonly releasesPageUrl: string;
54
+ }
55
+
56
+ interface ResolveReleaseNotesPromptOptions {
57
+ readonly currentVersion: string;
58
+ readonly releases: readonly NormalizedGitHubRelease[];
59
+ readonly previewLineCount: number;
60
+ readonly maxReleases: number;
61
+ }
62
+
63
+ interface FetchReleaseNotesPromptOptions {
64
+ readonly currentVersion: string;
65
+ readonly previewLineCount: number;
66
+ readonly maxReleases: number;
67
+ readonly fetchImpl?: typeof fetch;
68
+ readonly apiUrl?: string;
69
+ readonly releasesPageUrl?: string;
70
+ }
71
+
72
+ function parseSemverTag(value: string): ParsedSemver | null {
73
+ const trimmed = value.trim();
74
+ const normalized = trimmed.startsWith('v') ? trimmed.slice(1) : trimmed;
75
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/u.exec(normalized);
76
+ if (match === null) {
77
+ return null;
78
+ }
79
+ const major = Number.parseInt(match[1]!, 10);
80
+ const minor = Number.parseInt(match[2]!, 10);
81
+ const patch = Number.parseInt(match[3]!, 10);
82
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) {
83
+ return null;
84
+ }
85
+ const prereleaseRaw = match[4];
86
+ if (prereleaseRaw === undefined) {
87
+ return {
88
+ major,
89
+ minor,
90
+ patch,
91
+ prerelease: null,
92
+ };
93
+ }
94
+ const prerelease = prereleaseRaw
95
+ .split('.')
96
+ .map((part) => {
97
+ if (/^\d+$/u.test(part)) {
98
+ return Number.parseInt(part, 10);
99
+ }
100
+ return part;
101
+ })
102
+ .filter((part) => (typeof part === 'number' ? Number.isFinite(part) : part.length > 0));
103
+ return {
104
+ major,
105
+ minor,
106
+ patch,
107
+ prerelease: prerelease.length > 0 ? prerelease : null,
108
+ };
109
+ }
110
+
111
+ function comparePrerelease(
112
+ left: readonly (number | string)[] | null,
113
+ right: readonly (number | string)[] | null,
114
+ ): number {
115
+ if (left === null && right === null) {
116
+ return 0;
117
+ }
118
+ if (left === null) {
119
+ return 1;
120
+ }
121
+ if (right === null) {
122
+ return -1;
123
+ }
124
+ const maxLength = Math.max(left.length, right.length);
125
+ for (let index = 0; index < maxLength; index += 1) {
126
+ const leftEntry = left[index];
127
+ const rightEntry = right[index];
128
+ if (leftEntry === undefined) {
129
+ return -1;
130
+ }
131
+ if (rightEntry === undefined) {
132
+ return 1;
133
+ }
134
+ if (leftEntry === rightEntry) {
135
+ continue;
136
+ }
137
+ const leftNumber = typeof leftEntry === 'number';
138
+ const rightNumber = typeof rightEntry === 'number';
139
+ if (leftNumber && rightNumber) {
140
+ return leftEntry < rightEntry ? -1 : 1;
141
+ }
142
+ if (leftNumber && !rightNumber) {
143
+ return -1;
144
+ }
145
+ if (!leftNumber && rightNumber) {
146
+ return 1;
147
+ }
148
+ return String(leftEntry).localeCompare(String(rightEntry));
149
+ }
150
+ return 0;
151
+ }
152
+
153
+ export function compareSemverTags(leftTag: string, rightTag: string): number {
154
+ const left = parseSemverTag(leftTag);
155
+ const right = parseSemverTag(rightTag);
156
+ if (left === null || right === null) {
157
+ return leftTag.localeCompare(rightTag);
158
+ }
159
+ if (left.major !== right.major) {
160
+ return left.major < right.major ? -1 : 1;
161
+ }
162
+ if (left.minor !== right.minor) {
163
+ return left.minor < right.minor ? -1 : 1;
164
+ }
165
+ if (left.patch !== right.patch) {
166
+ return left.patch < right.patch ? -1 : 1;
167
+ }
168
+ return comparePrerelease(left.prerelease, right.prerelease);
169
+ }
170
+
171
+ function previewLinesForBody(
172
+ body: string,
173
+ maxLines: number,
174
+ ): {
175
+ readonly lines: readonly string[];
176
+ readonly truncated: boolean;
177
+ } {
178
+ const safeMaxLines = Math.max(1, Math.floor(maxLines));
179
+ const normalizedLines = body
180
+ .replace(/\r\n?/gu, '\n')
181
+ .split('\n')
182
+ .map((line) => line.trim())
183
+ .filter((line) => line.length > 0);
184
+ return {
185
+ lines: normalizedLines.slice(0, safeMaxLines),
186
+ truncated: normalizedLines.length > safeMaxLines,
187
+ };
188
+ }
189
+
190
+ function asRecord(value: unknown): Record<string, unknown> | null {
191
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
192
+ return null;
193
+ }
194
+ return value as Record<string, unknown>;
195
+ }
196
+
197
+ function parseGitHubReleaseList(raw: unknown): readonly NormalizedGitHubRelease[] {
198
+ if (!Array.isArray(raw)) {
199
+ return [];
200
+ }
201
+ const parsed: NormalizedGitHubRelease[] = [];
202
+ const seenTags = new Set<string>();
203
+ for (const item of raw) {
204
+ const record = asRecord(item);
205
+ if (record === null) {
206
+ continue;
207
+ }
208
+ if (record['draft'] === true || record['prerelease'] === true) {
209
+ continue;
210
+ }
211
+ const tagName = record['tag_name'];
212
+ const htmlUrl = record['html_url'];
213
+ if (typeof tagName !== 'string' || typeof htmlUrl !== 'string') {
214
+ continue;
215
+ }
216
+ const tag = tagName.trim();
217
+ const url = htmlUrl.trim();
218
+ if (tag.length === 0 || url.length === 0 || seenTags.has(tag)) {
219
+ continue;
220
+ }
221
+ seenTags.add(tag);
222
+ parsed.push({
223
+ tag,
224
+ name: typeof record['name'] === 'string' ? record['name'].trim() : '',
225
+ url,
226
+ body: typeof record['body'] === 'string' ? record['body'] : '',
227
+ });
228
+ }
229
+ parsed.sort((left, right) => compareSemverTags(right.tag, left.tag));
230
+ return parsed;
231
+ }
232
+
233
+ export function resolveReleaseNotesPrompt(
234
+ options: ResolveReleaseNotesPromptOptions,
235
+ ): ReleaseNotesPrompt | null {
236
+ const sorted = [...options.releases].sort((left, right) =>
237
+ compareSemverTags(right.tag, left.tag),
238
+ );
239
+ if (sorted.length === 0) {
240
+ return null;
241
+ }
242
+ const newer = sorted.filter(
243
+ (release) => compareSemverTags(release.tag, options.currentVersion) > 0,
244
+ );
245
+ if (newer.length === 0) {
246
+ return null;
247
+ }
248
+ const safeMaxReleases = Math.max(1, Math.floor(options.maxReleases));
249
+ const releases = newer.slice(0, safeMaxReleases).map((release) => {
250
+ const preview = previewLinesForBody(release.body, options.previewLineCount);
251
+ return {
252
+ tag: release.tag,
253
+ name: release.name,
254
+ url: release.url,
255
+ previewLines: preview.lines,
256
+ previewTruncated: preview.truncated,
257
+ };
258
+ });
259
+ return {
260
+ currentVersion: options.currentVersion,
261
+ latestTag: newer[0]!.tag,
262
+ releases,
263
+ releasesPageUrl: DEFAULT_RELEASE_NOTES_URL,
264
+ };
265
+ }
266
+
267
+ export async function fetchReleaseNotesPrompt(
268
+ options: FetchReleaseNotesPromptOptions,
269
+ ): Promise<ReleaseNotesPrompt | null> {
270
+ const fetchImpl = options.fetchImpl ?? fetch;
271
+ const apiUrl = options.apiUrl ?? DEFAULT_RELEASE_NOTES_API_URL;
272
+ const releasesPageUrl = options.releasesPageUrl ?? DEFAULT_RELEASE_NOTES_URL;
273
+ try {
274
+ const response = await fetchImpl(`${apiUrl}?per_page=20`, {
275
+ headers: {
276
+ Accept: 'application/vnd.github+json',
277
+ 'User-Agent': `harness/${options.currentVersion}`,
278
+ },
279
+ });
280
+ if (!response.ok) {
281
+ return null;
282
+ }
283
+ const raw = (await response.json()) as unknown;
284
+ const releases = parseGitHubReleaseList(raw);
285
+ const prompt = resolveReleaseNotesPrompt({
286
+ currentVersion: options.currentVersion,
287
+ releases,
288
+ previewLineCount: options.previewLineCount,
289
+ maxReleases: options.maxReleases,
290
+ });
291
+ if (prompt === null) {
292
+ return null;
293
+ }
294
+ return {
295
+ ...prompt,
296
+ releasesPageUrl,
297
+ };
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ function defaultReleaseNotesState(): ReleaseNotesState {
304
+ return {
305
+ version: RELEASE_NOTES_STATE_VERSION,
306
+ neverShow: false,
307
+ dismissedLatestTag: null,
308
+ };
309
+ }
310
+
311
+ export function parseReleaseNotesState(raw: unknown): ReleaseNotesState | null {
312
+ const record = asRecord(raw);
313
+ if (record === null) {
314
+ return null;
315
+ }
316
+ if (record['version'] !== RELEASE_NOTES_STATE_VERSION) {
317
+ return null;
318
+ }
319
+ const neverShow = record['neverShow'];
320
+ const dismissedLatestTag = record['dismissedLatestTag'];
321
+ if (typeof neverShow !== 'boolean') {
322
+ return null;
323
+ }
324
+ if (dismissedLatestTag !== null && typeof dismissedLatestTag !== 'string') {
325
+ return null;
326
+ }
327
+ return {
328
+ version: RELEASE_NOTES_STATE_VERSION,
329
+ neverShow,
330
+ dismissedLatestTag,
331
+ };
332
+ }
333
+
334
+ export function resolveReleaseNotesStatePath(
335
+ invocationDirectory: string,
336
+ env: NodeJS.ProcessEnv = process.env,
337
+ ): string {
338
+ return resolve(
339
+ resolveHarnessWorkspaceDirectory(invocationDirectory, env),
340
+ RELEASE_NOTES_STATE_FILE_NAME,
341
+ );
342
+ }
343
+
344
+ export function readReleaseNotesState(statePath: string): ReleaseNotesState {
345
+ if (!existsSync(statePath)) {
346
+ return defaultReleaseNotesState();
347
+ }
348
+ try {
349
+ const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
350
+ return parseReleaseNotesState(raw) ?? defaultReleaseNotesState();
351
+ } catch {
352
+ return defaultReleaseNotesState();
353
+ }
354
+ }
355
+
356
+ export function writeReleaseNotesState(statePath: string, state: ReleaseNotesState): void {
357
+ const tempPath = `${statePath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
358
+ try {
359
+ mkdirSync(dirname(statePath), { recursive: true });
360
+ writeFileSync(tempPath, `${JSON.stringify(state)}\n`, 'utf8');
361
+ renameSync(tempPath, statePath);
362
+ } catch (error: unknown) {
363
+ try {
364
+ unlinkSync(tempPath);
365
+ } catch {
366
+ // Best-effort cleanup only.
367
+ }
368
+ throw error;
369
+ }
370
+ }
371
+
372
+ export function readInstalledHarnessVersion(packageJsonPath: string = PACKAGE_JSON_PATH): string {
373
+ try {
374
+ const raw = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as unknown;
375
+ const version = asRecord(raw)?.['version'];
376
+ if (typeof version === 'string' && version.trim().length > 0) {
377
+ return version.trim();
378
+ }
379
+ } catch {
380
+ // Fall back to a safe value that still allows release comparisons.
381
+ }
382
+ return '0.0.0';
383
+ }
@@ -1,6 +1,7 @@
1
1
  const ESC = '\u001b';
2
2
 
3
3
  const SUPPORTED_ESC_SINGLE = new Set(['7', '8', 'D', 'E', 'M', 'H', 'c']);
4
+ const SUPPORTED_C0 = new Set(['\b', '\t', '\n', '\r']);
4
5
  const SUPPORTED_CSI_FINALS = new Set([
5
6
  'm',
6
7
  'A',
@@ -27,9 +28,13 @@ const SUPPORTED_PRIVATE_MODE_PARAMS = new Set([
27
28
  6, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 2004, 1047, 1048, 1049,
28
29
  ]);
29
30
 
30
- type RenderTraceControlIssueKind = 'unsupported-esc' | 'unsupported-csi' | 'unsupported-dcs';
31
+ export type RenderTraceControlIssueKind =
32
+ | 'unsupported-c0'
33
+ | 'unsupported-esc'
34
+ | 'unsupported-csi'
35
+ | 'unsupported-dcs';
31
36
 
32
- interface RenderTraceControlIssue {
37
+ export interface RenderTraceControlIssue {
33
38
  readonly kind: RenderTraceControlIssueKind;
34
39
  readonly offset: number;
35
40
  readonly sequence: string;
@@ -37,6 +42,37 @@ interface RenderTraceControlIssue {
37
42
  readonly rawParams?: string;
38
43
  }
39
44
 
45
+ function isUnsupportedControlCharacter(char: string): boolean {
46
+ if (char === ESC || SUPPORTED_C0.has(char)) {
47
+ return false;
48
+ }
49
+ const code = char.charCodeAt(0);
50
+ if (code < 0x20) {
51
+ return true;
52
+ }
53
+ return code === 0x7f || (code >= 0x80 && code < 0xa0);
54
+ }
55
+
56
+ function escapeCharForPreview(char: string): string {
57
+ if (char === '\r') {
58
+ return '\\r';
59
+ }
60
+ if (char === '\n') {
61
+ return '\\n';
62
+ }
63
+ if (char === '\t') {
64
+ return '\\t';
65
+ }
66
+ if (char === ESC) {
67
+ return '\\u001b';
68
+ }
69
+ const code = char.charCodeAt(0);
70
+ if (code < 0x20 || code === 0x7f || (code >= 0x80 && code < 0xa0)) {
71
+ return `\\u${code.toString(16).padStart(4, '0')}`;
72
+ }
73
+ return char;
74
+ }
75
+
40
76
  function isLikelyCsiQueryPayload(payload: string): boolean {
41
77
  if (/^(?:c|0c|>c|>0c)$/u.test(payload)) {
42
78
  return true;
@@ -88,11 +124,10 @@ function csiSupported(rawParams: string, finalByte: string): boolean {
88
124
 
89
125
  export function renderTraceChunkPreview(chunk: Buffer | string, maxChars = 200): string {
90
126
  const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
91
- const replaced = text
92
- .replaceAll('\r', '\\r')
93
- .replaceAll('\n', '\\n')
94
- .replaceAll('\t', '\\t')
95
- .replaceAll(ESC, '\\u001b');
127
+ let replaced = '';
128
+ for (const char of text) {
129
+ replaced += escapeCharForPreview(char);
130
+ }
96
131
  if (replaced.length <= maxChars) {
97
132
  return replaced;
98
133
  }
@@ -108,6 +143,16 @@ export function findRenderTraceControlIssues(
108
143
 
109
144
  let index = 0;
110
145
  while (index < text.length && issues.length < maxIssues) {
146
+ const current = text[index]!;
147
+ if (isUnsupportedControlCharacter(current)) {
148
+ issues.push({
149
+ kind: 'unsupported-c0',
150
+ offset: index,
151
+ sequence: current,
152
+ });
153
+ index += 1;
154
+ continue;
155
+ }
111
156
  if (text[index] !== ESC) {
112
157
  index += 1;
113
158
  continue;
@@ -12,6 +12,9 @@ export function selectedRepositoryGroupIdForLeftNav(
12
12
  if (leftNavSelection.kind === 'project') {
13
13
  return repositoryGroupIdForDirectory(leftNavSelection.directoryId);
14
14
  }
15
+ if (leftNavSelection.kind === 'github') {
16
+ return repositoryGroupIdForDirectory(leftNavSelection.directoryId);
17
+ }
15
18
  if (leftNavSelection.kind === 'conversation') {
16
19
  const conversation = conversations.get(leftNavSelection.sessionId);
17
20
  if (conversation?.directoryId !== null && conversation?.directoryId !== undefined) {
@@ -89,10 +89,6 @@ export function hasAltModifier(code: number): boolean {
89
89
  return (code & 0b0000_1000) !== 0;
90
90
  }
91
91
 
92
- export function hasShiftModifier(code: number): boolean {
93
- return (code & 0b0000_0100) !== 0;
94
- }
95
-
96
92
  export function isLeftButtonPress(code: number, final: 'M' | 'm'): boolean {
97
93
  if (final !== 'M') {
98
94
  return false;
@@ -0,0 +1,21 @@
1
+ import { resolve } from 'node:path';
2
+ import { resolveHarnessWorkspaceDirectory } from '../../config/harness-paths.ts';
3
+
4
+ export const DEFAULT_SESSION_DIAGNOSTICS_ROOT_PATH = 'session-diagnostics';
5
+
6
+ export function resolveSessionDiagnosticsDirectory(
7
+ invocationDirectory: string,
8
+ sessionName: string | null,
9
+ env: NodeJS.ProcessEnv = process.env,
10
+ ): string {
11
+ const workspaceDirectory = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
12
+ if (sessionName === null) {
13
+ return resolve(workspaceDirectory, DEFAULT_SESSION_DIAGNOSTICS_ROOT_PATH);
14
+ }
15
+ return resolve(
16
+ workspaceDirectory,
17
+ 'sessions',
18
+ sessionName,
19
+ DEFAULT_SESSION_DIAGNOSTICS_ROOT_PATH,
20
+ );
21
+ }