@corenel/term 0.1.0 → 0.2.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 (55) hide show
  1. package/dist/chunk-2D7G4YW2.js +97 -0
  2. package/dist/chunk-CB5RFNB4.js +33 -0
  3. package/dist/chunk-F4ENQXQQ.js +909 -0
  4. package/dist/chunk-I44FC7PE.js +142 -0
  5. package/dist/chunk-IK4ZANNW.js +95 -0
  6. package/dist/chunk-M44CET5L.js +40 -0
  7. package/dist/chunk-OSJFMQCW.js +44 -0
  8. package/dist/{chunk-R6AXTD55.js → chunk-VTJEWHM3.js} +19 -1
  9. package/dist/commands.d.ts +14 -0
  10. package/dist/commands.d.ts.map +1 -1
  11. package/dist/commands.js +1 -1
  12. package/dist/describe.d.ts +23 -0
  13. package/dist/describe.d.ts.map +1 -1
  14. package/dist/describe.js +6 -1
  15. package/dist/diff.d.ts +100 -0
  16. package/dist/diff.d.ts.map +1 -0
  17. package/dist/diff.js +18 -0
  18. package/dist/ink/App.d.ts +12 -0
  19. package/dist/ink/App.d.ts.map +1 -1
  20. package/dist/ink/Modal.d.ts.map +1 -1
  21. package/dist/ink/TodoBlock.d.ts +10 -0
  22. package/dist/ink/TodoBlock.d.ts.map +1 -0
  23. package/dist/ink/banner.d.ts +36 -0
  24. package/dist/ink/banner.d.ts.map +1 -0
  25. package/dist/ink/index.d.ts.map +1 -1
  26. package/dist/ink/index.js +300 -140
  27. package/dist/ink/items.d.ts.map +1 -1
  28. package/dist/ink/scroll.d.ts +14 -2
  29. package/dist/ink/scroll.d.ts.map +1 -1
  30. package/dist/ink/status.d.ts.map +1 -1
  31. package/dist/ink/theme.d.ts +9 -0
  32. package/dist/ink/theme.d.ts.map +1 -1
  33. package/dist/ink/todos.d.ts +20 -0
  34. package/dist/ink/todos.d.ts.map +1 -0
  35. package/dist/markdown.d.ts.map +1 -1
  36. package/dist/persist.d.ts +27 -4
  37. package/dist/persist.d.ts.map +1 -1
  38. package/dist/persist.js +3 -1
  39. package/dist/policySource.d.ts +26 -0
  40. package/dist/policySource.d.ts.map +1 -0
  41. package/dist/policySource.js +8 -0
  42. package/dist/session.d.ts +199 -13
  43. package/dist/session.d.ts.map +1 -1
  44. package/dist/session.js +10 -5
  45. package/dist/state.d.ts +84 -0
  46. package/dist/state.d.ts.map +1 -1
  47. package/dist/state.js +5 -3
  48. package/dist/writePreview.d.ts +58 -0
  49. package/dist/writePreview.d.ts.map +1 -0
  50. package/dist/writePreview.js +9 -0
  51. package/package.json +19 -6
  52. package/dist/chunk-47IH6X5H.js +0 -20
  53. package/dist/chunk-HAQGHLW6.js +0 -235
  54. package/dist/chunk-HYWLODZT.js +0 -1230
  55. package/dist/chunk-TCVDVYMF.js +0 -40
