@hncode/hncode 0.3.5 → 0.4.0

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 (147) hide show
  1. package/package.json +4 -2
  2. package/src/agent-task.js +154 -84
  3. package/src/agent.js +533 -145
  4. package/src/clipboard.js +5 -2
  5. package/src/colors.js +242 -238
  6. package/src/config.js +217 -17
  7. package/src/file-history.js +177 -0
  8. package/src/hooks.js +11 -1
  9. package/src/i18n.js +389 -0
  10. package/src/index.js +46 -3
  11. package/src/llm.js +139 -16
  12. package/src/permissions.js +120 -0
  13. package/src/session-hub.js +407 -0
  14. package/src/session.js +31 -0
  15. package/src/term.js +19 -3
  16. package/src/tool-result.js +236 -0
  17. package/src/tools/agent.js +11 -0
  18. package/src/tools/bash.js +84 -17
  19. package/src/tools/edit.js +192 -32
  20. package/src/tools/file-lines.js +5 -0
  21. package/src/tools/index.js +4 -0
  22. package/src/tools/memory.js +88 -0
  23. package/src/tools/read-media-file.js +85 -68
  24. package/src/tools/read.js +15 -1
  25. package/src/tools/utils.js +14 -0
  26. package/src/tools/write.js +35 -5
  27. package/src/tui.js +11665 -9179
  28. package/src/web-auth.js +98 -0
  29. package/src/web-daemon-cli.js +63 -0
  30. package/src/web-daemon-client.js +203 -0
  31. package/src/web-daemon.js +871 -0
  32. package/src/web-public/app.css +1509 -0
  33. package/src/web-public/app.js +2913 -0
  34. package/src/web-public/complete.js +154 -0
  35. package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
  36. package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
  38. package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
  39. package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
  40. package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
  41. package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
  42. package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
  43. package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
  44. package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
  45. package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
  46. package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
  47. package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
  48. package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
  49. package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
  50. package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
  51. package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
  52. package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
  53. package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
  54. package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
  55. package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
  56. package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
  57. package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
  58. package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
  59. package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
  60. package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
  61. package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
  62. package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
  63. package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
  64. package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
  65. package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
  66. package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
  67. package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
  68. package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
  69. package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
  70. package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
  71. package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
  72. package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
  73. package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
  74. package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
  75. package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
  76. package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
  77. package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
  78. package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
  79. package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
  80. package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
  81. package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
  82. package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
  83. package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
  84. package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
  85. package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
  86. package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
  87. package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
  88. package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
  89. package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
  90. package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
  91. package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
  92. package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
  93. package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
  94. package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
  95. package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
  96. package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
  97. package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
  98. package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
  99. package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
  100. package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
  101. package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
  102. package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
  103. package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
  104. package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
  105. package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
  106. package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
  107. package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
  108. package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
  109. package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
  110. package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
  111. package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
  112. package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
  113. package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
  114. package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
  115. package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
  116. package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
  117. package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
  118. package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
  119. package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
  120. package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
  121. package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
  122. package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
  123. package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
  124. package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
  125. package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
  126. package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
  127. package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
  128. package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
  129. package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
  130. package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
  131. package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
  132. package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
  133. package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
  134. package/src/web-public/fonts/noto-sans-sc.css +775 -0
  135. package/src/web-public/fonts.css +31 -0
  136. package/src/web-public/home.css +222 -0
  137. package/src/web-public/home.html +33 -0
  138. package/src/web-public/home.js +160 -0
  139. package/src/web-public/i18n.js +983 -0
  140. package/src/web-public/index.html +196 -0
  141. package/src/web-public/login.css +123 -0
  142. package/src/web-public/login.html +25 -0
  143. package/src/web-public/login.js +46 -0
  144. package/src/web-public/settings.js +290 -0
  145. package/src/web-ws.js +226 -0
  146. package/src/web.js +380 -0
  147. package/src/tui.js.bak_20260921_191527 +0 -9203
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -17,7 +17,9 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "preinstall": "node bin/preinstall.js",
20
- "install": "node bin/install.js"
20
+ "install": "node bin/install.js",
21
+ "i18n": "node tools/sync-i18n.mjs",
22
+ "i18n:check": "node tools/sync-i18n.mjs --check"
21
23
  },
