buildline 0.7.0 → 0.8.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
@@ -134,10 +134,10 @@ Start at 2. Raise it as your gates and acceptance scenarios earn trust. [More on
134
134
 
135
135
  <img alt="buildline watch: features on tracks through spec, design, plan, build, verify, review and ship, with an inspector and live event log" src="docs/assets/watch.png">
136
136
 
137
- - a **comet** on the track means a Claude Code session is working on it right now, with its latest step; **NEEDS YOU** and **BLOCKED** mean an agent is waiting on your answer
137
+ - a **comet** on the track means a Claude Code session is working on it right now, with its latest step; **NEEDS YOU** and **BLOCKED** mean an agent is waiting on your answer, permission or reply; **BACKGROUND** means it's waiting on something else, such as CI or a background agent, and will carry on by itself
138
138
  - run several sessions at once, one per feature: each gets its own track
139
139
  - the model under each station is the one that runs it
140
- - `↑↓` to inspect tasks and history, `⏎` to open the station on its model, `a` to approve, `q` to quit
140
+ - `↑↓` to inspect tasks and history, `⏎` to open the station on its model, `a` to approve, `e` to hide the event log, `q` to quit
141
141
  - a **chime** when a feature starts waiting on you or ships, so you can look away; `m` mutes it, `npx buildline sound off` turns it off for good (the dashboard chimes too)
142
142
 
143
143
  ## The dashboard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buildline",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Install an AI software factory into any project. A spec-driven pipeline for Claude Code (and any AGENTS.md-compatible agent), for developers, designers and product managers.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/features.js CHANGED
@@ -181,8 +181,11 @@ export function readSessions(root, at = Date.now()) {
181
181
  try {
182
182
  const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
183
183
  const age = at - new Date(s.lastAt);
184
- if (s.state === 'working' && age < WORKING_FOR) out.push({ ...s, age });
185
- else if (s.state === 'waiting' && age < WAITING_FOR) out.push({ ...s, age });
184
+ // How long it has been in this state, and since the session started.
185
+ const timing = { for: at - new Date(s.since || s.lastAt), total: at - new Date(s.startedAt || s.lastAt) };
186
+ // A long command (tests, a deploy watch) keeps a session working with no new events.
187
+ if (s.state === 'working' && age < (s.inTool ? WAITING_FOR : WORKING_FOR)) out.push({ ...s, age, ...timing });
188
+ else if ((s.state === 'waiting' || s.state === 'background') && age < WAITING_FOR) out.push({ ...s, age, ...timing });
186
189
  } catch {}
187
190
  }
188
191
  return Object.assign(out.sort((a, b) => a.age - b.age), { tracked: true });
@@ -205,18 +208,24 @@ export function readActivity(root, limit = 200) {
205
208
  }
206
209
 
207
210
  /**
208
- * What's happening on a feature right now: live sessions on it, whether one is
209
- * working or waiting for a reply. Where no hooks report (agents other than
211
+ * What's happening on a feature right now: live sessions on it, and whether one
212
+ * waits on a person (that wins), one is working, or one finished its turn with
213
+ * work still running in the background (a CI watch, a background agent). Where no hooks report (agents other than
210
214
  * Claude Code), a feature whose files changed in the last two minutes counts as worked on.
211
215
  */
212
216
  export function liveState(feature, sessions, at = Date.now()) {
213
217
  const mine = sessions.filter((s) => s.feature === feature.id);
214
218
  const working = mine.filter((s) => s.state === 'working');
215
219
  const recent = at - new Date(feature.updated || 0) < 2 * 60e3;
220
+ const waiting = mine.find((s) => s.state === 'waiting');
216
221
  return {
217
222
  sessions: mine,
218
223
  working: working.length > 0 || (!sessions.tracked && recent && !feature.blocked && feature.status !== 'done'),
219
- waiting: !working.length && mine.some((s) => s.state === 'waiting'),
224
+ waiting: Boolean(waiting),
225
+ // why: 'reply' | 'question' | 'permission', and what the agent said or asked
226
+ waitingFor: waiting ? { why: waiting.waitingFor || 'reply', said: waiting.said || null, for: waiting.for } : null,
227
+ background: !working.length && !waiting ? mine.filter((s) => s.state === 'background').flatMap((s) => s.background || []) : [],
228
+ backgroundFor: Math.max(0, ...mine.filter((s) => s.state === 'background').map((s) => s.for || 0)),
220
229
  };
221
230
  }
222
231
 
package/src/install.js CHANGED
@@ -182,6 +182,9 @@ export function mergeSettings(root, config) {
182
182
  ensureHook('SessionStart', null, 'session-start');
183
183
  ensureHook('PreToolUse', null, 'pre');
184
184
  ensureHook('UserPromptSubmit', null, 'prompt');
185
+ ensureHook('PostToolUse', null, 'post');
186
+ // Claude asked for permission or input mid-turn: the session waits on a person.
187
+ ensureHook('Notification', 'permission_prompt|elicitation_dialog', 'notify');
185
188
  ensureHook('Stop', null, 'stop');
186
189
  ensureHook('SessionEnd', null, 'session-end');
187
190
 
package/src/tui.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // `buildline watch`: a live, full-screen view of the line in the terminal.
2
2
  // Every feature is a track running through the stations; the dot shows where it
3
- // is, a moving comet means an agent can work on it, a blinking dot means it waits
3
+ // is, a moving comet means a session is working on it, a turning clock means work
4
+ // runs in the background (CI, a background agent), a blinking dot means it waits
4
5
  // on a person. Zero dependencies beyond picocolors: raw ANSI on a cell canvas.
5
6
  import fs from 'node:fs';
6
7
  import os from 'node:os';
@@ -16,6 +17,8 @@ const TRACK = ['spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
16
17
  const LOGO = ['┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐', '├┴┐│ │││ ││││ ││││├┤ ', '└─┘└─┘┴┴─┘─┴┘┴─┘┴┘└┘└─┘'];
17
18
  const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
18
19
  const SPARK = ' ▁▂▃▄▅▆▇█';
20
+ const CLOCK = '◴◷◶◵';
21
+ const WAITS = { reply: 'waiting for your reply', question: 'asking you a question', permission: 'waiting for your permission' };
19
22
 
20
23
  // ---- colour ------------------------------------------------------------------
21
24
 
@@ -123,16 +126,17 @@ const shortModel = (config, station) => {
123
126
  const m = modelFor(config, station);
124
127
  return m.cli === 'claude' && !m.model ? 'session' : describeModel(m).replace(/^claude-/, '');
125
128
  };
126
- const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : `${Math.round(ms / 3600e3)}h`);
129
+ const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.floor(ms / 60e3)}m` : `${Math.floor(ms / 3600e3)}h${Math.floor(ms / 60e3) % 60 ? `${Math.floor(ms / 60e3) % 60}m` : ''}`);
127
130
 
128
131
  /** The NEXT UP cell: who or what the feature is waiting on right now. */
129
132
  function rowStatus(f, t) {
130
133
  if (f.blocked) return ['BLOCKED', C.red];
134
+ if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
131
135
  if (f.live.working) {
132
136
  const n = f.live.sessions.filter((x) => x.state === 'working').length;
133
137
  return [`${SPINNER[t % SPINNER.length]} ${n > 1 ? `×${n} ` : ''}WORKING`, C.amberBold];
134
138
  }
135
- if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
139
+ if (f.live.background.length) return [`${CLOCK[Math.floor(t / 4) % CLOCK.length]} BACKGROUND`, C.cyan];
136
140
  if (f.next.who === 'agent') return ['AGENT', C.amberDim];
137
141
  const who = WHO[f.next.who];
138
142
  return who ? [who.label, who.color] : ['', null];
@@ -166,7 +170,8 @@ function drawTrack(line, x, f, seg, t) {
166
170
  const who = WHO[f.next.who];
167
171
  const skipped = (i) => f.type === 'fix' && i < 3 && cur >= 3;
168
172
  // The comet only runs while a session is actually working on this feature.
169
- const working = f.live?.working && !f.blocked;
173
+ const working = f.live?.working && !f.blocked && !f.live.waiting;
174
+ const background = f.live?.background?.length && !f.blocked && !f.live.waiting;
170
175
  const idleAgent = f.next.who === 'agent' && !f.blocked && !f.live?.waiting;
171
176
  for (let i = 0; i < TRACK.length; i++) {
172
177
  const nx = x + i * seg;
@@ -176,6 +181,7 @@ function drawTrack(line, x, f, seg, t) {
176
181
  else if (i === cur && f.blocked) node = ['✖', t % 10 < 5 ? C.red : C.faint];
177
182
  else if (i === cur && working) node = [['◉', '◎', '◉', '●'][Math.floor(t / 3) % 4], C.amberBold];
178
183
  else if (i === cur && f.live?.waiting) node = ['◉', t % 12 < 8 ? C.red : C.faint];
184
+ else if (i === cur && background) node = [CLOCK[Math.floor(t / 4) % CLOCK.length], C.cyan];
179
185
  else if (i === cur && idleAgent) node = ['◉', C.amberDim];
180
186
  else if (i === cur) node = ['◉', t % 12 < 8 ? who?.color || C.amber : C.faint];
181
187
  line.put(nx, node[0], node[1]);
@@ -208,8 +214,9 @@ function render(st, ui, W, H) {
208
214
  const active = st.all.filter((f) => f.status !== 'done');
209
215
  const shipped = st.all.length - active.length;
210
216
  const tasks = active.flatMap((f) => f.tasks || []);
211
- const waiting = features.filter((f) => f.status !== 'done' && !f.live.working && (f.live.waiting || ['pm', 'designer', 'eng', 'human'].includes(f.next.who))).length;
212
- const sessionsWorking = st.sessions.filter((x) => x.state === 'working').length;
217
+ const busy = (f) => f.live.working || f.live.background.length;
218
+ const waiting = features.filter((f) => f.status !== 'done' && (f.live.waiting || (!busy(f) && ['pm', 'designer', 'eng', 'human'].includes(f.next.who)))).length;
219
+ const sessionsWorking = st.sessions.filter((x) => x.state !== 'waiting').length;
213
220
 
214
221
  // header
215
222
  const tall = W >= 96 && H >= 26;
@@ -300,13 +307,13 @@ function render(st, ui, W, H) {
300
307
  const by = top + lineH;
301
308
  const bh = H - 1 - by;
302
309
  if (bh >= 5) {
303
- const split = W >= 100 ? Math.floor(W * 0.52) : W;
310
+ const split = W >= 100 && ui.events !== false ? Math.floor(W * 0.52) : W;
304
311
  drawInspector(screen, st, sel, 0, by, split, bh);
305
312
  if (split < W) drawEvents(screen, st, ui, split, by, W - split, bh);
306
313
  }
307
314
 
308
315
  // footer
309
- const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['m', ui.sound ? 'mute' : 'unmute'], ['q', 'quit']];
316
+ const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['e', ui.events === false ? 'events' : 'hide events'], ['m', ui.sound ? 'mute' : 'unmute'], ['q', 'quit']];
310
317
  let fx = 1;
311
318
  const foot = screen[H - 1];
312
319
  if (ui.message && ui.messageUntil > Date.now()) fx = foot.put(1, ` ${ui.message} `, C.flash);
@@ -344,15 +351,23 @@ function drawInspector(screen, st, f, x, y, w, h) {
344
351
  lines.slice(0, 4).forEach((l, i) => add([i ? ' ' : '✖ blocked ', C.red], [l, C.bold]));
345
352
  }
346
353
  for (const ses of f.live?.sessions || []) {
347
- const on = ses.state === 'working';
348
- add(
349
- [on ? '● ' : '◌ ', on ? C.amberBold : C.red],
350
- [`session ${String(ses.id).slice(0, 8)} `, C.muted],
351
- [`${(ses.model ? String(ses.model) : shortModel(config, f.status)).replace(/^claude-/, '')} `, C.text],
352
- [on ? `${ses.tool || 'thinking'} ` : 'waiting for your reply ', on ? C.cyan : C.red],
353
- [on ? `${ses.detail || ''} ` : '', C.text],
354
- [`· ${ago(ses.age)} ago`, C.faint],
355
- );
354
+ // "● c41c8a97 opus-5-5 working 4m · up 1h12m", then what it's doing or waiting for.
355
+ const model = (ses.model ? String(ses.model) : shortModel(config, f.status)).replace(/^claude-/, '');
356
+ const up = ses.total - (ses.for ?? 0) > 60e3 ? [` · up ${ago(ses.total)}`, C.faint] : ['', null];
357
+ const head = (icon, iconColor, label, color) => add([icon + ' ', iconColor], [`${String(ses.id).slice(0, 8)} `, C.muted], [`${model} `, C.text], [`${label} ${ago(ses.for ?? ses.age)}`, color], up);
358
+ const sub = (text, color) => text && add([' ', null], [text, color]);
359
+ if (ses.state === 'working') {
360
+ head('●', C.amberBold, 'working', C.amberBold);
361
+ sub(`${ses.tool || 'thinking'} ${ses.detail || ''}`, C.cyan);
362
+ } else if (ses.state === 'background') {
363
+ head(CLOCK[Math.floor(Date.now() / 440) % CLOCK.length], C.cyan, 'background', C.cyan);
364
+ for (const job of (ses.background || []).slice(0, 3)) sub(job, C.text);
365
+ sub(ses.said, C.muted);
366
+ } else {
367
+ head('◌', C.red, 'needs you', C.red);
368
+ sub(WAITS[ses.waitingFor] || WAITS.reply, C.red);
369
+ sub(ses.said, C.bold);
370
+ }
356
371
  }
357
372
  const ts = f.tasks || [];
358
373
  if (ts.length) {
@@ -502,6 +517,7 @@ export async function watch(root, opts = {}) {
502
517
  if (k === '\x1b[A' || k === 'k') ui.sel = Math.max(0, ui.sel - 1);
503
518
  else if (k === '\x1b[B' || k === 'j') ui.sel = Math.min(st.features.length - 1, ui.sel + 1);
504
519
  else if (k === 's') (ui.shipped = !ui.shipped), refresh();
520
+ else if (k === 'e') (ui.events = ui.events === false), out.write('\x1b[2J');
505
521
  else if (k === 'm') (ui.sound = !ui.sound), say(ui.sound ? '♪ sound on' : 'sound muted until you quit');
506
522
  else if (k === 'u') return done({ action: 'ui' });
507
523
  else if ((k === '\r' || k === '\n') && f) {
package/src/ui/index.html CHANGED
@@ -78,6 +78,8 @@ main { padding: 16px 20px 40px; }
78
78
  .live .dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); align-self: center; animation: pulse 1.2s ease-in-out infinite; }
79
79
  .live.waiting .dot, .live.blocked .dot { background: var(--red); }
80
80
  .live.blocked .dot { animation: none; }
81
+ .live.background b { color: var(--blue); }
82
+ .live.background .dot { background: var(--blue); animation-duration: 2.4s; }
81
83
  .live code { font-size: 11px; background: none; padding: 0; }
82
84
  .card.is-working { border-color: var(--accent); }
83
85
  @keyframes pulse { 50% { opacity: .25; transform: scale(.7); } }
@@ -242,21 +244,25 @@ function card(f) {
242
244
  </button>`;
243
245
  }