@@ -0,0 +1,909 @@
1
+ import {
2
+ emptyState,
3
+ parseTodos
4
+ } from "./chunk-OSJFMQCW.js";
5
+ import {
6
+ parseCommand
7
+ } from "./chunk-VTJEWHM3.js";
8
+ import {
9
+ WRITE_TOOL_NAMES,
10
+ buildWritePreview
11
+ } from "./chunk-M44CET5L.js";
12
+ import {
13
+ committedFromMessages,
14
+ titleFrom
15
+ } from "./chunk-IK4ZANNW.js";
16
+ import {
17
+ capPreview,
18
+ describeToolCall,
19
+ formatPolicy,
20
+ formatTools
21
+ } from "./chunk-2D7G4YW2.js";
22
+
23
+ // src/session.ts
24
+ import { runAgent } from "@corenel/harness/core/loop";
25
+
26
+ // src/markdown.ts
27
+ var FENCE = /^```(\w*)\s*$/;
28
+ var HEADING = /^(#{1,6})\s+(.*)$/;
29
+ var BULLET = /^\s*[-*]\s+(.*)$/;
30
+ var NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
31
+ function parseMarkdown(text) {
32
+ return parseMarkdownSpans(text).map((s) => s.block);
33
+ }
34
+ function parseMarkdownSpans(text) {
35
+ const out = [];
36
+ const lines = text.split(/\r?\n/);
37
+ let fence = null;
38
+ for (const line of lines) {
39
+ const f = FENCE.exec(line);
40
+ if (fence) {
41
+ if (f) {
42
+ fence.raw.push(line);
43
+ out.push({ block: { kind: "code", lang: fence.lang, text: fence.body.join("\n") }, raw: fence.raw.join("\n") });
44
+ fence = null;
45
+ } else {
46
+ fence.body.push(line);
47
+ fence.raw.push(line);
48
+ }
49
+ continue;
50
+ }
51
+ if (f) {
52
+ fence = { lang: f[1] ?? "", body: [], raw: [line] };
53
+ continue;
54
+ }
55
+ if (!line.trim()) {
56
+ const prev = out[out.length - 1];
57
+ if (prev && prev.block.kind === "blank") {
58
+ prev.raw += `
59
+ ${line}`;
60
+ continue;
61
+ }
62
+ out.push({ block: { kind: "blank", text: "" }, raw: line });
63
+ continue;
64
+ }
65
+ const h = HEADING.exec(line);
66
+ if (h) {
67
+ out.push({ block: { kind: "h", level: h[1].length, text: stripInline(h[2]) }, raw: line });
68
+ continue;
69
+ }
70
+ const li = BULLET.exec(line) ?? NUMBERED.exec(line);
71
+ if (li) {
72
+ out.push({ block: { kind: "li", text: stripInline(li[1]) }, raw: line });
73
+ continue;
74
+ }
75
+ out.push({ block: { kind: "p", text: stripInline(line) }, raw: line });
76
+ }
77
+ if (fence) out.push({ block: { kind: "code", lang: fence.lang, text: fence.body.join("\n") }, raw: fence.raw.join("\n") });
78
+ return out;
79
+ }
80
+ function stripInline(s) {
81
+ return s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/\*\*(\S(?:[^*]*\S)?)\*\*/g, "$1").replace(/(?<!\*)\*(\S(?:[^*]*\S)?)\*(?!\*)/g, "$1").replace(/`([^`]+)`/g, "$1");
82
+ }
83
+
84
+ // src/session.ts
85
+ var HELP_TEXT = [
86
+ "Type a request and press enter.",
87
+ "",
88
+ " /tools what the agent can do, and what it can change",
89
+ " /policy how this session is governed -- levels, caps, and what actually confines it",
90
+ " /expand #3 re-print a tool result in full (#3 is the id on its row)",
91
+ " /resume [id] reopen an earlier conversation",
92
+ " /clear forget the conversation",
93
+ " /exit leave",
94
+ "",
95
+ " /mode [id] switch the agent mode, or list what is configured",
96
+ " /persona [id] switch persona, or list what is configured",
97
+ " /skill [name] apply a skill to your NEXT message only, or list what is installed",
98
+ " /strategy [name] run a saved strategy from .prompd/strategies, or list what is saved",
99
+ "",
100
+ /* Deliberately not 'esc stops a turn': this text is shared, and Escape is
101
+ * an Ink-only key -- the readline fallback has no key handler at all, so
102
+ * there Ctrl-C is the only way to stop a turn. Name the one that is true
103
+ * on both faces, and mark the other as what it is. */
104
+ "Ctrl-C stops a turn; twice leaves. Ctrl-D leaves. In the full terminal face, esc also stops a turn."
105
+ ].join("\n");
106
+ var DELTA_FLUSH_MS = 50;
107
+ var TOOL_DELTA_CAP = 4e3;
108
+ var TermSession = class {
109
+ constructor(deps) {
110
+ this.deps = deps;
111
+ }
112
+ state = emptyState();
113
+ listeners = /* @__PURE__ */ new Set();
114
+ nextOrd = 1;
115
+ idSeq = 0;
116
+ pendingDelta = "";
117
+ flushTimer = null;
118
+ /* The full text streamed so far THIS ROUND (reset at beginTurn and again at
119
+ * every 'assistant' event, since a round of tool calls can be followed by
120
+ * another round of assistant text). Re-parsed into markdown blocks on every
121
+ * flush so all-but-the-last block can be committed as soon as it can no
122
+ * longer change -- see the module comment and flushDeltas(). */
123
+ streamFull = "";
124
+ /** How many leading blocks of `streamFull` have already been committed.
125
+ * Only the block at this index (the last one seen so far) is still live. */
126
+ streamCommittedBlocks = 0;
127
+ /** Whether this TURN has committed an assistant block yet. Reset only in
128
+ * `beginTurn` (never per round-trip within a turn, even though a turn with
129
+ * tool calls emits several 'assistant' events) so a whole turn's streamed
130
+ * reply -- however many rounds of tool use it took -- reads as ONE reply
131
+ * with ONE leading blank line, not one per round. See `commitAssistantBlock`. */
132
+ replyHasBlock = false;
133
+ /** Set when a `blank` markdown span was seen but not committed as its own
134
+ * item (see `commitCompletedBlocks`), and consumed by the next block that
135
+ * DOES commit -- see `commitAssistantBlock`. Reset in `beginTurn` too: a
136
+ * blank line at the very end of one round of streamed text must never leak
137
+ * a gap onto the first block of a LATER round (e.g. after a tool call). */
138
+ pendingGap = false;
139
+ /* An offload is emitted just BEFORE the tool-result it belongs to (see
140
+ * @corenel/harness core/loop.ts: recall.record() then emit('offload'), then
141
+ * emit('tool-result')), and it carries the BLOB id, not the call id. So it is
142
+ * held here and claimed by the next result rather than looked up by id. */
143
+ pendingOffload = null;
144
+ /** The model's truth, as opposed to `committed`, which is display. */
145
+ history = [];
146
+ turnAc = null;
147
+ /** Monotonic turn id. Events stamped with an older one are dropped: a late
148
+ * delta from an aborted turn must never paint into the next one. */
149
+ turnId = 0;
150
+ activeTurn = 0;
151
+ pending = null;
152
+ draining = false;
153
+ /* Bumped whenever the conversation is REPLACED out from under a turn --
154
+ * `/clear` and `resume()`. A turn captures it when it starts and only adopts
155
+ * the messages it accumulated if the stamp still matches, because runAgent
156
+ * copies `messages` at turn start: without this, a `/clear` typed mid-turn
157
+ * was undone the moment the turn landed (and then written to disk), so the
158
+ * screen said forgotten while the next request still carried everything. */
159
+ historyGen = 0;
160
+ /* True from the moment a turn starts until the queue behind it has been
161
+ * drained. `status` flips to 'idle' inside runAgent's 'done' event, but
162
+ * runTurn then awaits persist() -- a DISK WRITE -- before it reaches drain().
163
+ * A line submitted in that window used to find an idle controller with a
164
+ * non-empty queue and start a turn immediately: the queue lost its order,
165
+ * and two turns could overlap, leaving a user message with no reply. */
166
+ settling = false;
167
+ /** The approval gate. Supplied by whichever face can show a prompt (readline's
168
+ * `ask`, the Ink modal, ...) — the controller has no rendering of its own, so
169
+ * it cannot build one itself. Unattached, `runTurn` simply omits `permission`
170
+ * from the runner args, and the loop runs every tool ungated. */
171
+ permission;
172
+ /** Armed by `/skill <name>` (see runCommand); consumed and cleared by the
173
+ * NEXT runTurn, never a later one. `state.pendingSkill` mirrors just the
174
+ * label, for a face's indicator -- the instructions text never needs to
175
+ * reach a renderer. */
176
+ pendingSkill = null;
177
+ wantsExit = false;
178
+ attachPermission(p) {
179
+ this.permission = p;
180
+ }
181
+ historyForTest() {
182
+ return this.history;
183
+ }
184
+ getSnapshot() {
185
+ return this.state;
186
+ }
187
+ subscribe(fn) {
188
+ this.listeners.add(fn);
189
+ return () => {
190
+ this.listeners.delete(fn);
191
+ };
192
+ }
193
+ /** Replace state and notify. Every mutation goes through here so `seq` and the
194
+ * snapshot identity can never drift from the data. */
195
+ set(patch) {
196
+ this.state = { ...this.state, ...patch, seq: this.state.seq + 1 };
197
+ for (const fn of this.listeners) fn();
198
+ }
199
+ id(prefix) {
200
+ return `${prefix}${++this.idSeq}`;
201
+ }
202
+ commit(item) {
203
+ this.set({ committed: [...this.state.committed, item] });
204
+ }
205
+ /** Commit one assistant block, marking it `leadingBlank` iff no block has
206
+ * been committed yet this turn OR a `blank` span was seen (and swallowed --
207
+ * see `commitCompletedBlocks`) since the last one that was. This is the
208
+ * ONLY place that commits an 'assistant' item -- commitCompletedBlocks, the
209
+ * manual commits in the 'assistant' event case, and endTurn's
210
+ * trailing-block commit all route through here, so there is one rule for
211
+ * which block gets the leading margin rather than four call sites that
212
+ * merely happen to agree today. */
213
+ commitAssistantBlock(text) {
214
+ const leadingBlank = !this.replyHasBlock || this.pendingGap;
215
+ this.replyHasBlock = true;
216
+ this.pendingGap = false;
217
+ this.commit({ kind: "assistant", id: this.id("a"), text, ...leadingBlank ? { leadingBlank: true } : {} });
218
+ }
219
+ /** The system prompt for the turn about to run -- called fresh every time
220
+ * (see `runTurn`), never cached, so a mode/persona switch (which mutates
221
+ * state the HOST owns, then calls `deps.system` again on the next turn)
222
+ * actually changes what the model sees instead of only looking like it did.
223
+ * A plain string `deps.system` degrades to exactly today's behaviour. */
224
+ async resolveSystem() {
225
+ const s = this.deps.system;
226
+ if (s === void 0) return void 0;
227
+ return typeof s === "function" ? await s() : s;
228
+ }
229
+ note(level, text, detail) {
230
+ this.commit({ kind: "note", id: this.id("n"), level, text, ...detail ? { detail } : {} });
231
+ }
232
+ /** Open a live turn. Public-for-test until Task 4 gives it a real caller. */
233
+ beginTurnForTest() {
234
+ this.beginTurn();
235
+ }
236
+ flushDeltasForTest() {
237
+ this.flushDeltas();
238
+ }
239
+ beginTurn() {
240
+ this.streamFull = "";
241
+ this.streamCommittedBlocks = 0;
242
+ this.replyHasBlock = false;
243
+ this.pendingGap = false;
244
+ this.set({
245
+ live: { streaming: "", tools: [], turn: (this.state.live?.turn ?? 0) + 1 },
246
+ status: "running",
247
+ error: null,
248
+ turnUsage: { promptTokens: 0, completionTokens: 0 }
249
+ });
250
+ }
251
+ /** Commit every markdown block in `streamFull` that can no longer change --
252
+ * i.e. every block except the last, which more text can still extend --
253
+ * and return the still-live trailing block's raw text. This is what keeps
254
+ * the live region Ink redraws every frame bounded by one block instead of
255
+ * the whole reply: see the module comment for why an unbounded live region
256
+ * corrupts the frame once it scrolls out of the viewport.
257
+ *
258
+ * KNOWN LIMIT: any single block taller than the terminal -- not just a
259
+ * fenced code block, but equally one long unbroken paragraph, or any other
260
+ * span with no internal blank-line break -- stays "the trailing block", and
261
+ * so stays live and unbounded, until it closes (or the blank line that ends
262
+ * it arrives). That bounds the live region by one block rather than by the
263
+ * viewport, which the headless controller has no way to know. It is still
264
+ * a large improvement over the whole reply staying live, and a single block
265
+ * taller than the terminal is rare. */
266
+ commitCompletedBlocks() {
267
+ const spans = parseMarkdownSpans(this.streamFull);
268
+ if (spans.length === 0) return "";
269
+ while (this.streamCommittedBlocks < spans.length - 1) {
270
+ const span = spans[this.streamCommittedBlocks];
271
+ if (span.block.kind === "blank") this.pendingGap = true;
272
+ else this.commitAssistantBlock(span.raw);
273
+ this.streamCommittedBlocks++;
274
+ }
275
+ return spans[spans.length - 1].raw;
276
+ }
277
+ flushDeltas() {
278
+ if (this.flushTimer) {
279
+ clearTimeout(this.flushTimer);
280
+ this.flushTimer = null;
281
+ }
282
+ if (!this.pendingDelta) return;
283
+ const chunk = this.pendingDelta;
284
+ this.pendingDelta = "";
285
+ const live = this.state.live;
286
+ if (!live) return;
287
+ this.streamFull += chunk;
288
+ const trailing = this.commitCompletedBlocks();
289
+ this.set({ live: { ...this.state.live, streaming: trailing } });
290
+ }
291
+ scheduleFlush() {
292
+ if (this.flushTimer) return;
293
+ this.flushTimer = setTimeout(() => {
294
+ this.flushTimer = null;
295
+ this.flushDeltas();
296
+ }, DELTA_FLUSH_MS);
297
+ this.flushTimer.unref?.();
298
+ }
299
+ /** Commit whatever the live region still holds -- the trailing block that
300
+ * flushDeltas kept live, since every block before it is already committed
301
+ * -- and close the turn. */
302
+ endTurn() {
303
+ this.flushDeltas();
304
+ const live = this.state.live;
305
+ if (live?.streaming.trim()) {
306
+ this.commitAssistantBlock(live.streaming);
307
+ }
308
+ const stopped = this.state.status === "aborting";
309
+ this.set({ live: null, status: "idle" });
310
+ this.streamFull = "";
311
+ this.streamCommittedBlocks = 0;
312
+ if (stopped) this.note("info", "(stopped)");
313
+ }
314
+ /** The ordinal a 'tool-result' with this callId will commit with: the id
315
+ * already recorded against it in `live.tools` if a 'tool-call' announced
316
+ * it, or the next fresh one otherwise -- the second path is how a bare
317
+ * result with no matching call (loop.ts emits one straight away when the
318
+ * model's JSON arguments don't parse) still gets a real ordinal.
319
+ *
320
+ * PEEKS `nextOrd`, never advances it. This is shared, read-only, by BOTH
321
+ * `ordFor` (the tap's preview) and the 'tool-result' reducer case below
322
+ * (which alone decides whether to advance `nextOrd`, immediately after
323
+ * calling this) precisely so there is one rule for "what ordinal does this
324
+ * result get", expressed once, rather than two conditions that merely
325
+ * happen to agree today. A same-shaped bare-result WITHOUT this shared
326
+ * helper is exactly the case that let the tap and the reducer drift before
327
+ * -- ordFor returned undefined while the reducer minted a real ordinal. */
328
+ resultOrd(callId) {
329
+ return this.state.live?.tools.find((t) => t.callId === callId)?.ord ?? this.nextOrd;
330
+ }
331
+ /** The ordinal a face's raw event tap should show for `e`, read from the
332
+ * SAME state the reducer below is about to use -- never a second counter.
333
+ * 'tool-call' previews the id the reducer is about to hand out (`nextOrd`
334
+ * itself, not a copy of it); 'tool-result' previews `resultOrd` above,
335
+ * covering the announced AND the bare-result case alike. Anything else has
336
+ * no ordinal to show. */
337
+ ordFor(e) {
338
+ if (e.type === "tool-call") return this.nextOrd;
339
+ if (e.type === "tool-result") return this.resultOrd(e.id);
340
+ return void 0;
341
+ }
342
+ handleEvent(e) {
343
+ this.deps.onEvent?.(e, this.ordFor(e));
344
+ switch (e.type) {
345
+ case "turn": {
346
+ const live = this.state.live;
347
+ if (live) this.set({ live: { ...live, turn: e.turn } });
348
+ break;
349
+ }
350
+ case "assistant-delta":
351
+ this.pendingDelta += e.delta;
352
+ this.scheduleFlush();
353
+ break;
354
+ case "assistant": {
355
+ this.flushDeltas();
356
+ const live = this.state.live;
357
+ const trailing = live?.streaming ?? "";
358
+ if (trailing.trim()) {
359
+ this.commitAssistantBlock(trailing);
360
+ } else if (this.streamCommittedBlocks === 0 && e.content.trim()) {
361
+ this.commitAssistantBlock(e.content);
362
+ }
363
+ if (live) this.set({ live: { ...this.state.live, streaming: "" } });
364
+ this.streamFull = "";
365
+ this.streamCommittedBlocks = 0;
366
+ this.pendingGap = false;
367
+ break;
368
+ }
369
+ case "tool-call": {
370
+ this.flushDeltas();
371
+ if (e.name === "todo_write") {
372
+ this.set({ todos: parseTodos(e.args) });
373
+ }
374
+ const live = this.state.live;
375
+ if (!live) break;
376
+ const tool = {
377
+ callId: e.id,
378
+ ord: this.nextOrd++,
379
+ name: e.name,
380
+ subject: describeToolCall(e.name, e.args)
381
+ };
382
+ this.set({ live: { ...live, tools: [...live.tools, tool] } });
383
+ break;
384
+ }
385
+ case "tool-delta": {
386
+ const live = this.state.live;
387
+ if (!live) break;
388
+ const idx = live.tools.findIndex((t) => t.callId === e.id);
389
+ if (idx < 0) break;
390
+ const prior = live.tools[idx];
391
+ const combined = (prior.delta ?? "") + e.delta;
392
+ const capped = combined.length > TOOL_DELTA_CAP ? combined.slice(-TOOL_DELTA_CAP) : combined;
393
+ const tools = [...live.tools];
394
+ tools[idx] = { ...prior, delta: capped };
395
+ this.set({ live: { ...live, tools } });
396
+ break;
397
+ }
398
+ case "tool-result": {
399
+ this.flushDeltas();
400
+ const live = this.state.live;
401
+ const pending = live?.tools.find((t) => t.callId === e.id);
402
+ const ord = this.resultOrd(e.id);
403
+ if (!pending) this.nextOrd++;
404
+ const isError = e.isError === true;
405
+ const offload = this.pendingOffload;
406
+ this.pendingOffload = null;
407
+ const item = {
408
+ kind: "tool",
409
+ id: this.id("t"),
410
+ callId: e.id,
411
+ ord,
412
+ name: e.name,
413
+ subject: pending?.subject ?? "",
414
+ preview: capPreview(e.result, isError),
415
+ isError,
416
+ ...e.durationMs !== void 0 ? { durationMs: e.durationMs } : {},
417
+ ...offload ? { offloadedBytes: offload.bytes, offloadRefId: offload.refId } : {}
418
+ };
419
+ if (live) {
420
+ this.state = {
421
+ ...this.state,
422
+ live: { ...live, tools: live.tools.filter((t) => t.callId !== e.id) }
423
+ };
424
+ }
425
+ this.commit(item);
426
+ break;
427
+ }
428
+ case "offload":
429
+ this.pendingOffload = { refId: e.id, bytes: e.bytes };
430
+ break;
431
+ case "permission":
432
+ case "permission-audit":
433
+ break;
434
+ case "policy-violation":
435
+ this.note(
436
+ e.action === "stop" ? "error" : "warn",
437
+ e.action === "stop" ? `blocked ${e.message}` : `warning ${e.message}`,
438
+ e.tool
439
+ );
440
+ break;
441
+ case "heal":
442
+ if (e.record.outcome === "abandoned" || e.record.outcome === "exhausted") {
443
+ this.note("warn", `recovery gave up ${e.record.error ?? e.record.strategy}`);
444
+ }
445
+ break;
446
+ case "usage":
447
+ this.set({
448
+ usage: {
449
+ promptTokens: this.state.usage.promptTokens + e.usage.promptTokens,
450
+ completionTokens: this.state.usage.completionTokens + e.usage.completionTokens
451
+ },
452
+ turnUsage: {
453
+ promptTokens: this.state.turnUsage.promptTokens + e.usage.promptTokens,
454
+ completionTokens: this.state.turnUsage.completionTokens + e.usage.completionTokens
455
+ }
456
+ });
457
+ break;
458
+ case "artifact":
459
+ this.note("info", `artifact ${e.kind}${e.title ? ` ${e.title}` : ""}`);
460
+ break;
461
+ case "budget-stop":
462
+ case "budget-warn":
463
+ this.note("warn", `budget ${e.type === "budget-stop" ? "stopped" : "over"}`);
464
+ break;
465
+ case "error":
466
+ this.flushDeltas();
467
+ this.note("error", e.message);
468
+ this.set({ error: e.message });
469
+ break;
470
+ case "done":
471
+ this.endTurn();
472
+ break;
473
+ default:
474
+ break;
475
+ }
476
+ }
477
+ /** Route one line: a command runs here; anything else is a turn. */
478
+ async submit(text) {
479
+ const line = text.trim();
480
+ if (!line) return;
481
+ const cmd = parseCommand(line);
482
+ if (cmd) {
483
+ await this.runCommand(cmd);
484
+ return;
485
+ }
486
+ await this.queueOrRun(line);
487
+ }
488
+ /** Run `line` as a turn now, or queue it, exactly like `submit` does for
489
+ * ordinary text -- factored out so `/strategy` (runCommand) can send its
490
+ * resolved prompt through the SAME busy/queue check a typed line gets,
491
+ * rather than calling `runTurn` directly and risking two turns in flight. */
492
+ async queueOrRun(line) {
493
+ if (this.state.status !== "idle" || this.settling) {
494
+ this.set({ queued: [...this.state.queued, line] });
495
+ return;
496
+ }
497
+ await this.runTurn(line);
498
+ }
499
+ async runCommand(cmd) {
500
+ switch (cmd.cmd) {
501
+ case "exit":
502
+ this.wantsExit = true;
503
+ return;
504
+ case "clear": {
505
+ const droppedSkill = this.pendingSkill;
506
+ this.pendingSkill = null;
507
+ this.history = [];
508
+ this.historyGen++;
509
+ this.set({
510
+ committed: [],
511
+ usage: { promptTokens: 0, completionTokens: 0 },
512
+ turnUsage: { promptTokens: 0, completionTokens: 0 },
513
+ todos: [],
514
+ pendingSkill: null
515
+ });
516
+ if (droppedSkill) this.note("info", `cleared -- the armed skill "${droppedSkill.label}" was dropped too`);
517
+ return;
518
+ }
519
+ case "tools":
520
+ this.note("info", formatTools(this.deps.tools));
521
+ return;
522
+ case "policy":
523
+ if (!this.deps.policy || !this.deps.policySource) {
524
+ this.note("warn", "/policy is not available in this session");
525
+ return;
526
+ }
527
+ this.note("info", formatPolicy(this.deps.policy, this.deps.policySource, this.deps.tools));
528
+ return;
529
+ case "help":
530
+ this.note("info", HELP_TEXT);
531
+ return;
532
+ case "expand": {
533
+ if (cmd.ord === void 0) {
534
+ this.note("warn", "usage: /expand #3");
535
+ return;
536
+ }
537
+ await this.expand(cmd.ord);
538
+ return;
539
+ }
540
+ case "resume":
541
+ await this.resume(cmd.id);
542
+ return;
543
+ case "mode":
544
+ await this.switchIdentity({
545
+ kind: "mode",
546
+ id: cmd.id,
547
+ list: this.deps.listModes,
548
+ apply: this.deps.setMode
549
+ });
550
+ return;
551
+ case "persona":
552
+ await this.switchIdentity({
553
+ kind: "persona",
554
+ id: cmd.id,
555
+ list: this.deps.listPersonas,
556
+ apply: this.deps.setPersona
557
+ });
558
+ return;
559
+ case "skill":
560
+ await this.armSkillCommand(cmd.id);
561
+ return;
562
+ case "strategy":
563
+ await this.runStrategyCommand(cmd.id);
564
+ return;
565
+ }
566
+ }
567
+ /** Runs a host-supplied config-workspace call (`listModes`, `listSkills`,
568
+ * `armSkill`, ...) and turns a THROW into a `warn` note instead of letting
569
+ * it escape the command handler and take the turn down with it.
570
+ *
571
+ * `listSkills()` (packages/harness/skills.ts) already wraps its own
572
+ * `seedConfig()`/read calls this way and never throws; `loadModes`/
573
+ * `loadPersonas` (packages/harness/prompts/config.ts) call `seedConfig()`
574
+ * UNGUARDED, so a genuine config-workspace failure (permissions, a
575
+ * read-only home, disk full -- masked on a normal install because
576
+ * `NodeFileService.write()` mkdirs recursively, but real on a broken one)
577
+ * threw straight out of `/mode`/`/persona` and crashed the turn instead of
578
+ * reporting "not available" the way `/skill` already does. Wrapping every
579
+ * host call here, not just the two that are known to need it, means the
580
+ * NEXT one added can't reintroduce the same gap by omission. */
581
+ async safeCall(kind, fn) {
582
+ try {
583
+ return { ok: true, value: await fn() };
584
+ } catch (e) {
585
+ this.note("warn", `/${kind} is not available -- could not read the config workspace (${e instanceof Error ? e.message : String(e)})`);
586
+ return { ok: false };
587
+ }
588
+ }
589
+ /** Shared `/mode` and `/persona` shape: no id lists what's configured, an
590
+ * unknown id reports the valid options (never silently no-ops, never sends
591
+ * a typo through to the model), a real id applies immediately -- "applies"
592
+ * meaning `deps.system` will pick it up on the NEXT `resolveSystem()` call,
593
+ * not this instant (see the field docs on TermSessionDeps.system). */
594
+ async switchIdentity(opts) {
595
+ if (!opts.list || !opts.apply) {
596
+ this.note("warn", `/${opts.kind} is not available in this session`);
597
+ return;
598
+ }
599
+ const listed = await this.safeCall(opts.kind, opts.list);
600
+ if (!listed.ok) return;
601
+ const entries = listed.value;
602
+ const names = entries.map((e) => e.id).join(", ") || "none configured";
603
+ if (!opts.id) {
604
+ this.note("info", `${opts.kind}s: ${names}`);
605
+ return;
606
+ }
607
+ const found = entries.find((e) => e.id === opts.id);
608
+ if (!found) {
609
+ this.note("warn", `no ${opts.kind} "${opts.id}". available: ${names}`);
610
+ return;
611
+ }
612
+ opts.apply(found.id);
613
+ this.note("info", `${opts.kind} set to ${found.label} -- takes effect on your next message`);
614
+ }
615
+ async armSkillCommand(id) {
616
+ if (!this.deps.listSkills || !this.deps.armSkill) {
617
+ this.note("warn", "/skill is not available in this session");
618
+ return;
619
+ }
620
+ const listed = await this.safeCall("skill", this.deps.listSkills);
621
+ if (!listed.ok) return;
622
+ const entries = listed.value;
623
+ const names = entries.map((e) => e.id).join(", ") || "none installed";
624
+ if (!id) {
625
+ this.note("info", `skills: ${names}`);
626
+ return;
627
+ }
628
+ const found = entries.find((e) => e.id === id);
629
+ if (!found) {
630
+ this.note("warn", `no skill "${id}". available: ${names}`);
631
+ return;
632
+ }
633
+ const armSkill = this.deps.armSkill;
634
+ const loaded = await this.safeCall("skill", () => armSkill(found.id));
635
+ if (!loaded.ok) return;
636
+ const armed = loaded.value;
637
+ if (!armed) {
638
+ this.note("warn", `could not load skill "${found.id}"`);
639
+ return;
640
+ }
641
+ this.pendingSkill = armed;
642
+ this.set({ pendingSkill: armed.label });
643
+ this.note("info", `skill "${armed.label}" armed -- applies to your next message, then clears`);
644
+ }
645
+ async runStrategyCommand(id) {
646
+ if (!this.deps.listStrategies || !this.deps.resolveStrategy) {
647
+ this.note("warn", "/strategy is not available in this session");
648
+ return;
649
+ }
650
+ const listed = await this.safeCall("strategy", this.deps.listStrategies);
651
+ if (!listed.ok) return;
652
+ const entries = listed.value;
653
+ const names = entries.map((e) => e.id).join(", ");
654
+ if (!id) {
655
+ this.note("info", entries.length ? `strategies: ${names}` : "no saved strategies in this workspace");
656
+ return;
657
+ }
658
+ const found = entries.find((e) => e.id === id);
659
+ if (!found) {
660
+ this.note("warn", entries.length ? `no strategy "${id}". available: ${names}` : `no strategy "${id}" -- no saved strategies in this workspace`);
661
+ return;
662
+ }
663
+ const resolveStrategy = this.deps.resolveStrategy;
664
+ const resolved = await this.safeCall("strategy", () => resolveStrategy(found.id));
665
+ if (!resolved.ok) return;
666
+ if (!resolved.value) {
667
+ this.note("warn", `could not load strategy "${found.id}"`);
668
+ return;
669
+ }
670
+ this.note("info", `running strategy "${resolved.value.name}"`);
671
+ await this.queueOrRun(resolved.value.prompt);
672
+ }
673
+ /** Save after the turn, never during: a half-written turn is a lie on disk. */
674
+ async persist() {
675
+ const p = this.deps.persistence;
676
+ if (!p) return;
677
+ try {
678
+ const title = this.state.session?.title ?? titleFrom(this.history);
679
+ const { id } = await p.save({ id: this.state.session?.id ?? null, title, messages: this.history });
680
+ this.set({ session: { id, title } });
681
+ this.deps.onSessionId?.(id);
682
+ } catch (e) {
683
+ this.note("warn", `could not save this conversation: ${e instanceof Error ? e.message : String(e)}`);
684
+ }
685
+ }
686
+ /** Reopen a conversation: `id` if given, otherwise the most recent. */
687
+ async resume(id) {
688
+ const p = this.deps.persistence;
689
+ if (!p) {
690
+ this.note("warn", "this session is not being saved, so there is nothing to resume");
691
+ return;
692
+ }
693
+ const loaded = await p.load(id);
694
+ if (!loaded) {
695
+ this.note("warn", id ? `no conversation ${id}` : "no earlier conversation to resume");
696
+ return;
697
+ }
698
+ const droppedSkill = this.pendingSkill;
699
+ this.pendingSkill = null;
700
+ this.history = loaded.messages;
701
+ this.historyGen++;
702
+ this.nextOrd = 1;
703
+ const committed = committedFromMessages(loaded.messages).map(
704
+ (item) => item.kind === "tool" ? { ...item, ord: this.nextOrd++ } : item
705
+ );
706
+ this.set({
707
+ committed,
708
+ // Defensive, not reachable today (a command can't run while `live` is
709
+ // set -- see `submit`'s `status !== 'idle'` guard): `nextOrd` is reset
710
+ // above on the assumption no turn is mid-flight handing out ordinals
711
+ // of its own right now. If that assumption is ever wrong, a live tool
712
+ // row from BEFORE the resume would still be pointing at an ordinal
713
+ // this reset just reused for a restored row -- the very drift this
714
+ // whole ordinal design exists to rule out. Clearing it here keeps the
715
+ // assumption true by construction instead of by the caller's care.
716
+ live: null,
717
+ transcriptGen: this.state.transcriptGen + 1,
718
+ session: { id: loaded.id, title: loaded.title },
719
+ pendingSkill: null
720
+ });
721
+ this.deps.onSessionId?.(loaded.id);
722
+ if (droppedSkill) this.note("info", `resumed -- the armed skill "${droppedSkill.label}" was dropped too`);
723
+ }
724
+ /** Re-print a committed tool row in full, as a NEW block at the bottom.
725
+ * Inline rendering owns this trade: the original row's bytes belong to the
726
+ * terminal's scrollback and cannot be rewritten in place. */
727
+ async expand(ord) {
728
+ const row = this.state.committed.find((i) => i.kind === "tool" && i.ord === ord);
729
+ if (!row || row.kind !== "tool") {
730
+ this.note("warn", `no tool result #${ord} in this conversation`);
731
+ return;
732
+ }
733
+ const full = await this.fullResult(row.id, row);
734
+ this.note("info", `#${row.ord} ${row.name} ${row.subject}`, full);
735
+ }
736
+ /** The full body of a committed row. `committed` only ever keeps the capped
737
+ * `preview`, and a second copy would be redundant AND wrong: `history`
738
+ * already carries the exact string the model saw for this call (the full
739
+ * body when it wasn't offloaded, the short offload ref when it was — see
740
+ * @corenel/harness core/loop.ts's `messages.push({ role: 'tool', ... })`),
741
+ * and `history` is cleared by `/clear`, so a stale row can't outlive the
742
+ * conversation it belongs to the way a side map would. Branch (a) below reads
743
+ * an offloaded body back out of the blob store by `row.offloadRefId` — live
744
+ * now that the CLI passes `deps.recall` and `deps.compression` (see
745
+ * packages/cli/src/cli.ts). */
746
+ async fullResult(_id, row) {
747
+ if (row.offloadedBytes !== void 0 && this.deps.recall) {
748
+ const full = await this.deps.recall(row.offloadRefId ?? row.id);
749
+ if (full) return full;
750
+ }
751
+ const msg = this.history.find(
752
+ (m) => m.role === "tool" && m.tool_call_id === row.callId
753
+ );
754
+ if (msg && typeof msg.content === "string") return msg.content;
755
+ return row.preview.join("\n");
756
+ }
757
+ async runTurn(line) {
758
+ this.settling = true;
759
+ this.commit({ kind: "user", id: this.id("u"), text: line });
760
+ const skill = this.pendingSkill;
761
+ this.pendingSkill = null;
762
+ this.set({ pendingSkill: null });
763
+ const content = skill ? `[Skill: ${skill.label}]
764
+ ${skill.instructions}
765
+
766
+ ---
767
+
768
+ ${line}` : line;
769
+ this.history.push({ role: "user", content });
770
+ const gen = this.historyGen;
771
+ const ac = new AbortController();
772
+ this.turnAc = ac;
773
+ const mine = ++this.turnId;
774
+ this.activeTurn = mine;
775
+ this.beginTurn();
776
+ const system = await this.resolveSystem();
777
+ const run = this.deps.runner ?? runAgent;
778
+ try {
779
+ const result = await run({
780
+ client: this.deps.client,
781
+ model: this.deps.model,
782
+ ...system ? { system } : {},
783
+ messages: this.history,
784
+ tools: this.deps.tools,
785
+ toolCtx: this.deps.toolCtx(ac.signal, (q) => this.askUser(q), this.permission),
786
+ signal: ac.signal,
787
+ ...this.permission ? { permission: this.permission } : {},
788
+ ...this.deps.compression ? { compression: this.deps.compression } : {},
789
+ onEvent: (e) => {
790
+ if (this.activeTurn === mine) this.handleEvent(e);
791
+ }
792
+ });
793
+ if (this.activeTurn !== mine) return;
794
+ if (ac.signal.aborted || result.stopReason === "aborted") {
795
+ await this.finishAbandoned(gen);
796
+ return;
797
+ }
798
+ if (this.historyGen === gen) this.history = result.messages;
799
+ this.endTurn();
800
+ await this.persist();
801
+ if (this.activeTurn === mine) this.activeTurn = 0;
802
+ } catch (e) {
803
+ if (this.activeTurn === mine) {
804
+ if (!ac.signal.aborted) this.note("error", e instanceof Error ? e.message : String(e));
805
+ await this.finishAbandoned(gen);
806
+ }
807
+ } finally {
808
+ if (this.turnAc === ac) this.turnAc = null;
809
+ this.forceClosePending();
810
+ await this.drain();
811
+ if (!this.draining) this.settling = false;
812
+ }
813
+ }
814
+ /** Close out a turn that produced nothing usable -- aborted, or failed.
815
+ * Drops the abandoned user message: a user message with no reply, or a tool
816
+ * call with no result, poisons the next request. Exactly once, and never when
817
+ * the conversation was replaced under it (`/clear`, `/resume`), where the
818
+ * message it would drop belongs to someone else's history. */
819
+ async finishAbandoned(gen) {
820
+ if (this.historyGen === gen) this.history = this.history.slice(0, -1);
821
+ this.endTurn();
822
+ await this.persist();
823
+ this.activeTurn = 0;
824
+ }
825
+ async drain() {
826
+ if (this.draining) return;
827
+ this.draining = true;
828
+ try {
829
+ for (; ; ) {
830
+ const [next, ...rest] = this.state.queued;
831
+ if (next === void 0) break;
832
+ this.set({ queued: rest });
833
+ await this.runTurn(next);
834
+ }
835
+ } finally {
836
+ this.draining = false;
837
+ }
838
+ }
839
+ /** Stop the TURN, never the process. A long tool call you regret is the common
840
+ * case, and killing the shell to escape it loses the conversation. */
841
+ abort() {
842
+ if (this.state.status === "idle" && !this.pending) return;
843
+ this.set({ status: "aborting" });
844
+ this.forceClosePending();
845
+ this.turnAc?.abort();
846
+ }
847
+ /** DO NOT mark this method `async` -- that is a deliberate, load-bearing
848
+ * choice, not an oversight to "clean up" later. An `async` function turns a
849
+ * synchronous `throw` into a REJECTED PROMISE rather than an exception the
850
+ * caller's own `try`/`catch` sees, which would silently break the "a
851
+ * question is already open" guard immediately below (session.turn.test.ts's
852
+ * "refuses a second concurrent ask rather than queueing it quietly" pins
853
+ * this: it calls `askPermission` twice back-to-back and asserts the SECOND
854
+ * call throws synchronously). The diff read only ever happens for a write
855
+ * tool with a `path` AND a host that wired `previewWrite` -- reached via the
856
+ * `.then(openModal)` chain below, not `await` -- so every other call stays
857
+ * fully synchronous, exactly as before this method could show a diff at
858
+ * all. */
859
+ askPermission(req) {
860
+ if (this.deps.approveAll) return Promise.resolve("allow");
861
+ if (this.pending) throw new Error("a question is already open");
862
+ const detail = req.input;
863
+ const shown = typeof detail?.command === "string" ? detail.command : typeof detail?.path === "string" ? detail.path : "";
864
+ const openModal = (diff) => {
865
+ if (this.state.status === "aborting") return "deny";
866
+ this.set({ modal: { kind: "permission", tool: req.tool, detail: shown, ...diff ? { diff } : {} } });
867
+ return new Promise((resolve) => {
868
+ this.pending = { resolve: (v) => resolve(v ?? "deny") };
869
+ });
870
+ };
871
+ if (!this.deps.previewWrite || !WRITE_TOOL_NAMES.has(req.tool)) return Promise.resolve(openModal(null));
872
+ return buildWritePreview(req.tool, req.input, this.deps.previewWrite).then(openModal);
873
+ }
874
+ askUser(questions) {
875
+ if (this.pending) throw new Error("a question is already open");
876
+ this.set({ modal: { kind: "user", questions } });
877
+ return new Promise((resolve) => {
878
+ this.pending = { resolve: (v) => resolve(v ?? null) };
879
+ });
880
+ }
881
+ /** The view's answer to whichever modal is open. */
882
+ answer(value) {
883
+ this.resolvePending(value);
884
+ }
885
+ /** Force-close whatever modal is open, on turn teardown or `abort()`. The
886
+ * sentinel must match the modal's OWN contract, not a shared default:
887
+ * `askPermission` promises `PermissionDecision`, where `'deny'` is a real
888
+ * value; `askUser` promises `AskAnswers | null`, and `'deny'` there would be
889
+ * a bogus truthy string standing in for the documented "dismissed" (`null`)
890
+ * outcome that `askUserTool` (packages/harness/tools/builtins.ts) checks for. */
891
+ forceClosePending() {
892
+ if (!this.pending) return;
893
+ this.resolvePending(this.state.modal?.kind === "user" ? null : "deny");
894
+ }
895
+ resolvePending(value) {
896
+ const p = this.pending;
897
+ if (!p) return;
898
+ this.pending = null;
899
+ this.set({ modal: null });
900
+ p.resolve(value);
901
+ }
902
+ };
903
+
904
+ export {
905
+ parseMarkdown,
906
+ DELTA_FLUSH_MS,
907
+ TOOL_DELTA_CAP,
908
+ TermSession
909
+ };