22
24
  "engines": {
23
25
  "node": ">=20"
package/src/agent-task.js CHANGED
@@ -32,13 +32,25 @@ export function newTaskId(prefix = 'task') {
32
32
 
33
33
  // Decode a task's buffered chunks for display. Buffer chunks (Bash) are UTF-8;
34
34
  // string chunks (Agent/AgentSwarm) are already text.
35
+ //
36
+ // Tolerant of a RESTORED task: after a session round-trip the chunks may be
37
+ // strings, Buffers, `{type:'Buffer',data:[…]}` objects (what JSON.stringify does
38
+ // to a Buffer), or nothing at all. Anything unrecognisable is stringified rather
39
+ // than thrown on — a display helper must never be able to take the TUI down, and
40
+ // the output panel is exactly where a half-restored task shows up.
35
41
  function decodeTaskOutput(chunks) {
36
- const list = chunks || [];
42
+ if (chunks == null) return '';
43
+ const list = Array.isArray(chunks) ? chunks : [chunks];
37
44
  if (!list.length) return '';
38
- const allStrings = list.every((c) => typeof c === 'string');
39
- if (allStrings) return list.join('');
40
- try { return Buffer.concat(list.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(String(c))))).toString('utf8'); }
41
- catch { return list.map((c) => String(c)).join(''); }
45
+ const normalize = (c) => {
46
+ if (Buffer.isBuffer(c)) return c;
47
+ if (c && c.type === 'Buffer' && Array.isArray(c.data)) return Buffer.from(c.data);
48
+ if (typeof c === 'string') return c;
49
+ return String(c);
50
+ };
51
+ const items = list.map(normalize);
52
+ try { return Buffer.concat(items.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString('utf8'); }
53
+ catch { return items.map((c) => String(c)).join(''); }
42
54
  }
43
55
 
44
56
  // The task store lives on the agent ctx (`ctx.tasks`), so every tool and the TUI
@@ -98,12 +110,22 @@ export function createTask(ctx, kind, options = {}) {
98
110
  // * a process appends Buffer chunks -> decode as UTF-8
99
111
  // * an agent/swarm appends strings -> join
100
112
  // Callers may override this getter when they need a different decoding.
113
+ defineOutputGetter(task);
114
+ tasks[task.taskId] = task;
115
+ return task;
116
+ }
117
+
118
+ // Install the lazy `output` getter. Extracted so a RESTORED task can have it
119
+ // reinstalled: JSON.stringify resolves the getter into a plain string property, so
120
+ // a reloaded record carries a frozen snapshot instead of a live view (see
121
+ // appendTaskOutput, which repairs both `_chunks` and the getter). `configurable`
122
+ // must stay true or the reinstall would throw on an already-defined property.
123
+ export function defineOutputGetter(task) {
101
124
  Object.defineProperty(task, 'output', {
102
125
  enumerable: true,
103
126
  configurable: true,
104
127
  get() { return decodeTaskOutput(this._chunks); },
105
128
  });
106
- tasks[task.taskId] = task;
107
129
  return task;
108
130
  }
109
131
 
@@ -111,8 +133,56 @@ export function createTask(ctx, kind, options = {}) {
111
133
  export const MAX_TASK_OUTPUT_BYTES = 2 * 1024 * 1024;
112
134
 
113
135
  // Append raw bytes/chunks to a task, trimming the oldest past the cap.
136
+ //
137
+ // `_chunks` is repaired rather than assumed. The task store is reachable from
138
+ // three directions — created in-process, restored with a SESSION
139
+ // (`state.tasks`/`ctx.tasks` are copied verbatim into the session JSON and read
140
+ // back), and handed around between tools — so a record can arrive here without an
141
+ // array. A session round-trip also turns Buffers into `{type:'Buffer',data:[…]}`
142
+ // plain objects (JSON has no Buffer), and a record that never produced output can
143
+ // come back with no `_chunks` at all.
144
+ //
145
+ // This matters beyond tidiness: the only production callers are stdout/stderr
146
+ // 'data' listeners on a ChildProcess, and a throw inside an event listener is NOT
147
+ // caught by the surrounding tool. A missing array therefore surfaces as an
148
+ // unhandledRejection that kills the whole TUI mid-tool-call, with nothing printed
149
+ // (the exit handler wipes the alternate screen). Both observed shapes —
150
+ // "undefined.push" and "null.push" — come from exactly this line.
114
151
  export function appendTaskOutput(task, chunk) {
115
152
  if (!task || chunk == null) return;
153
+ if (!Array.isArray(task._chunks)) {
154
+ // Salvage whatever the old value held: a single chunk, an array-like, or a
155
+ // serialized Buffer object. A Buffer must be revived from its JSON form or the
156
+ // decoder would later try to concat a plain object.
157
+ const prev = task._chunks;
158
+ const revived = [];
159
+ if (prev != null) {
160
+ const items = Array.isArray(prev) ? prev : [prev];
161
+ for (const item of items) {
162
+ if (item && item.type === 'Buffer' && Array.isArray(item.data)) revived.push(Buffer.from(item.data));
163
+ else if (item != null) revived.push(item);
164
+ }
165
+ }
166
+ task._chunks = revived;
167
+ // `_bytes` may be missing too on a restored record; recount from what we kept
168
+ // so the size cap works from here on instead of comparing against undefined.
169
+ task._bytes = revived.reduce((n, c) => n + (c.length || 0), 0);
170
+ }
171
+ // `JSON.stringify` does not preserve the `output` GETTER: it reads the getter and
172
+ // stores the resulting STRING as an ordinary own property. A restored task's
173
+ // `_chunks` is still an array, so the repair above does NOT run for it — but its
174
+ // `output` is a frozen snapshot that no longer tracks `_chunks`, and would keep
175
+ // reporting the pre-save text (or `undefined`) for the rest of the session.
176
+ // Reinstall the live getter whenever the property is a data value rather than an
177
+ // accessor. Checked on every append; after the first repair it is an accessor and
178
+ // this is a cheap no-op.
179
+ try {
180
+ const desc = Object.getOwnPropertyDescriptor(task, 'output');
181
+ if (!desc || !desc.get) {
182
+ if (desc) delete task.output;
183
+ defineOutputGetter(task);
184
+ }
185
+ } catch { /* a frozen record still appends correctly, only its display lags */ }
116
186
  task._chunks.push(chunk);
117
187
  task._bytes = (task._bytes || 0) + (chunk.length || 0);
118
188
  while (task._bytes > MAX_TASK_OUTPUT_BYTES && task._chunks.length > 1) {
@@ -176,81 +246,81 @@ export function statusColor(status) {
176
246
  if (status === 'completed') return 'gray';
177
247
  return 'red';
178
248
  }
179
-
180
- // ---- background-task transcript card (mirrors kimi-code) --------------------
181
- // A background task reports its lifecycle as a one-line card in the transcript:
182
- //
183
- // ● agent task completed in background (review the diff)
184
- // ✗ bash task failed in background (npm test · exit 1)
185
- //
186
- // `phase` drives the bullet colour (started / completed / failed); `headline`
187
- // and the dim `detail` carry the rest. Ported from kimi-code's
188
- // formatBackgroundTaskTranscript (apps/kimi-code/src/tui/utils/background-task-status.ts)
189
- // so the two tools read the same way.
190
-
191
- const BG_DETAIL_MAX = 240;
192
-
193
- function bgTruncate(value) {
194
- if (value === undefined || value === null) return undefined;
195
- const collapsed = String(value).trim().replace(/\s+/g, ' ');
196
- if (!collapsed) return undefined;
197
- return collapsed.length <= BG_DETAIL_MAX ? collapsed : collapsed.slice(0, BG_DETAIL_MAX - 1) + '…';
198
- }
199
-
200
- export function backgroundTaskPhase(status) {
201
- if (status === 'running') return 'started';
202
- if (status === 'completed') return 'completed';
203
- return 'failed'; // failed / timed_out / killed / lost
204
- }
205
-
206
- function bgSubject(kind) {
207
- if (kind === 'agent') return 'agent task';
208
- if (kind === 'question') return 'question task';
209
- return 'bash task';
210
- }
211
-
212
- function bgHeadline(status, kind) {
213
- const subject = bgSubject(kind);
214
- switch (status) {
215
- case 'running': return `${subject} started in background`;
216
- case 'completed': return `${subject} completed in background`;
217
- case 'failed': return `${subject} failed in background`;
218
- case 'timed_out': return `${subject} timed out`;
219
- case 'killed': return `${subject} stopped`;
220
- case 'lost': return `${subject} lost`;
221
- default: return `${subject} ${status}`;
222
- }
223
- }
224
-
225
- function bgDetail(task) {
226
- const parts = [];
227
- const description = bgTruncate(task.description);
228
- if (description !== undefined) parts.push(description);
229
-
230
- if (task.status === 'completed' || task.status === 'failed') {
231
- if (task.kind === 'process' && task.exitCode !== null && task.exitCode !== undefined) {
232
- parts.push(`exit ${task.exitCode}`);
233
- }
234
- }
235
- if (task.status === 'killed') {
236
- const reason = bgTruncate(task.stopReason);
237
- parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped');
238
- }
239
- if (task.status === 'failed') {
240
- const reason = bgTruncate(task.stopReason);
241
- if (reason !== undefined) parts.push(reason);
242
- }
243
- if (task.status === 'timed_out') parts.push('timed out');
244
- if (task.status === 'lost') parts.push('session restarted before completion');
245
-
246
- return parts.length ? parts.join(' · ') : undefined;
247
- }
248
-
249
- // Build the card payload for a task snapshot.
250
- export function backgroundTaskCard(task) {
251
- return {
252
- phase: backgroundTaskPhase(task.status),
253
- headline: bgHeadline(task.status, task.kind),
254
- detail: bgDetail(task),
255
- };
256
- }
249
+
250
+ // ---- background-task transcript card (mirrors kimi-code) --------------------
251
+ // A background task reports its lifecycle as a one-line card in the transcript:
252
+ //
253
+ // ● agent task completed in background (review the diff)
254
+ // ✗ bash task failed in background (npm test · exit 1)
255
+ //
256
+ // `phase` drives the bullet colour (started / completed / failed); `headline`
257
+ // and the dim `detail` carry the rest. Ported from kimi-code's
258
+ // formatBackgroundTaskTranscript (apps/kimi-code/src/tui/utils/background-task-status.ts)
259
+ // so the two tools read the same way.
260
+
261
+ const BG_DETAIL_MAX = 240;
262
+
263
+ function bgTruncate(value) {
264
+ if (value === undefined || value === null) return undefined;
265
+ const collapsed = String(value).trim().replace(/\s+/g, ' ');
266
+ if (!collapsed) return undefined;
267
+ return collapsed.length <= BG_DETAIL_MAX ? collapsed : collapsed.slice(0, BG_DETAIL_MAX - 1) + '…';
268
+ }
269
+
270
+ export function backgroundTaskPhase(status) {
271
+ if (status === 'running') return 'started';
272
+ if (status === 'completed') return 'completed';
273
+ return 'failed'; // failed / timed_out / killed / lost
274
+ }
275
+
276
+ function bgSubject(kind) {
277
+ if (kind === 'agent') return 'agent task';
278
+ if (kind === 'question') return 'question task';
279
+ return 'bash task';
280
+ }
281
+
282
+ function bgHeadline(status, kind) {
283
+ const subject = bgSubject(kind);
284
+ switch (status) {
285
+ case 'running': return `${subject} started in background`;
286
+ case 'completed': return `${subject} completed in background`;
287
+ case 'failed': return `${subject} failed in background`;
288
+ case 'timed_out': return `${subject} timed out`;
289
+ case 'killed': return `${subject} stopped`;
290
+ case 'lost': return `${subject} lost`;
291
+ default: return `${subject} ${status}`;
292
+ }
293
+ }
294
+
295
+ function bgDetail(task) {
296
+ const parts = [];
297
+ const description = bgTruncate(task.description);
298
+ if (description !== undefined) parts.push(description);
299
+
300
+ if (task.status === 'completed' || task.status === 'failed') {
301
+ if (task.kind === 'process' && task.exitCode !== null && task.exitCode !== undefined) {
302
+ parts.push(`exit ${task.exitCode}`);
303
+ }
304
+ }
305
+ if (task.status === 'killed') {
306
+ const reason = bgTruncate(task.stopReason);
307
+ parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped');
308
+ }
309
+ if (task.status === 'failed') {
310
+ const reason = bgTruncate(task.stopReason);
311
+ if (reason !== undefined) parts.push(reason);
312
+ }
313
+ if (task.status === 'timed_out') parts.push('timed out');
314
+ if (task.status === 'lost') parts.push('session restarted before completion');
315
+
316
+ return parts.length ? parts.join(' · ') : undefined;
317
+ }
318
+
319
+ // Build the card payload for a task snapshot.
320
+ export function backgroundTaskCard(task) {
321
+ return {
322
+ phase: backgroundTaskPhase(task.status),
323
+ headline: bgHeadline(task.status, task.kind),
324
+ detail: bgDetail(task),
325
+ };
326
+ }