teapot-coding-agent 0.8.0 → 0.10.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/README.md CHANGED
@@ -108,12 +108,17 @@ master (Hono server, src/master.ts + src/server/api.ts)
108
108
  - **LLM** (`src/agent/llm.ts`): official `openai` npm client against any
109
109
  OpenAI-compatible endpoint (OpenRouter, vLLM, Ollama...). Model is config,
110
110
  never hard-coded.
111
- - **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function
112
- specs `read_file`, `write_file`, `edit_file`, `list_dir`, `bash` (git goes
113
- through bash), plus meta tools `finish` / `report_progress` / `get_goal` /
114
- `set_goal` / `read_memory` / `set_memory` / `list_skills`. Paths are
115
- confined to the workspace; bash runs detached in its own process group and
116
- the whole group is SIGKILLed on timeout.
111
+ - **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function specs —
112
+ file work via `read_file` (numbered lines, grep-style `pattern` mode, negative
113
+ offsets), `write_file`, `edit_file` (unique replacement, `replace_all`,
114
+ whitespace-tolerant fallbacks with recovery hints), `apply_patch`
115
+ (Codex-style multi-file add/update/rename/delete patches, validated
116
+ atomically before writing) and `list_dir`; `bash` for git/builds/tests and
117
+ quick bulk transforms; `read_url` fetches a web page's readable content
118
+ (Mozilla Readability + happy-dom); plus meta tools `finish` /
119
+ `report_progress` / `get_goal` / `set_goal` / `read_memory` / `set_memory` /
120
+ `list_skills`. Paths are confined to the workspace; bash runs detached in its
121
+ own process group and the whole group is SIGKILLed on timeout or shutdown.
117
122
  - **Cache-friendly prompt design** — the system prompt is byte-identical on
118
123
  every turn; session state (goal, memory, skills) is fetched via tools, never
119
124
  injected. Combined with the append-only message history this keeps provider
@@ -168,8 +173,10 @@ description: Steps to cut a release safely
168
173
  - The system prompt lists every discovered skill (name + description);
169
174
  workspace skills override same-named global ones.
170
175
  - The agent calls `load_skill(name)` when a task matches and follows it.
171
- - The agent calls `save_skill(name, description, content)` to distill a
172
- reusable procedure it developed available from the next turn, forever.
176
+ - The agent calls `save_skill(name, description, content, files?)` to distill a
177
+ reusable procedure `files` bundles helper scripts next to `SKILL.md`
178
+ (made executable automatically), and `load_skill` lists them as runnable
179
+ workspace paths. Available from the next turn, forever.
173
180
 
174
181
  ### Session log format (JSONL)
175
182
 
@@ -213,10 +220,13 @@ GET /api/agents list snapshots
213
220
  GET /api/agents/:id one snapshot
214
221
  POST /api/agents/:id/prompt {text, start?}
215
222
  POST /api/agents/:id/start | /stop
216
- POST /api/agents/:id/goal {text} or {status}
223
+ POST /api/agents/:id/load lazy session restore (stopped → idle)
224
+ POST /api/agents/:id/goal {text, notify?} or {status}
217
225
  POST /api/agents/:id/fork {} → new branch, same session log
226
+ POST /api/agents/:id/edit-prompt {eventId, text, tail: "discard"|"summarize"} → fork & resend
218
227
  GET /api/agents/:id/events?limit&branch&session
219
228
  GET /api/agents/:id/branches
229
+ GET /api/tasks scheduled tasks with computed next-fire times
220
230
  GET /api/metrics master rss/heap/load + per-agent stats
221
231
  GET /api/events SSE updates (push, no polling)
222
232
  GET /brew 418 I'm a teapot (RFC 2324)
@@ -33,7 +33,17 @@ Session state is not injected into prompts — fetch it with tools instead:
33
33
 
34
34
  ## Rules
35
35
  - Work step by step with tools. Verify results (run tests/builds) before claiming progress.
36
+ - File changes — pick by scope: write_file (one new file / full rewrite) ·
37
+ edit_file (exactly one small unique replacement) · apply_patch (several
38
+ edits, renames or deletes across one or more files, applied atomically).
39
+ read_file numbers lines and can grep via its pattern option. bash
40
+ text-munging (sed/awk/heredoc) remains available for quick bulk transforms
41
+ when that is genuinely faster.
36
42
  - When a loaded skill matches your task, follow its playbook.