244
246
  const stationModel = (station) => { const m = S.config.models || {}; const v = m[station] ?? m.default; return v && v !== 'inherit' ? v : ''; };
245
- const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : `${Math.round(ms / 3600e3)}h`);
246
- /** What is happening on a feature right now: a blocker, a working session, or one waiting for a reply. */
247
+ const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.floor(ms / 60e3)}m` : `${Math.floor(ms / 3600e3)}h${Math.floor(ms / 60e3) % 60 ? `${Math.floor(ms / 60e3) % 60}m` : ''}`);
248
+ const WAITS = { reply: 'Waiting for your reply', question: 'Asking you', permission: 'Needs your permission' };
249
+ /** What is happening on a feature right now: a blocker, a session waiting on you, one working, or work left running in the background. */
247
250
  function liveLine(f) {
248
251
  if (f.blocked) return `<div class="live blocked"><i class="dot"></i><span class="wrap"><b>Blocked:</b> ${esc(f.blocked)}</span></div>`;
252
+ const w = f.live?.waitingFor;
253
+ if (w) return `<div class="live waiting" title="${esc(w.said || '')}"><i class="dot"></i><span><b>${WAITS[w.why] || WAITS.reply}</b>${w.for != null ? ` · ${ago(w.for)}` : ''}${w.said ? `<span class="wrap">${esc(w.said)}</span>` : ' in a Claude session'}</span></div>`;
249
254
  const ses = f.live?.sessions || [];
250
255
  const on = ses.filter((x) => x.state === 'working');
251
256
  if (f.live?.working) {
252
257
  const s = on[0];
253
258
  if (!s) return `<div class="live working"><i class="dot"></i><span><b>Working</b> · files changing</span></div>`;
254
259
  const model = String(s.model || stationModel(f.status) || '').replace(/^claude-/, '');
255
- const head = [`<b>Working${on.length > 1 ? ` ×${on.length}` : ''}</b>`, ago(s.age), model && esc(model)].filter(Boolean).join(' · ');
260
+ const head = [`<b>Working${on.length > 1 ? ` ×${on.length}` : ''}</b>`, ago(s.for ?? s.age), model && esc(model)].filter(Boolean).join(' · ');
256
261
  const doing = s.tool ? `${esc(s.tool)} <code>${esc(s.detail || '')}</code>` : 'thinking';
257
- return `<div class="live working" title="${esc([s.tool, s.detail].filter(Boolean).join(': '))}"><i class="dot"></i><span><span class="line">${head}</span><span class="line">${doing}</span></span></div>`;
262
+ return `<div class="live working" title="${esc([[s.tool, s.detail].filter(Boolean).join(': '), s.total != null && `session running ${ago(s.total)}`].filter(Boolean).join('\n'))}"><i class="dot"></i><span><span class="line">${head}</span><span class="line">${doing}</span></span></div>`;
258
263
  }
259
- if (f.live?.waiting) return `<div class="live waiting"><i class="dot"></i><span><b>Waiting for your reply</b> in a Claude session</span></div>`;
264
+ const bg = f.live?.background || [];
265
+ if (bg.length) return `<div class="live background" title="${esc(bg.join('\n'))}"><i class="dot"></i><span><b>In the background</b> · ${ago(f.live.backgroundFor)}${bg.length > 1 ? ` · ${bg.length} jobs` : ''}<span class="line"><code>${esc(bg[0])}</code></span></span></div>`;
260
266
  return '';
261
267
  }
262
268
  function renderBoard() {
@@ -6,10 +6,11 @@
6
6
  // guard blocks edits to a spec's "## Acceptance" section while the
7
7
  // feature is in build/verify/review, so agents can't move the goalposts,
8
8
  // and hand edits to feature.json (agents use .buildline/bin/feature.mjs)
9
- // prompt / stop / session-end
10
- // heartbeat: which session works on which feature, so
11
- // `buildline watch` and the dashboard can show live work.
12
- // Written to .buildline/sessions/ (git-ignored).
9
+ // prompt / post / notify / stop / session-end
10
+ // heartbeat: which session works on which feature and whether
11
+ // it is working, running something in the background, or
12
+ // waiting for a person, so `buildline watch` and the dashboard
13
+ // can show live work. Written to .buildline/sessions/ (git-ignored).
13
14
  import fs from 'node:fs';
14
15
  import path from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
@@ -106,6 +107,12 @@ function featureRef(text) {
106
107
  return m ? m[1].padStart(3, '0') : null;
107
108
  }
108
109
 
110
+ /** A prompt in a line. Background tasks finishing arrive as <task-notification> prompts: show their summary. */
111
+ function promptDetail(prompt) {
112
+ const done = /^\s*<task-notification>[\s\S]*?<summary>([\s\S]*?)<\/summary>/.exec(prompt);
113
+ return done ? oneLine(done[1], 90) : oneLine(prompt, 60);
114
+ }
115
+
109
116
  function detail(input) {
110
117
  const ti = input.tool_input || {};
111
118
  if (ti.file_path) return path.basename(ti.file_path);
@@ -119,6 +126,56 @@ function detail(input) {
119
126
  return '';
120
127
  }
121
128
 
129
+ // Tools that stop the turn to ask the person something.
130
+ const ASKS = ['AskUserQuestion', 'ExitPlanMode'];
131
+
132
+ const oneLine = (s, n) => {
133
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
134
+ return t.length > n ? t.slice(0, n - 1) + '…' : t;
135
+ };
136
+
137
+ /** What a session left running when its turn ended: background shells, subagents, scheduled checks. */
138
+ function stillRunning(input) {
139
+ const tasks = (Array.isArray(input.background_tasks) ? input.background_tasks : [])
140
+ .filter((t) => !t.status || t.status === 'running')
141
+ .map((t) => (t.type === 'shell' ? detail({ tool_input: { command: t.command || t.description } }) : `agent: ${oneLine(t.description, 40)}`));
142
+ const crons = (Array.isArray(input.session_crons) ? input.session_crons : []).map((c) => `scheduled: ${oneLine(c.prompt || c.cron || c.schedule || 'check', 36)}`);
143
+ return [...tasks, ...crons];
144
+ }
145
+
146
+ /** The end of the agent's last message (its last paragraph, or last sentence when that's long): what it's waiting for. */
147
+ function lastWords(message) {
148
+ const para = oneLine(String(message || '').trim().split(/\n\s*\n/).pop(), 1000);
149
+ if (para.length <= 90) return para || null;
150
+ return oneLine(/[^.!?]+[.!?]*\s*$/.exec(para)?.[0] || para, 140);
151
+ }
152
+
153
+ /** The last thing the agent said ends with a question: it wants an answer even if work runs on. */
154
+ const asks = (message) => /\?\s*$/.test(String(message || '').replace(/[`*_)\]\s]+$/, ''));
155
+
156
+ const quietParent = (prev) => prev.state === 'background' || prev.state === 'waiting';
157
+
158
+ /** What a session is doing after this event: working, background (turn over, tasks still running) or waiting on a person. */
159
+ function nextState(prev, input, event) {
160
+ const tool = input.tool_name;
161
+ const fromSubagent = Boolean(input.agent_id);
162
+ if (event === 'stop') {
163
+ const running = stillRunning(input);
164
+ const said = lastWords(input.last_assistant_message);
165
+ if (running.length && !asks(input.last_assistant_message)) return { state: 'background', waitingFor: null, background: running, said };
166
+ return { state: 'waiting', waitingFor: 'reply', background: running, said };
167
+ }
168
+ // A background subagent working on its own doesn't make the session busy again,
169
+ // nor does it answer a question the session is waiting on.
170
+ if (fromSubagent && quietParent(prev)) return {};
171
+ if (event === 'notify') return { state: 'waiting', waitingFor: prev.waitingFor === 'question' ? 'question' : 'permission', said: prev.waitingFor === 'question' ? prev.said : oneLine(input.message, 140) };
172
+ if (event === 'tool' && ASKS.includes(tool)) {
173
+ const q = input.tool_input?.questions?.[0]?.question || (tool === 'ExitPlanMode' ? 'Approve the plan?' : 'A question');
174
+ return { state: 'waiting', waitingFor: 'question', said: oneLine(q, 140), inTool: true };
175
+ }
176
+ return { state: 'working', waitingFor: null, background: [], said: null, inTool: event === 'tool' };
177
+ }
178
+
122
179
  function heartbeat(input, event) {
123
180
  const id = String(input.session_id || '').replace(/[^\w-]/g, '');
124
181
  if (!id) return;
@@ -139,11 +196,13 @@ function heartbeat(input, event) {
139
196
  feature,
140
197
  station: station || prev.station || null,
141
198
  model: input.model?.id || input.model || prev.model || null,
142
- state: event === 'stop' ? 'waiting' : 'working',
199
+ ...nextState(prev, input, event),
143
200
  lastAt: at,
144
- ...(event === 'tool' ? { tool: input.tool_name, detail: detail(input) } : {}),
145
- ...(event === 'prompt' ? { tool: null, detail: prompt.replace(/\s+/g, ' ').slice(0, 60) } : {}),
201
+ ...(event === 'tool' && !(input.agent_id && quietParent(prev)) ? { tool: input.tool_name, detail: detail(input) } : {}),
202
+ ...(event === 'prompt' ? { tool: null, detail: promptDetail(prompt) } : {}),
146
203
  };
204
+ // When the session entered its current state, so the views can say "working 4m", "waiting 12m".
205
+ next.since = next.state === prev.state && prev.since ? prev.since : at;
147
206
  fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
148
207
  if (event === 'tool' && feature) {
149
208
  // A short live activity feed for the views; trimmed so it never grows large.
@@ -165,6 +224,8 @@ process.stdin.on('end', () => {
165
224
  else if (mode === 'guard') guard(input);
166
225
  else if (mode === 'pre') heartbeat(input, 'tool'), guard(input);
167
226
  else if (mode === 'prompt') heartbeat(input, 'prompt');
227
+ else if (mode === 'post') heartbeat(input, 'post');
228
+ else if (mode === 'notify') heartbeat(input, 'notify');
168
229
  else if (mode === 'stop') heartbeat(input, 'stop');
169
230
  else if (mode === 'session-end') heartbeat(input, 'end');
170
231
  } catch (err) {