buildline 0.7.0 → 0.8.1

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.1",
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,25 @@ 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
- const mine = sessions.filter((s) => s.feature === feature.id);
217
+ // A finished feature has nothing live, even if an old session is still tied to it.
218
+ const mine = feature.status === 'done' ? [] : sessions.filter((s) => s.feature === feature.id);
214
219
  const working = mine.filter((s) => s.state === 'working');
215
220
  const recent = at - new Date(feature.updated || 0) < 2 * 60e3;
221
+ const waiting = mine.find((s) => s.state === 'waiting');
216
222
  return {
217
223
  sessions: mine,
218
224
  working: working.length > 0 || (!sessions.tracked && recent && !feature.blocked && feature.status !== 'done'),
219
- waiting: !working.length && mine.some((s) => s.state === 'waiting'),
225
+ waiting: Boolean(waiting),
226
+ // why: 'reply' | 'question' | 'permission', and what the agent said or asked
227
+ waitingFor: waiting ? { why: waiting.waitingFor || 'reply', said: waiting.said || null, for: waiting.for } : null,
228
+ background: !working.length && !waiting ? mine.filter((s) => s.state === 'background').flatMap((s) => s.background || []) : [],
229
+ backgroundFor: Math.max(0, ...mine.filter((s) => s.state === 'background').map((s) => s.for || 0)),
220
230
  };
221
231
  }
