@pugi/cli 0.1.0-alpha.6 → 0.1.0-alpha.8

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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Persistent REPL history (per-workspace) - Sprint α6.14.
3
+ *
4
+ * Stores submitted briefs in `~/.pugi/history/<workspace-slug>.jsonl`,
5
+ * one JSON object per line. The format is line-delimited JSON so the
6
+ * file can be appended to atomically (single `write(2)` per entry on
7
+ * Linux/macOS for entries < PIPE_BUF) and tailed by humans without a
8
+ * parser. Per-workspace separation lets the operator switch repos and
9
+ * keep brief history contextual: `brief: fix the cabinet sidebar 401`
10
+ * does not bleed into the agents repo.
11
+ *
12
+ * Contract:
13
+ *
14
+ * - `append({ home, workspaceSlug, brief })` writes one line. Dedups
15
+ * a brief that is identical to the immediately preceding entry
16
+ * (most common operator pattern: Up + Enter to re-run).
17
+ * - `read({ home, workspaceSlug })` returns entries oldest-first so
18
+ * the caller can navigate with `index = entries.length - 1` for
19
+ * "most recent" semantics.
20
+ * - The file is capped at MAX_ENTRIES; on overflow we keep the most
21
+ * recent slice and rewrite. Cheap because briefs are short text
22
+ * and the cap is 1000.
23
+ * - `slugForCwd(cwd)` normalises a working directory into a safe
24
+ * filename component (alphanumerics + `-`, lowercase, slashes
25
+ * collapsed). Empty cwd resolves to `default`.
26
+ * - Failures (missing $HOME, disk full, EACCES) NEVER throw. History
27
+ * is operator comfort, not a contract surface; degrading to "no
28
+ * history this session" is correct.
29
+ *
30
+ * Brand voice: file is operator-facing if they `cat` it, so the JSON
31
+ * keys stay readable English (`brief`, `ts`). No forbidden words.
32
+ */
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, unlinkSync, } from 'node:fs';
34
+ import { homedir } from 'node:os';
35
+ import { dirname, join } from 'node:path';
36
+ /** Cap on stored entries per workspace. Drops oldest on overflow. */
37
+ export const MAX_HISTORY_ENTRIES = 1000;
38
+ /**
39
+ * Compute the on-disk path for a given workspace slug. Tests rely on
40
+ * this to assert per-workspace isolation without re-implementing the
41
+ * directory math.
42
+ */
43
+ export function historyPath(io) {
44
+ const home = io.home ?? homedir();
45
+ const safe = sanitiseSlug(io.workspaceSlug);
46
+ return join(home, '.pugi', 'history', `${safe}.jsonl`);
47
+ }
48
+ /**
49
+ * Append a brief to history. Dedups consecutive identical entries.
50
+ * Returns the entry that was written, or `null` when the entry was
51
+ * deduped or the brief was empty.
52
+ */
53
+ export function append(input) {
54
+ const brief = input.brief.trim();
55
+ if (brief.length === 0)
56
+ return null;
57
+ const path = historyPath(input);
58
+ try {
59
+ ensureDir(dirname(path));
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ const existing = read({ home: input.home, workspaceSlug: input.workspaceSlug });
65
+ const last = existing[existing.length - 1];
66
+ if (last && last.brief === brief) {
67
+ return null;
68
+ }
69
+ const ts = (input.now ?? (() => new Date()))().toISOString();
70
+ const entry = { ts, brief };
71
+ // Overflow path: combined length > cap means we trim before rewrite.
72
+ // Write to a sibling tmp file and renameSync over the target so
73
+ // concurrent CLI instances in the same workspace cannot observe a
74
+ // half-written file or race a parallel appendFileSync into oblivion.
75
+ // POSIX renameSync is atomic within a directory; on Windows fs.rename
76
+ // is atomic too as long as both paths are on the same volume (the tmp
77
+ // sibling guarantees that). P2 fix from PR #335 triple-review.
78
+ if (existing.length + 1 > MAX_HISTORY_ENTRIES) {
79
+ const trimmed = [...existing.slice(existing.length + 1 - MAX_HISTORY_ENTRIES), entry];
80
+ const tmpPath = `${path}.tmp`;
81
+ try {
82
+ writeFileSync(tmpPath, trimmed.map(serialize).join('\n') + '\n', { mode: 0o600 });
83
+ renameSync(tmpPath, path);
84
+ }
85
+ catch {
86
+ // Best-effort cleanup of the orphan tmp file; never throw out.
87
+ try {
88
+ unlinkSync(tmpPath);
89
+ }
90
+ catch {
91
+ /* ignore — tmp file may not exist yet */
92
+ }
93
+ return null;
94
+ }
95
+ return entry;
96
+ }
97
+ try {
98
+ appendFileSync(path, serialize(entry) + '\n', { mode: 0o600 });
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ return entry;
104
+ }
105
+ /**
106
+ * Read history for a workspace, oldest-first. Returns `[]` when the
107
+ * file is missing, unreadable, or empty. Malformed lines are dropped
108
+ * silently - one bad line should not nuke the whole history.
109
+ */
110
+ export function read(io) {
111
+ const path = historyPath(io);
112
+ if (!existsSync(path))
113
+ return [];
114
+ let raw;
115
+ try {
116
+ raw = readFileSync(path, 'utf8');
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ const out = [];
122
+ for (const line of raw.split('\n')) {
123
+ const trimmed = line.trim();
124
+ if (trimmed.length === 0)
125
+ continue;
126
+ try {
127
+ const parsed = JSON.parse(trimmed);
128
+ if (typeof parsed === 'object' &&
129
+ parsed !== null &&
130
+ typeof parsed.brief === 'string' &&
131
+ typeof parsed.ts === 'string') {
132
+ out.push({
133
+ ts: parsed.ts,
134
+ brief: parsed.brief,
135
+ });
136
+ }
137
+ }
138
+ catch {
139
+ // Drop malformed line.
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+ /**
145
+ * Normalise a cwd or workspace name into a safe filename component.
146
+ * Lowercase, alphanumerics + `-` only. Slashes become `-`. Empty input
147
+ * resolves to `default` so we never produce an empty filename.
148
+ */
149
+ export function slugForCwd(cwd) {
150
+ if (!cwd || cwd.trim().length === 0)
151
+ return 'default';
152
+ // Strip leading slash so `/Users/foo` becomes `users-foo`.
153
+ const normalised = cwd
154
+ .replace(/^[/\\]+/, '')
155
+ .replace(/[/\\]+/g, '-')
156
+ .toLowerCase();
157
+ return sanitiseSlug(normalised);
158
+ }
159
+ function sanitiseSlug(raw) {
160
+ const cleaned = raw.replace(/[^a-z0-9-]/gi, '-').toLowerCase().replace(/-+/g, '-');
161
+ const trimmed = cleaned.replace(/^-+|-+$/g, '');
162
+ return trimmed.length === 0 ? 'default' : trimmed;
163
+ }
164
+ function serialize(entry) {
165
+ return JSON.stringify(entry);
166
+ }
167
+ function ensureDir(dir) {
168
+ if (existsSync(dir))
169
+ return;
170
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
171
+ }
172
+ //# sourceMappingURL=history.js.map
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Kill ring for the REPL input - Sprint α6.14.
3
+ *
4
+ * Tiny LIFO buffer that backs the readline-style kill commands:
5
+ *
6
+ * Ctrl+U - kill from cursor to line start
7
+ * Ctrl+K - kill from cursor to line end
8
+ * Ctrl+W - kill word backwards (whitespace + punctuation delimiter)
9
+ * Ctrl+Y - yank the most recent kill at the cursor
10
+ *
11
+ * The ring is bounded (MAX_RING_ENTRIES = 10) so the operator's
12
+ * recent kills are reachable without leaking memory across long
13
+ * sessions. We do NOT implement Meta+Y (cycle yanks) at this layer -
14
+ * sticking to the most-recent-yank keeps the input box logic small
15
+ * and matches the bash default for new operators.
16
+ *
17
+ * The module is pure functional: every operation returns a NEW ring,
18
+ * so the input box can stash one in `useState` without mutation
19
+ * worries. Empty slices are no-ops (we do not push empty strings) so
20
+ * Ctrl+U at column 0 does not pollute the ring with `""`.
21
+ */
22
+ export const MAX_RING_ENTRIES = 10;
23
+ export const EMPTY_KILL_RING = { entries: [] };
24
+ /**
25
+ * Push a slice into the ring. Returns a new ring with the slice at
26
+ * the front; older entries shift right and the tail is dropped if
27
+ * the cap is exceeded. Empty / whitespace-only slices are a no-op so
28
+ * the ring stays meaningful.
29
+ */
30
+ export function push(ring, slice) {
31
+ if (slice.length === 0)
32
+ return ring;
33
+ const next = [slice, ...ring.entries];
34
+ return { entries: next.slice(0, MAX_RING_ENTRIES) };
35
+ }
36
+ /**
37
+ * Read the most-recent entry without mutating the ring. Returns
38
+ * `null` when the ring is empty so the caller can decide whether to
39
+ * beep, no-op, or fall through to plain insert.
40
+ */
41
+ export function yank(ring) {
42
+ return ring.entries.length > 0 ? ring.entries[0] : null;
43
+ }
44
+ /**
45
+ * Word delimiter used by Ctrl+W. Whitespace + ASCII punctuation,
46
+ * matching the readline default. We treat `_` and `-` as part of the
47
+ * word so kebab-case and snake_case identifiers behave as one token
48
+ * (the brief frequently mentions filenames + symbols).
49
+ */
50
+ const WORD_DELIMITERS = new Set([
51
+ ' ', '\t', '\n',
52
+ '.', ',', ';', ':',
53
+ '/', '\\',
54
+ '!', '?', '@', '#', '$', '%', '^', '&', '*',
55
+ '(', ')', '[', ']', '{', '}', '<', '>',
56
+ '"', "'", '`',
57
+ '=', '+', '|', '~',
58
+ ]);
59
+ /**
60
+ * Compute the start of the previous word given a cursor position.
61
+ * Walks LEFT from `cursor - 1`, skipping any delimiters that
62
+ * immediately precede the cursor (so Ctrl+W at the end of `foo `
63
+ * still kills `foo`), then continues left until it hits the next
64
+ * delimiter or the start of the line.
65
+ *
66
+ * Returns the offset BEFORE which the kill should start (i.e. the
67
+ * slice to remove is `line.slice(start, cursor)`).
68
+ */
69
+ export function previousWordStart(line, cursor) {
70
+ let i = Math.min(cursor, line.length) - 1;
71
+ // Skip trailing delimiters.
72
+ while (i >= 0 && WORD_DELIMITERS.has(line[i]))
73
+ i -= 1;
74
+ // Walk through the word.
75
+ while (i >= 0 && !WORD_DELIMITERS.has(line[i]))
76
+ i -= 1;
77
+ return i + 1;
78
+ }
79
+ /**
80
+ * Apply a Ctrl+U kill: from cursor to line start. Returns the new
81
+ * line + cursor + ring. No-op when cursor is already at column 0.
82
+ */
83
+ export function killToLineStart(line, cursor, ring) {
84
+ if (cursor === 0)
85
+ return { line, cursor, ring };
86
+ const slice = line.slice(0, cursor);
87
+ return {
88
+ line: line.slice(cursor),
89
+ cursor: 0,
90
+ ring: push(ring, slice),
91
+ };
92
+ }
93
+ /**
94
+ * Apply a Ctrl+K kill: from cursor to line end. Returns the new
95
+ * line + cursor + ring. No-op when cursor is already past the last
96
+ * character.
97
+ */
98
+ export function killToLineEnd(line, cursor, ring) {
99
+ if (cursor >= line.length)
100
+ return { line, cursor, ring };
101
+ const slice = line.slice(cursor);
102
+ return {
103
+ line: line.slice(0, cursor),
104
+ cursor,
105
+ ring: push(ring, slice),
106
+ };
107
+ }
108
+ /**
109
+ * Apply a Ctrl+W kill: from cursor back to the start of the previous
110
+ * word. Returns the new line + cursor + ring. No-op when cursor is
111
+ * at column 0.
112
+ */
113
+ export function killWordBackward(line, cursor, ring) {
114
+ if (cursor === 0)
115
+ return { line, cursor, ring };
116
+ const start = previousWordStart(line, cursor);
117
+ const slice = line.slice(start, cursor);
118
+ return {
119
+ line: line.slice(0, start) + line.slice(cursor),
120
+ cursor: start,
121
+ ring: push(ring, slice),
122
+ };
123
+ }
124
+ /**
125
+ * Apply a Ctrl+Y yank: insert the most-recent entry at the cursor.
126
+ * Returns the new line + cursor unchanged when the ring is empty
127
+ * (the caller decides whether to surface a visual cue).
128
+ */
129
+ export function yankAtCursor(line, cursor, ring) {
130
+ const entry = yank(ring);
131
+ if (entry === null)
132
+ return { line, cursor };
133
+ return {
134
+ line: line.slice(0, cursor) + entry + line.slice(cursor),
135
+ cursor: cursor + entry.length,
136
+ };
137
+ }
138
+ //# sourceMappingURL=kill-ring.js.map
@@ -30,6 +30,11 @@ import { randomUUID } from 'node:crypto';
30
30
  import { listRoles, getPersonaForRole } from '../agents/registry.js';
31
31
  import { evaluateCap, describeVerdict } from './cap-warning.js';
32
32
  import { parseSlashCommand } from './slash-commands.js';
33
+ import { webFetchTool } from '../../tools/web-fetch.js';
34
+ import { loadSettings } from '../settings.js';
35
+ import { getJobRegistry } from '../jobs/registry.js';
36
+ import { existsSync, readdirSync, statSync } from 'node:fs';
37
+ import { resolve as resolvePath } from 'node:path';
33
38
  const MAX_TRANSCRIPT_ROWS = 500;
34
39
  const MAX_RECONNECT_ATTEMPTS = 10;
35
40
  const RECONNECT_BASE_MS = 250;
@@ -43,6 +48,18 @@ export class ReplSession {
43
48
  reconnectAttempt = 0;
44
49
  reconnectTimer;
45
50
  closed = false;
51
+ /**
52
+ * Last non-trivial step.detail recorded per taskId. The server streams
53
+ * the persona reply incrementally via `agent.step` events whose
54
+ * `detail` field carries the cumulative model output. `agent.completed`
55
+ * arrives last and previously overwrote the visible detail to the
56
+ * literal string `'shipped'` while the transcript line said only
57
+ * `shipped.` — the actual reply text was lost. By caching the last
58
+ * non-trivial detail here, we can flush it into the transcript when
59
+ * the agent completes so the operator sees what the persona actually
60
+ * said. CEO wave-2 fix 2026-05-25.
61
+ */
62
+ lastStepDetail = new Map();
46
63
  constructor(options) {
47
64
  this.options = options;
48
65
  this.state = {
@@ -133,8 +150,154 @@ export class ReplSession {
133
150
  await this.dispatchBrief(verdict.brief);
134
151
  return verdict;
135
152
  }
153
+ case 'web': {
154
+ await this.dispatchWebFetch(verdict.url);
155
+ return verdict;
156
+ }
157
+ case 'clear': {
158
+ this.clearTranscript();
159
+ return verdict;
160
+ }
161
+ case 'version': {
162
+ this.appendSystemLine(`pugi ${this.options.cliVersion}`);
163
+ return verdict;
164
+ }
165
+ case 'jobs': {
166
+ await this.dispatchJobs();
167
+ return verdict;
168
+ }
169
+ case 'diff': {
170
+ this.dispatchDiff();
171
+ return verdict;
172
+ }
173
+ case 'cost': {
174
+ this.dispatchCost();
175
+ return verdict;
176
+ }
177
+ case 'status': {
178
+ this.dispatchStatus();
179
+ return verdict;
180
+ }
181
+ case 'stub': {
182
+ this.appendSystemLine(verdict.message);
183
+ return verdict;
184
+ }
136
185
  }
137
186
  }
187
+ /**
188
+ * Reset the conversation transcript. The agent registry stays intact
189
+ * so the operator can `/clear` to declutter the chat pane without
190
+ * losing visibility into running dispatches.
191
+ */
192
+ clearTranscript() {
193
+ this.patch({ transcript: [] });
194
+ }
195
+ /* ------------- Tier 1 / Tier 2 wired handlers -------------- */
196
+ async dispatchJobs() {
197
+ try {
198
+ const registry = getJobRegistry();
199
+ const entries = await registry.list();
200
+ if (entries.length === 0) {
201
+ this.appendSystemLine('No background jobs tracked.');
202
+ return;
203
+ }
204
+ this.appendSystemLine(`Background jobs (${entries.length}):`);
205
+ for (const entry of entries) {
206
+ const id = entry.id.replace(/^pj-/, '').slice(0, 8);
207
+ const status = entry.status;
208
+ const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
209
+ this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
210
+ }
211
+ }
212
+ catch (error) {
213
+ this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
214
+ }
215
+ }
216
+ dispatchDiff() {
217
+ try {
218
+ const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
219
+ if (!existsSync(artifactsRoot)) {
220
+ this.appendSystemLine('No pending diffs (.pugi/artifacts/ not found).');
221
+ return;
222
+ }
223
+ const subdirs = readdirSync(artifactsRoot, { withFileTypes: true })
224
+ .filter((d) => d.isDirectory())
225
+ .map((d) => d.name);
226
+ const diffs = [];
227
+ for (const name of subdirs) {
228
+ const candidate = resolvePath(artifactsRoot, name, 'diff.patch');
229
+ if (existsSync(candidate)) {
230
+ const size = statSync(candidate).size;
231
+ diffs.push(` ${name}/diff.patch (${size} bytes)`);
232
+ }
233
+ }
234
+ if (diffs.length === 0) {
235
+ this.appendSystemLine('No pending diffs.');
236
+ return;
237
+ }
238
+ this.appendSystemLine(`Pending diffs (${diffs.length}):`);
239
+ for (const line of diffs)
240
+ this.appendSystemLine(line);
241
+ }
242
+ catch (error) {
243
+ this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
244
+ }
245
+ }
246
+ dispatchCost() {
247
+ const { tokensDownstreamTotal, agents } = this.state;
248
+ const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
249
+ const lineTokens = `Tokens this session: ${tokensDownstreamTotal.toLocaleString()} (in+out).`;
250
+ const lineAgents = `Active dispatches: ${active} of cap.`;
251
+ this.appendSystemLine(lineTokens);
252
+ this.appendSystemLine(lineAgents);
253
+ this.appendSystemLine('Full per-persona budget breakdown lands in α6.5.');
254
+ }
255
+ dispatchStatus() {
256
+ const sessionId = this.state.sessionId ?? '(unbound)';
257
+ const reach = this.state.connection;
258
+ this.appendSystemLine(`Backend: ${this.options.apiUrl} (${reach}).`);
259
+ this.appendSystemLine(`Session: ${sessionId}.`);
260
+ this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
261
+ this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
262
+ }
263
+ /**
264
+ * Fetch one URL via the web_fetch tool and inject the resulting
265
+ * Markdown into the transcript as an operator-attributed brief. The
266
+ * `<untrusted-content>` sentinel travels with the body so the Mira
267
+ * system prompt can refuse to follow instructions inside it.
268
+ *
269
+ * Gating: the dispatcher reads PugiSettings from disk on every
270
+ * call so the operator can flip `web.fetch.enabled` mid-session
271
+ * without restarting the REPL. The CLI's bare `--allow-fetch` flag
272
+ * is honored by the runtime entry point and propagates through
273
+ * env to keep the session module transport-free.
274
+ */
275
+ async dispatchWebFetch(url) {
276
+ // Malformed `.pugi/settings.json` must not crash the REPL —
277
+ // surface the error to the operator and treat fetch as disabled
278
+ // (fail-safe). The session keeps running.
279
+ let settings;
280
+ try {
281
+ settings = loadSettings(process.cwd());
282
+ }
283
+ catch (error) {
284
+ const msg = error instanceof Error ? error.message : String(error);
285
+ this.appendSystemLine(`web_fetch refused: failed to load .pugi/settings.json (${msg}).`);
286
+ return;
287
+ }
288
+ const allowFetch = (this.options.env ?? process.env).PUGI_ALLOW_FETCH === '1';
289
+ const result = await webFetchTool({ url }, { settings, allowFetch });
290
+ if (!result.ok) {
291
+ this.appendSystemLine(`web_fetch refused: ${result.error}`);
292
+ return;
293
+ }
294
+ this.appendOperatorLine(`/web ${result.url}`);
295
+ this.appendSystemLine(`Fetched ${result.title} (${result.fetched_at}).`);
296
+ // Surface the Markdown body so the operator sees what landed in
297
+ // the brief; downstream the body is the actual dispatch payload.
298
+ this.appendSystemLine(result.content_md);
299
+ await this.dispatchBrief(`Brief from fetched page:\n\n${result.content_md}`);
300
+ }
138
301
  /* ------------- dispatch -------------- */
139
302
  async dispatchBrief(brief) {
140
303
  const sessionId = this.state.sessionId;
@@ -263,6 +426,12 @@ export class ReplSession {
263
426
  return;
264
427
  }
265
428
  case 'agent.step': {
429
+ // Cache the running detail per task so we can surface the
430
+ // model's actual reply when agent.completed lands (otherwise
431
+ // the reply disappears under the literal 'shipped' patch).
432
+ if (event.detail && event.detail.trim().length > 0) {
433
+ this.lastStepDetail.set(event.taskId, event.detail);
434
+ }
266
435
  this.patch({
267
436
  agents: this.state.agents.map((a) => a.taskId === event.taskId
268
437
  ? { ...a, status: 'thinking', detail: event.detail }
@@ -286,18 +455,41 @@ export class ReplSession {
286
455
  }
287
456
  case 'agent.completed': {
288
457
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
458
+ const finalDetail = this.lastStepDetail.get(event.taskId);
459
+ this.lastStepDetail.delete(event.taskId);
289
460
  this.patch({
290
461
  agents: this.state.agents.map((a) => a.taskId === event.taskId
291
462
  ? { ...a, status: 'shipped', detail: 'shipped' }
292
463
  : a),
293
464
  });
294
465
  if (target) {
295
- this.appendPersonaLine(target.personaSlug, 'shipped.');
466
+ // If the persona actually produced a reply via incremental
467
+ // agent.step events, render that reply in the transcript so
468
+ // the operator sees the full answer. The threshold (>4 chars
469
+ // + not the queued placeholder) filters out the no-op
470
+ // "shipped"/"queued for dispatch" placeholders the dispatcher
471
+ // emits before the model speaks. Multi-line replies are
472
+ // emitted line by line so the conversation pane wraps each
473
+ // sentence on its own row.
474
+ if (finalDetail
475
+ && finalDetail !== 'queued for dispatch'
476
+ && finalDetail.trim().length > 4) {
477
+ for (const line of finalDetail.split('\n')) {
478
+ const trimmed = line.trim();
479
+ if (trimmed.length > 0) {
480
+ this.appendPersonaLine(target.personaSlug, trimmed);
481
+ }
482
+ }
483
+ }
484
+ else {
485
+ this.appendPersonaLine(target.personaSlug, 'shipped.');
486
+ }
296
487
  }
297
488
  return;
298
489
  }
299
490
  case 'agent.blocked': {
300
491
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
492
+ this.lastStepDetail.delete(event.taskId);
301
493
  this.patch({
302
494
  agents: this.state.agents.map((a) => a.taskId === event.taskId
303
495
  ? { ...a, status: 'blocked', detail: event.detail }
@@ -310,6 +502,7 @@ export class ReplSession {
310
502
  }
311
503
  case 'agent.failed': {
312
504
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
505
+ this.lastStepDetail.delete(event.taskId);
313
506
  this.patch({
314
507
  agents: this.state.agents.map((a) => a.taskId === event.taskId
315
508
  ? { ...a, status: 'failed', detail: event.error }