@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.
- package/dist/chunk-2D7G4YW2.js +97 -0
- package/dist/chunk-CB5RFNB4.js +33 -0
- package/dist/chunk-F4ENQXQQ.js +909 -0
- package/dist/chunk-I44FC7PE.js +142 -0
- package/dist/chunk-IK4ZANNW.js +95 -0
- package/dist/chunk-M44CET5L.js +40 -0
- package/dist/chunk-OSJFMQCW.js +44 -0
- package/dist/{chunk-R6AXTD55.js → chunk-VTJEWHM3.js} +19 -1
- package/dist/commands.d.ts +14 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +1 -1
- package/dist/describe.d.ts +23 -0
- package/dist/describe.d.ts.map +1 -1
- package/dist/describe.js +6 -1
- package/dist/diff.d.ts +100 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +18 -0
- package/dist/ink/App.d.ts +12 -0
- package/dist/ink/App.d.ts.map +1 -1
- package/dist/ink/Modal.d.ts.map +1 -1
- package/dist/ink/TodoBlock.d.ts +10 -0
- package/dist/ink/TodoBlock.d.ts.map +1 -0
- package/dist/ink/banner.d.ts +36 -0
- package/dist/ink/banner.d.ts.map +1 -0
- package/dist/ink/index.d.ts.map +1 -1
- package/dist/ink/index.js +300 -140
- package/dist/ink/items.d.ts.map +1 -1
- package/dist/ink/scroll.d.ts +14 -2
- package/dist/ink/scroll.d.ts.map +1 -1
- package/dist/ink/status.d.ts.map +1 -1
- package/dist/ink/theme.d.ts +9 -0
- package/dist/ink/theme.d.ts.map +1 -1
- package/dist/ink/todos.d.ts +20 -0
- package/dist/ink/todos.d.ts.map +1 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/persist.d.ts +27 -4
- package/dist/persist.d.ts.map +1 -1
- package/dist/persist.js +3 -1
- package/dist/policySource.d.ts +26 -0
- package/dist/policySource.d.ts.map +1 -0
- package/dist/policySource.js +8 -0
- package/dist/session.d.ts +199 -13
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +10 -5
- package/dist/state.d.ts +84 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +5 -3
- package/dist/writePreview.d.ts +58 -0
- package/dist/writePreview.d.ts.map +1 -0
- package/dist/writePreview.js +9 -0
- package/package.json +19 -6
- package/dist/chunk-47IH6X5H.js +0 -20
- package/dist/chunk-HAQGHLW6.js +0 -235
- package/dist/chunk-HYWLODZT.js +0 -1230
- 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
|
+
};
|