222
232
 
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,23 @@ 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
+
116
+ /** A feature.mjs command that changes a feature's state, e.g. "feature.mjs task 3 T1 pass". */
117
+ function actedOn(command) {
118
+ const m = /feature\.mjs\s+(?:move|task|log|block|unblock|approve)\s+(\d{1,4})\b/.exec(String(command || ''));
119
+ return m ? m[1].padStart(3, '0') : null;
120
+ }
121
+
122
+ function isDone(id) {
123
+ const dir = fs.existsSync(featuresDir) ? fs.readdirSync(featuresDir).find((d) => d.startsWith(`${id}-`)) : null;
124
+ return dir ? readJson(path.join(featuresDir, dir, 'feature.json'))?.status === 'done' : false;
125
+ }
126
+
109
127
  function detail(input) {
110
128
  const ti = input.tool_input || {};
111
129
  if (ti.file_path) return path.basename(ti.file_path);
@@ -119,6 +137,56 @@ function detail(input) {
119
137
  return '';
120
138
  }
121
139
 
140
+ // Tools that stop the turn to ask the person something.
141
+ const ASKS = ['AskUserQuestion', 'ExitPlanMode'];
142
+
143
+ const oneLine = (s, n) => {
144
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
145
+ return t.length > n ? t.slice(0, n - 1) + '…' : t;
146
+ };
147
+
148
+ /** What a session left running when its turn ended: background shells, subagents, scheduled checks. */
149
+ function stillRunning(input) {
150
+ const tasks = (Array.isArray(input.background_tasks) ? input.background_tasks : [])
151
+ .filter((t) => !t.status || t.status === 'running')
152
+ .map((t) => (t.type === 'shell' ? detail({ tool_input: { command: t.command || t.description } }) : `agent: ${oneLine(t.description, 40)}`));
153
+ const crons = (Array.isArray(input.session_crons) ? input.session_crons : []).map((c) => `scheduled: ${oneLine(c.prompt || c.cron || c.schedule || 'check', 36)}`);
154
+ return [...tasks, ...crons];
155
+ }
156
+
157
+ /** The end of the agent's last message (its last paragraph, or last sentence when that's long): what it's waiting for. */
158
+ function lastWords(message) {
159
+ const para = oneLine(String(message || '').trim().split(/\n\s*\n/).pop(), 1000);
160
+ if (para.length <= 90) return para || null;
161
+ return oneLine(/[^.!?]+[.!?]*\s*$/.exec(para)?.[0] || para, 140);
162
+ }
163
+
164
+ /** The last thing the agent said ends with a question: it wants an answer even if work runs on. */
165
+ const asks = (message) => /\?\s*$/.test(String(message || '').replace(/[`*_)\]\s]+$/, ''));
166
+
167
+ const quietParent = (prev) => prev.state === 'background' || prev.state === 'waiting';
168
+
169
+ /** What a session is doing after this event: working, background (turn over, tasks still running) or waiting on a person. */
170
+ function nextState(prev, input, event) {
171
+ const tool = input.tool_name;
172
+ const fromSubagent = Boolean(input.agent_id);
173
+ if (event === 'stop') {
174
+ const running = stillRunning(input);
175
+ const said = lastWords(input.last_assistant_message);
176
+ if (running.length && !asks(input.last_assistant_message)) return { state: 'background', waitingFor: null, background: running, said };
177
+ return { state: 'waiting', waitingFor: 'reply', background: running, said };
178
+ }
179
+ // A background subagent working on its own doesn't make the session busy again,
180
+ // nor does it answer a question the session is waiting on.
181
+ if (fromSubagent && quietParent(prev)) return {};
182
+ if (event === 'notify') return { state: 'waiting', waitingFor: prev.waitingFor === 'question' ? 'question' : 'permission', said: prev.waitingFor === 'question' ? prev.said : oneLine(input.message, 140) };
183
+ if (event === 'tool' && ASKS.includes(tool)) {
184
+ const q = input.tool_input?.questions?.[0]?.question || (tool === 'ExitPlanMode' ? 'Approve the plan?' : 'A question');
185
+ return { state: 'waiting', waitingFor: 'question', said: oneLine(q, 140), inTool: true };
186
+ }
187
+ return { state: 'working', waitingFor: null, background: [], said: null, inTool: event === 'tool' };
188
+ }
189
+
122
190
  function heartbeat(input, event) {
123
191
  const id = String(input.session_id || '').replace(/[^\w-]/g, '');
124
192
  if (!id) return;
@@ -132,18 +200,23 @@ function heartbeat(input, event) {
132
200
  const prompt = input.prompt || '';
133
201
  const station = /\/buildline-([a-z]+)/.exec(prompt)?.[1];
134
202
  const ti = input.tool_input || {};
135
- // A session stays on the feature it was started for; reading another feature's files doesn't move it.
136
- const feature = featureRef(prompt) || prev.feature || featureRef(ti.file_path) || featureRef(ti.command) || featureRef(ti.prompt) || null;
203
+ // A session stays on the feature it was started for; reading another feature's files doesn't move it,
204
+ // recording progress on one does. It lets go once its feature is done or the conversation is cleared,
205
+ // so a reopened session doesn't keep flagging a finished feature.
206
+ const bound = prev.feature && !isDone(prev.feature) && !(event === 'start' && input.source === 'clear') ? prev.feature : null;
207
+ const feature = featureRef(prompt) || actedOn(ti.command) || bound || featureRef(ti.file_path) || featureRef(ti.command) || featureRef(ti.prompt) || null;
137
208
  const next = {
138
209
  ...prev,
139
210
  feature,
140
- station: station || prev.station || null,
211
+ station: station || (feature === prev.feature ? prev.station : null) || null,
141
212
  model: input.model?.id || input.model || prev.model || null,
142
- state: event === 'stop' ? 'waiting' : 'working',
213
+ ...nextState(prev, input, event),
143
214
  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) } : {}),
215
+ ...(event === 'tool' && !(input.agent_id && quietParent(prev)) ? { tool: input.tool_name, detail: detail(input) } : {}),
216
+ ...(event === 'prompt' ? { tool: null, detail: promptDetail(prompt) } : {}),
146
217
  };
218
+ // When the session entered its current state, so the views can say "working 4m", "waiting 12m".
219
+ next.since = next.state === prev.state && prev.since ? prev.since : at;
147
220
  fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
148
221
  if (event === 'tool' && feature) {
149
222
  // A short live activity feed for the views; trimmed so it never grows large.
@@ -165,6 +238,8 @@ process.stdin.on('end', () => {
165
238
  else if (mode === 'guard') guard(input);
166
239
  else if (mode === 'pre') heartbeat(input, 'tool'), guard(input);
167
240
  else if (mode === 'prompt') heartbeat(input, 'prompt');
241
+ else if (mode === 'post') heartbeat(input, 'post');
242
+ else if (mode === 'notify') heartbeat(input, 'notify');
168
243
  else if (mode === 'stop') heartbeat(input, 'stop');
169
244
  else if (mode === 'session-end') heartbeat(input, 'end');
170
245
  } catch (err) {