43
+ - Turn proven procedures into skills: once something non-trivial worked well,
44
+ save_skill(name, description, content, files=[{name, content}]) so future
45
+ sessions can load_skill them — helper scripts go through files and are made
46
+ executable automatically.
37
47
  - When you make meaningful progress, call report_progress.
38
48
  - Be frugal: prefer small precise edits, avoid runaway loops.`;
39
49
  export class Agent {
@@ -46,6 +56,8 @@ export class Agent {
46
56
  currentBranch = "br0";
47
57
  goal = { text: "", status: "active", updatedAt: new Date().toISOString() };
48
58
  latestProgress = null;
59
+ /** set once the conversation has been restored (lazy: on first interaction) */
60
+ readyPromise = null;
49
61
  stats = {
50
62
  turns: 0,
51
63
  toolCalls: 0,
@@ -56,19 +68,34 @@ export class Agent {
56
68
  };
57
69
  opts;
58
70
  messages = [];
71
+ /**
72
+ * User prompts waiting for the next turn boundary. Deliberately NOT queued
73
+ * on runChain: that chain holds the long-running loop, so queueing behind
74
+ * it would delay both the log entry (UI) and delivery until the round —
75
+ * sometimes the whole goal — finished.
76
+ */
77
+ pendingPrompts = [];
59
78
  stopRequested = false;
60
79
  wake = null;
61
80
  abort = null;
81
+ /** aborted only by dispose(): kills in-flight subprocess groups instantly */
82
+ toolAbort = new AbortController();
62
83
  runChain = Promise.resolve();
63
84
  lastProgressAt = Date.now();
85
+ /** real assistant output since the last progress report (chars / turns) */
86
+ activityChars = 0;
87
+ turnsSinceProgress = 0;
64
88
  consecutiveToolErrors = 0;
65
89
  constructor(opts) {
66
90
  this.opts = {
67
91
  progressIntervalMs: 10 * 60_000,
92
+ progressMinChars: 4_000,
93
+ progressMaxQuietTurns: 40,
68
94
  autoContinue: true,
69
95
  continueDelayMs: 15_000,
70
96
  maxConsecutiveToolErrors: 5,
71
97
  contextTokenBudget: 96_000,
98
+ contextWindowTokens: 0,
72
99
  restoreSession: true,
73
100
  globalSkillsDir: "",
74
101
  provider: "",
@@ -84,6 +111,7 @@ export class Agent {
84
111
  defaultTimeoutMs: 120_000,
85
112
  maxOutputBytes: 60_000,
86
113
  skillRoots: this.skillRoots,
114
+ signal: this.toolAbort.signal,
87
115
  };
88
116
  // the session id IS the directory name — one directory per incarnation
89
117
  this.mainSession = path.basename(opts.sessionDir);
@@ -127,9 +155,35 @@ export class Agent {
127
155
  await this.migrateGoalFromWorkspace();
128
156
  const stored = await this.readGoalStore();
129
157
  this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
130
- if (this.opts.restoreSession)
131
- await this.restoreFromLog();
132
158
  await this.refreshSkills();
159
+ // the conversation is NOT restored here: boot cost stays O(agents), not
160
+ // O(history). It is rebuilt lazily by ensureReady() on first interaction.
161
+ if (this.opts.restoreSession) {
162
+ this.status = "stopped";
163
+ this.statusReason = "session not loaded — select it or send a prompt";
164
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
165
+ }
166
+ }
167
+ /**
168
+ * Restore the conversation from the JSONL log exactly once, on demand.
169
+ * Everything that touches history (prompts, start, fork, UI selection)
170
+ * funnels through here; boot stays cheap no matter how many sessions exist.
171
+ */
172
+ ensureReady() {
173
+ if (!this.readyPromise) {
174
+ this.readyPromise = (async () => {
175
+ if (this.opts.restoreSession) {
176
+ await this.restoreFromLog();
177
+ if (this.status === "stopped")
178
+ this.setStatus("idle", "session loaded");
179
+ }
180
+ })();
181
+ }
182
+ return this.readyPromise;
183
+ }
184
+ /** Explicit load (e.g. the user clicked the agent in the UI): stopped → idle. */
185
+ async load() {
186
+ await this.ensureReady();
133
187
  }
134
188
  /** harness-managed files inside the session directory */
135
189
  get goalFile() {
@@ -179,83 +233,11 @@ export class Agent {
179
233
  const events = await readEvents(this.log.filePath);
180
234
  if (events.length === 0)
181
235
  return;
182
- const byId = new Map(events.map((e) => [e.id, e]));
183
- let last = events[events.length - 1];
184
- // walk backwards to the root via parent links (fork-safe)
185
- const lineage = [];
186
- const seen = new Set();
187
- for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
188
- if (seen.has(cur.id))
189
- break;
190
- seen.add(cur.id);
191
- lineage.push(cur);
192
- }
193
- lineage.reverse();
194
- // skip the trailing fork event itself (it is bookkeeping, not conversation)
195
- while (lineage.length && lineage[0].type === "fork")
196
- lineage.shift();
197
- const msgs = [];
198
- for (const e of lineage) {
199
- const d = e.data;
200
- if (e.type === "prompt" && typeof d.text === "string") {
201
- msgs.push({ role: "user", content: d.text });
202
- }
203
- else if (e.type === "message") {
204
- const role = d.role === "assistant" ? "assistant" : "user";
205
- const m = { role, content: typeof d.content === "string" ? d.content : "" };
206
- if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
207
- m.tool_calls = d.toolCalls.map((c) => ({
208
- id: c.id,
209
- type: "function",
210
- function: { name: c.name, arguments: "{}" },
211
- }));
212
- }
213
- msgs.push(m);
214
- }
215
- else if (e.type === "tool_call") {
216
- // enrich the preceding assistant tool_calls with real arguments
217
- const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
218
- const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
219
- if (tc)
220
- tc.function.arguments = JSON.stringify(d.args ?? {});
221
- }
222
- else if (e.type === "tool_result") {
223
- msgs.push({
224
- role: "tool",
225
- tool_call_id: String(d.callId ?? ""),
226
- content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
227
- });
228
- }
229
- else if (e.type === "progress") {
230
- // progress events may follow an assistant report_progress call that
231
- // has no logged tool result — patch it in when present
232
- const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
233
- if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
234
- for (const t of lastAssistant.tool_calls) {
235
- if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
236
- msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
237
- }
238
- }
239
- }
240
- }
241
- }
242
- // every assistant tool_call must be answered by a tool message, or the
243
- // API rejects the sequence — close any holes left by meta tools (finish)
244
- for (let i = 0; i < msgs.length; i++) {
245
- const m = msgs[i];
246
- if (m.role === "assistant" && m.tool_calls?.length) {
247
- for (const t of m.tool_calls) {
248
- if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
249
- msgs.splice(i + 1, 0, {
250
- role: "tool",
251
- tool_call_id: t.id,
252
- content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
253
- });
254
- i++;
255
- }
256
- }
257
- }
258
- }
236
+ const lineage = lineageOf(events);
237
+ if (!lineage.length)
238
+ return;
239
+ const last = lineage[lineage.length - 1];
240
+ const msgs = rebuildMessagesFrom(lineage);
259
241
  if (msgs.length > 0) {
260
242
  this.messages = msgs;
261
243
  this.currentBranch = last.branch;
@@ -266,6 +248,61 @@ export class Agent {
266
248
  });
267
249
  }
268
250
  }
251
+ /**
252
+ * Edit a previously-sent prompt: fork the conversation at that point,
253
+ * replace its text, and optionally fold everything that happened after it
254
+ * into a summary note on the new branch (ChatGPT-edit style). The agent
255
+ * must not be running — editing under a live loop would race its history.
256
+ */
257
+ async editPromptAt(eventId, text, tail) {
258
+ if (this.status === "running")
259
+ throw new Error("agent is running — stop it before editing history");
260
+ const all = await readEvents(this.log.filePath);
261
+ const target = all.find((e) => e.id === eventId);
262
+ if (!target || target.type !== "prompt")
263
+ throw new Error("event not found on this session (or not a prompt)");
264
+ const lineage = lineageOf(all);
265
+ const tIdx = lineage.findIndex((e) => e.id === eventId);
266
+ if (tIdx === -1)
267
+ throw new Error("prompt is not on this agent's current lineage");
268
+ const kept = lineage.slice(0, tIdx);
269
+ const dropped = lineage.slice(tIdx); // includes the original prompt itself
270
+ const msgs = rebuildMessagesFrom(kept);
271
+ if (tail === "summarize" && dropped.length > 0) {
272
+ try {
273
+ const droppedMsgs = rebuildMessagesFrom(dropped);
274
+ if (droppedMsgs.length) {
275
+ const summary = await this.summarize(droppedMsgs);
276
+ if (summary.trim()) {
277
+ msgs.push({
278
+ role: "user",
279
+ content: "[harness] The conversation continued past this point on another timeline. " +
280
+ `Notes from what happened there:\n\n${summary}`,
281
+ });
282
+ }
283
+ }
284
+ }
285
+ catch {
286
+ // summarization is best-effort; the fork proceeds without notes
287
+ }
288
+ }
289
+ msgs.push({ role: "user", content: text });
290
+ const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
291
+ await this.log.append("fork", this.currentSession, newBranch, {
292
+ fromSession: this.currentSession,
293
+ fromBranch: this.currentBranch,
294
+ fromEvent: kept.at(-1)?.id ?? null,
295
+ newBranch,
296
+ reason: "prompt-edited",
297
+ droppedEvents: dropped.length,
298
+ tailMode: tail,
299
+ });
300
+ this.currentBranch = newBranch;
301
+ this.messages = msgs;
302
+ await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
303
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
304
+ return { droppedEvents: dropped.length, branch: newBranch };
305
+ }
269
306
  parseGoalFile(text) {
270
307
  // humans and agents may append their own status lines — latest wins
271
308
  const all = [...text.matchAll(/status:\s*(\w+)/gi)];
@@ -316,14 +353,35 @@ export class Agent {
316
353
  model: this.opts.llm.model,
317
354
  provider: this.opts.provider,
318
355
  sessionDir: this.opts.sessionDir,
356
+ ctx: {
357
+ usedTokens: this.estimateTokens(),
358
+ compactAt: this.opts.contextTokenBudget,
359
+ window: this.opts.contextWindowTokens || 0,
360
+ },
361
+ pendingPrompts: this.pendingPrompts.length,
319
362
  };
320
363
  }
321
- /** Queue a user prompt; wakes the loop if needed. Returns immediately. */
364
+ /**
365
+ * Queue a user prompt. Returns immediately: the event is logged right away
366
+ * (so every connected UI sees it instantly) and the text is handed to the
367
+ * model at the next turn boundary — never mid-turn, and never blocked by
368
+ * the running loop. The very first prompt on a fresh boot also triggers the
369
+ * lazy session restore (before the mailbox is filled, so no duplicates).
370
+ */
322
371
  enqueuePrompt(text, source = "user") {
323
- return this.enqueue(async () => {
324
- await this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
325
- this.messages.push({ role: "user", content: text });
326
- });
372
+ void this.ensureReady()
373
+ .then(() => {
374
+ this.pendingPrompts.push({ source, text });
375
+ return this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
376
+ })
377
+ .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
378
+ .catch(() => { });
379
+ }
380
+ /** Hand queued user prompts to the model at a turn boundary. */
381
+ drainPendingPrompts() {
382
+ for (const p of this.pendingPrompts.splice(0)) {
383
+ this.messages.push({ role: "user", content: p.text });
384
+ }
327
385
  }
328
386
  /** Resolves when all queued work (including a running loop) has settled. */
329
387
  settled() {
@@ -340,6 +398,7 @@ export class Agent {
340
398
  return;
341
399
  this.stopRequested = false;
342
400
  void this.enqueue(async () => {
401
+ await this.ensureReady(); // lazy restore before the loop touches history
343
402
  this.setStatus("running", reason);
344
403
  this.stats.startedAt ??= new Date().toISOString();
345
404
  });
@@ -372,9 +431,16 @@ export class Agent {
372
431
  while (!this.stopRequested) {
373
432
  try {
374
433
  const finished = await this.runTurnsUntilIdle();
375
- if (finished || this.stopRequested)
434
+ if (this.stopRequested)
376
435
  break;
377
- if (!this.opts.autoContinue || this.goal.status !== "active")
436
+ // fresh user input arrived while we were finishing up — another round now
437
+ if (this.pendingPrompts.length)
438
+ continue;
439
+ // auto-continue only makes sense with an active goal to continue toward
440
+ if (finished ||
441
+ !this.opts.autoContinue ||
442
+ this.goal.status !== "active" ||
443
+ !this.goal.text.trim())
378
444
  break;
379
445
  // auto-continue: wait quietly, then nudge with a fresh round
380
446
  await this.sleepInterruptible(this.opts.continueDelayMs);
@@ -458,6 +524,8 @@ export class Agent {
458
524
  for (let guard = 0; guard < 200; guard++) {
459
525
  if (this.stopRequested)
460
526
  return finished;
527
+ // deliver prompts queued while the previous turn was running
528
+ this.drainPendingPrompts();
461
529
  // skills may have been created last turn — refresh the prompt listing
462
530
  await this.refreshSkills();
463
531
  // periodic progress report at turn boundary (no mid-turn interruption)
@@ -471,14 +539,40 @@ export class Agent {
471
539
  turn: ++this.stats.turns,
472
540
  });
473
541
  // stream the assistant reply live to connected clients
474
- const res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
475
- bus.emit("update", {
476
- kind: "llm-delta",
477
- agentId: this.opts.id,
478
- text: s.text,
479
- reasoning: s.reasoning,
542
+ let res;
543
+ try {
544
+ res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
545
+ bus.emit("update", {
546
+ kind: "llm-delta",
547
+ agentId: this.opts.id,
548
+ text: s.text,
549
+ reasoning: s.reasoning,
550
+ });
480
551
  });
481
- });
552
+ }
553
+ catch (err) {
554
+ // user stop mid-stream: persist the partial output so the timeline
555
+ // keeps what was already visible (otherwise it silently vanishes)
556
+ const partial = err.partial;
557
+ if (this.stopRequested && partial && (partial.text || partial.reasoning)) {
558
+ await this.log.append("message", this.currentSession, this.currentBranch, {
559
+ role: "assistant",
560
+ content: partial.text ?? "",
561
+ reasoning: partial.reasoning,
562
+ interrupted: true,
563
+ });
564
+ this.messages.push({ role: "assistant", content: partial.text ?? "" });
565
+ }
566
+ else if (this.stopRequested) {
567
+ // nothing had streamed — leave an explicit marker so the log shows
568
+ // why this prompt has no reply
569
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
570
+ event: "turn-interrupted",
571
+ detail: "stopped before any output arrived",
572
+ });
573
+ }
574
+ throw err;
575
+ }
482
576
  if (res.usage) {
483
577
  this.stats.inputTokens += res.usage.inputTokens ?? 0;
484
578
  this.stats.outputTokens += res.usage.outputTokens ?? 0;
@@ -492,6 +586,8 @@ export class Agent {
492
586
  reasoning: res.reasoning,
493
587
  });
494
588
  this.messages.push(m);
589
+ this.turnsSinceProgress++;
590
+ this.activityChars += m.content?.length ?? 0;
495
591
  if (!m.tool_calls?.length)
496
592
  return finished;
497
593
  for (const call of m.tool_calls) {
@@ -608,9 +704,14 @@ export class Agent {
608
704
  });
609
705
  }
610
706
  async maybeRequestProgress() {
611
- if (Date.now() - this.lastProgressAt < this.opts.progressIntervalMs)
612
- return;
707
+ const elapsedOk = Date.now() - this.lastProgressAt >= this.opts.progressIntervalMs;
708
+ const activityOk = this.activityChars >= this.opts.progressMinChars ||
709
+ this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
710
+ if (!elapsedOk || !activityOk)
711
+ return; // stalling provider → don't waste a turn asking
613
712
  this.lastProgressAt = Date.now();
713
+ this.activityChars = 0;
714
+ this.turnsSinceProgress = 0;
614
715
  const request = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
615
716
  "what you recently tried, any problems, and your next step. Keep it under 10 lines.";
616
717
  // log both sides so a session restore replays this exchange faithfully
@@ -746,6 +847,10 @@ export class Agent {
746
847
  next: str(a.next) || undefined,
747
848
  ts: new Date().toISOString(),
748
849
  };
850
+ // a report (voluntary or requested) restarts the progress gates
851
+ this.lastProgressAt = Date.now();
852
+ this.activityChars = 0;
853
+ this.turnsSinceProgress = 0;
749
854
  await this.log.append("progress", this.currentSession, this.currentBranch, this.latestProgress);
750
855
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
751
856
  }
@@ -764,6 +869,9 @@ export class Agent {
764
869
  }
765
870
  async dispose() {
766
871
  this.stop("disposed");
872
+ // kill any in-flight subprocess group NOW so shutdown never waits out a
873
+ // long-running command (up to 10 min otherwise)
874
+ this.toolAbort.abort();
767
875
  await this.runChain.catch(() => { });
768
876
  await this.log.close();
769
877
  }
@@ -890,3 +998,120 @@ function safeParse(json) {
890
998
  return {};
891
999
  }
892
1000
  }
1001
+ /** Walk parent links backwards from the newest event, then flip forward. */
1002
+ function lineageOf(events) {
1003
+ if (events.length === 0)
1004
+ return [];
1005
+ const byId = new Map(events.map((e) => [e.id, e]));
1006
+ const last = events[events.length - 1];
1007
+ const lineage = [];
1008
+ const seen = new Set();
1009
+ for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
1010
+ if (seen.has(cur.id))
1011
+ break;
1012
+ seen.add(cur.id);
1013
+ lineage.push(cur);
1014
+ }
1015
+ lineage.reverse();
1016
+ // the trailing fork event itself is bookkeeping, not conversation
1017
+ while (lineage.length && lineage[0].type === "fork")
1018
+ lineage.shift();
1019
+ return lineage;
1020
+ }
1021
+ /**
1022
+ * Replay ordered events into ChatMessages (shared by session restore and
1023
+ * prompt-edit forks). Prompts logged inside an open tool batch are buffered
1024
+ * until it closes, so user messages never split a tool_call/tool_result pair.
1025
+ */
1026
+ function rebuildMessagesFrom(list) {
1027
+ const msgs = [];
1028
+ const META_TOOLS = new Set([
1029
+ "finish", "report_progress", "set_goal", "get_goal",
1030
+ "read_memory", "set_memory", "list_skills",
1031
+ ]);
1032
+ const openCalls = new Map(); // real tool_call id -> name
1033
+ const bufferedUsers = [];
1034
+ const flushUsers = () => {
1035
+ if (openCalls.size === 0) {
1036
+ for (const text of bufferedUsers.splice(0))
1037
+ msgs.push({ role: "user", content: text });
1038
+ }
1039
+ };
1040
+ for (const e of list) {
1041
+ const d = e.data;
1042
+ if (e.type === "prompt" && typeof d.text === "string") {
1043
+ if (openCalls.size > 0)
1044
+ bufferedUsers.push(d.text);
1045
+ else
1046
+ msgs.push({ role: "user", content: d.text });
1047
+ }
1048
+ else if (e.type === "message") {
1049
+ const role = d.role === "assistant" ? "assistant" : "user";
1050
+ const m = { role, content: typeof d.content === "string" ? d.content : "" };
1051
+ if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
1052
+ m.tool_calls = d.toolCalls.map((c) => ({
1053
+ id: c.id,
1054
+ type: "function",
1055
+ function: { name: c.name, arguments: "{}" },
1056
+ }));
1057
+ // meta tools are answered inline by the harness (no logged result);
1058
+ // the hole-filling pass below synthesizes theirs where they belong
1059
+ for (const t of m.tool_calls)
1060
+ if (!META_TOOLS.has(t.function.name))
1061
+ openCalls.set(t.id, t.function.name);
1062
+ }
1063
+ msgs.push(m);
1064
+ }
1065
+ else if (e.type === "tool_call") {
1066
+ // enrich the preceding assistant tool_calls with real arguments
1067
+ const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
1068
+ const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
1069
+ if (tc)
1070
+ tc.function.arguments = JSON.stringify(d.args ?? {});
1071
+ }
1072
+ else if (e.type === "tool_result") {
1073
+ msgs.push({
1074
+ role: "tool",
1075
+ tool_call_id: String(d.callId ?? ""),
1076
+ content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
1077
+ });
1078
+ openCalls.delete(String(d.callId ?? ""));
1079
+ flushUsers();
1080
+ }
1081
+ else if (e.type === "progress") {
1082
+ // progress events may follow an assistant report_progress call that
1083
+ // has no logged tool result — patch it in when present
1084
+ const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
1085
+ if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
1086
+ for (const t of lastAssistant.tool_calls) {
1087
+ if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
1088
+ msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
1089
+ }
1090
+ openCalls.delete(t.id);
1091
+ }
1092
+ flushUsers();
1093
+ }
1094
+ }
1095
+ }
1096
+ // every assistant tool_call must be answered by a tool message, or the
1097
+ // API rejects the sequence — close any holes left by meta tools (finish)
1098
+ for (let i = 0; i < msgs.length; i++) {
1099
+ const m = msgs[i];
1100
+ if (m.role === "assistant" && m.tool_calls?.length) {
1101
+ for (const t of m.tool_calls) {
1102
+ if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
1103
+ msgs.splice(i + 1, 0, {
1104
+ role: "tool",
1105
+ tool_call_id: t.id,
1106
+ content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
1107
+ });
1108
+ i++;
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+ // prompts that were still waiting on a hole-filled tail land here
1114
+ for (const text of bufferedUsers.splice(0))
1115
+ msgs.push({ role: "user", content: text });
1116
+ return msgs;
1117
+ }
package/dist/agent/llm.js CHANGED
@@ -96,6 +96,9 @@ export async function chat(cfg, messages, tools, signal) {
96
96
  */
97
97
  export async function chatStream(cfg, messages, tools, signal, onDelta) {
98
98
  let gotChunk = false;
99
+ // hoisted so the abort handler below can attach whatever streamed so far
100
+ let text = "";
101
+ let reasoning = "";
99
102
  try {
100
103
  const stream = await client(cfg).chat.completions.create({
101
104
  model: cfg.model,
@@ -103,8 +106,6 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
103
106
  ...(tools.length ? { tools } : {}),
104
107
  stream: true,
105
108
  }, { signal });
106
- let text = "";
107
- let reasoning = "";
108
109
  const calls = {};
109
110
  let finishReason;
110
111
  let usage;
@@ -155,6 +156,11 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
155
156
  // provider may not support streaming at all — one clean fallback
156
157
  if (!gotChunk && !signal?.aborted)
157
158
  return chat(cfg, messages, tools, signal);
159
+ // user interrupt: hand back whatever streamed so far so the harness can
160
+ // keep the partial output visible instead of losing it
161
+ if (signal?.aborted && (text || reasoning)) {
162
+ err.partial = { text, reasoning };
163
+ }
158
164
  throw err;
159
165
  }
160
166
  }
@@ -64,11 +64,24 @@ export async function discoverSkills(roots) {
64
64
  const name = parsed.meta.name || e.name;
65
65
  if (byName.has(name))
66
66
  continue; // higher-priority root already defined it
67
+ // collect bundled files (helper scripts, templates, ...) next to SKILL.md
68
+ let files = [];
69
+ try {
70
+ const siblings = await fs.readdir(path.join(root.dir, e.name), { withFileTypes: true });
71
+ files = siblings
72
+ .filter((f) => f.isFile() && f.name !== SKILL_FILE && !f.name.startsWith("."))
73
+ .map((f) => f.name)
74
+ .sort();
75
+ }
76
+ catch {
77
+ /* unreadable dir → no bundled files */
78
+ }
67
79
  byName.set(name, {
68
80
  name,
69
81
  description: parsed.meta.description || "",
70
82
  source: root.source,
71
83
  filePath,
84
+ files,
72
85
  });
73
86
  }
74
87
  }