buildline 0.6.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 +4 -2
- package/bin/buildline.js +7 -3
- package/package.json +1 -1
- package/src/cli.js +14 -0
- package/src/features.js +26 -5
- package/src/install.js +3 -0
- package/src/server.js +3 -2
- package/src/sound.js +29 -0
- package/src/tui.js +42 -19
- package/src/ui/index.html +46 -7
- package/templates/bin/hook.mjs +68 -7
package/README.md
CHANGED
|
@@ -134,10 +134,11 @@ 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
|
+
- 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)
|
|
141
142
|
|
|
142
143
|
## The dashboard
|
|
143
144
|
|
|
@@ -196,6 +197,7 @@ npx buildline watch live view of the line in the terminal
|
|
|
196
197
|
npx buildline step <station> [id] open a station on its model --headless --model
|
|
197
198
|
npx buildline models choose models set <station|phase> <model> · check
|
|
198
199
|
npx buildline run <id> autopilot with Claude Code /goal --headless
|
|
200
|
+
npx buildline sound [on|off] chime when a feature needs you or ships
|
|
199
201
|
npx buildline gates run quality gates
|
|
200
202
|
npx buildline doctor check the setup
|
|
201
203
|
npx buildline update refresh managed files, keep your edits
|
package/bin/buildline.js
CHANGED
|
@@ -18,10 +18,11 @@ ${pc.bold('Commands')}
|
|
|
18
18
|
${pc.cyan('new')} "<title>" open a feature ${pc.dim('--type fix|feature|epic --owner <name> --parent <id>')}
|
|
19
19
|
${pc.cyan('approve')} <id> <stage> record a human approval (spec | design | plan) ${pc.dim('--by <name>')}
|
|
20
20
|
${pc.cyan('move')} <id> <station> move a feature to a station ${pc.dim('--note "<why>"')}
|
|
21
|
-
${pc.cyan('watch')} live view of the line in your terminal ${pc.dim('--shipped --once --no-intro')}
|
|
22
|
-
${pc.cyan('ui')} open the dashboard in your browser ${pc.dim('--port <n> --no-open')}
|
|
21
|
+
${pc.cyan('watch')} live view of the line in your terminal ${pc.dim('--shipped --once --no-intro --quiet')}
|
|
22
|
+
${pc.cyan('ui')} open the dashboard in your browser ${pc.dim('--port <n> --no-open --quiet')}
|
|
23
23
|
${pc.cyan('step')} <station> [id] open a station in the CLI and model set for it ${pc.dim('--model <m> --headless --print')}
|
|
24
24
|
${pc.cyan('models')} choose models per station ${pc.dim('set <station|phase> <model> · check')}
|
|
25
|
+
${pc.cyan('sound')} [on|off] chime when a feature needs you or ships (watch and dashboard)
|
|
25
26
|
${pc.cyan('run')} [id] autopilot: build → verify → review with Claude Code /goal ${pc.dim('--headless --print')}
|
|
26
27
|
${pc.cyan('gates')} [names...] run the quality gates
|
|
27
28
|
${pc.cyan('doctor')} check the setup
|
|
@@ -75,13 +76,14 @@ const { values: opts, positionals } = parseArgs({
|
|
|
75
76
|
once: { type: 'boolean' },
|
|
76
77
|
shipped: { type: 'boolean' },
|
|
77
78
|
'no-intro': { type: 'boolean' },
|
|
79
|
+
quiet: { type: 'boolean', short: 'q' },
|
|
78
80
|
help: { type: 'boolean', short: 'h' },
|
|
79
81
|
version: { type: 'boolean', short: 'v' },
|
|
80
82
|
},
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
const root = path.resolve(opts.cwd || process.cwd());
|
|
84
|
-
const o = { ...opts, dryRun: opts['dry-run'], ci: opts['no-ci'] ? false : opts.ci, open: !opts['no-open'], intro: !opts['no-intro'] };
|
|
86
|
+
const o = { ...opts, dryRun: opts['dry-run'], ci: opts['no-ci'] ? false : opts.ci, open: !opts['no-open'], intro: !opts['no-intro'], sound: !opts.quiet };
|
|
85
87
|
const [cmd, ...rest] = positionals;
|
|
86
88
|
|
|
87
89
|
async function main() {
|
|
@@ -117,6 +119,8 @@ async function main() {
|
|
|
117
119
|
return cli.models(root, rest, o);
|
|
118
120
|
case 'run':
|
|
119
121
|
return cli.run(root, rest[0], o);
|
|
122
|
+
case 'sound':
|
|
123
|
+
return cli.sound(root, rest[0]);
|
|
120
124
|
case 'gates':
|
|
121
125
|
return cli.gates(root, rest);
|
|
122
126
|
case 'doctor':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildline",
|
|
3
|
-
"version": "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/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { AGENTS, AUTONOMY, VERSION, makeConfig, plan, apply, removePlan, applyRe
|
|
|
8
8
|
import { loadManifest } from './files.js';
|
|
9
9
|
import { PHASES, MODEL_STATIONS, CLIS, modelFor, parseModel, parseModelList, describeModel, discoverModelOptions, checkModel, stationPrompt, launchArgs, externalClis } from './models.js';
|
|
10
10
|
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, moveBlocker, STATIONS, TYPES } from './features.js';
|
|
11
|
+
import { chime } from './sound.js';
|
|
11
12
|
|
|
12
13
|
const LOGO = [
|
|
13
14
|
pc.yellow(' ┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐'),
|
|
@@ -270,6 +271,19 @@ export async function watch(root, opts = {}) {
|
|
|
270
271
|
if (next?.action === 'ui') return (await import('./server.js')).serve(root, {});
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
export function sound(root, value) {
|
|
275
|
+
const config = loadConfig(root);
|
|
276
|
+
if (value) {
|
|
277
|
+
if (!['on', 'off'].includes(value)) throw new Error('Usage: buildline sound [on|off]');
|
|
278
|
+
if (value === 'on') delete config.sound;
|
|
279
|
+
else config.sound = false;
|
|
280
|
+
fs.writeFileSync(configPath(root), JSON.stringify(config, null, 2) + '\n');
|
|
281
|
+
}
|
|
282
|
+
const on = config.sound !== false;
|
|
283
|
+
console.log(`${on ? pc.green('♪') : pc.dim('♪')} Sound is ${on ? 'on' : 'off'}: ${on ? '`watch` and the dashboard chime when a feature needs you or ships.' : 'no chime.'} ${pc.dim(on ? 'Turn off: buildline sound off' : 'Turn on: buildline sound on')}`);
|
|
284
|
+
if (on && value === 'on') chime();
|
|
285
|
+
}
|
|
286
|
+
|
|
273
287
|
// ---- new / approve / move ----------------------------------------------------
|
|
274
288
|
|
|
275
289
|
export async function newFeature(root, title, opts) {
|
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
|
-
|
|
185
|
-
|
|
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,17 +208,35 @@ 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
|
|
209
|
-
*
|
|
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:
|
|
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
|
}
|
|
231
|
+
|
|
232
|
+
/** What the chime is for: 'waiting' when a feature needs a person, 'done' once it ships, else null. */
|
|
233
|
+
export function attention(feature, live) {
|
|
234
|
+
if (feature.status === 'done') return 'done';
|
|
235
|
+
return feature.blocked || live?.waiting ? 'waiting' : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Ids whose attention changed to waiting or done since `before` (a Map from the last look). */
|
|
239
|
+
export function newAttention(before, now) {
|
|
240
|
+
if (!before) return [];
|
|
241
|
+
return [...now].filter(([id, a]) => a && before.get(id) !== a).map(([id]) => id);
|
|
242
|
+
}
|
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/server.js
CHANGED
|
@@ -33,8 +33,9 @@ function openBrowser(url) {
|
|
|
33
33
|
} catch {}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function state(root) {
|
|
36
|
+
function state(root, opts = {}) {
|
|
37
37
|
const config = JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8'));
|
|
38
|
+
if (opts.sound === false) config.sound = false;
|
|
38
39
|
const sessions = readSessions(root);
|
|
39
40
|
const features = listFeatures(root).map(({ path: _p, ...f }) => ({
|
|
40
41
|
...f,
|
|
@@ -89,7 +90,7 @@ export function serve(root, opts = {}) {
|
|
|
89
90
|
if (!url.pathname.startsWith('/api/')) return send(404, { error: 'Not found' });
|
|
90
91
|
if (req.method !== 'GET' && req.headers['x-buildline-token'] !== token) return send(403, { error: 'Bad token' });
|
|
91
92
|
|
|
92
|
-
if (req.method === 'GET' && url.pathname === '/api/state') return send(200, state(root));
|
|
93
|
+
if (req.method === 'GET' && url.pathname === '/api/state') return send(200, state(root, opts));
|
|
93
94
|
|
|
94
95
|
if (url.pathname === '/api/file') {
|
|
95
96
|
if (req.method === 'GET') {
|
package/src/sound.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The one sound Buildline makes: a short chime when a feature needs a person or
|
|
2
|
+
// ships. Plays the system's own notification sound where there is one, and falls
|
|
3
|
+
// back to the terminal bell. Off with `"sound": false` in buildline.json.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const PLAYERS = {
|
|
8
|
+
darwin: ['afplay', '/System/Library/Sounds/Glass.aiff'],
|
|
9
|
+
linux: ['paplay', '/usr/share/sounds/freedesktop/stereo/complete.oga'],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let last = 0;
|
|
13
|
+
|
|
14
|
+
export function chime(out = process.stdout) {
|
|
15
|
+
if (Date.now() - last < 3000) return; // several features at once still make one sound
|
|
16
|
+
last = Date.now();
|
|
17
|
+
const bell = () => out.write('\x07');
|
|
18
|
+
const player = PLAYERS[process.platform];
|
|
19
|
+
if (!player || !fs.existsSync(player[1])) return bell();
|
|
20
|
+
try {
|
|
21
|
+
const child = spawn(player[0], [player[1]], { stdio: 'ignore', detached: true });
|
|
22
|
+
child.on('error', bell);
|
|
23
|
+
child.unref();
|
|
24
|
+
} catch {
|
|
25
|
+
bell();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const soundOn = (config) => config?.sound !== false;
|
package/src/tui.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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
|
|
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';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import pc from 'picocolors';
|
|
10
|
-
import { listFeatures, nextAction, approve, readSessions, readActivity, liveState } from './features.js';
|
|
11
|
+
import { listFeatures, nextAction, approve, readSessions, readActivity, liveState, attention, newAttention } from './features.js';
|
|
12
|
+
import { chime, soundOn } from './sound.js';
|
|
11
13
|
import { modelFor, describeModel } from './models.js';
|
|
12
14
|
import { AUTONOMY, VERSION } from './install.js';
|
|
13
15
|
|
|
@@ -15,6 +17,8 @@ const TRACK = ['spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
|
|
|
15
17
|
const LOGO = ['┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐', '├┴┐│ │││ ││││ ││││├┤ ', '└─┘└─┘┴┴─┘─┴┘┴─┘┴┘└┘└─┘'];
|
|
16
18
|
const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
17
19
|
const SPARK = ' ▁▂▃▄▅▆▇█';
|
|
20
|
+
const CLOCK = '◴◷◶◵';
|
|
21
|
+
const WAITS = { reply: 'waiting for your reply', question: 'asking you a question', permission: 'waiting for your permission' };
|
|
18
22
|
|
|
19
23
|
// ---- colour ------------------------------------------------------------------
|
|
20
24
|
|
|
@@ -113,7 +117,8 @@ function loadState(root, showShipped) {
|
|
|
113
117
|
.flatMap((f) => (f.history || []).map((h) => ({ ...h, id: f.id, key: `${f.id}|${h.at}|${h.note}` })))
|
|
114
118
|
.concat(tools)
|
|
115
119
|
.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
116
|
-
|
|
120
|
+
const needs = new Map(all.map((f) => [f.id, attention(f, liveState(f, sessions))]));
|
|
121
|
+
return { config, all, features, events, sessions, needs };
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
const stationIndex = (status) => (status === 'new' ? 0 : status === 'done' ? TRACK.length : TRACK.indexOf(status));
|
|
@@ -121,16 +126,17 @@ const shortModel = (config, station) => {
|
|
|
121
126
|
const m = modelFor(config, station);
|
|
122
127
|
return m.cli === 'claude' && !m.model ? 'session' : describeModel(m).replace(/^claude-/, '');
|
|
123
128
|
};
|
|
124
|
-
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.
|
|
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` : ''}`);
|
|
125
130
|
|
|
126
131
|
/** The NEXT UP cell: who or what the feature is waiting on right now. */
|
|
127
132
|
function rowStatus(f, t) {
|
|
128
133
|
if (f.blocked) return ['BLOCKED', C.red];
|
|
134
|
+
if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
|
|
129
135
|
if (f.live.working) {
|
|
130
136
|
const n = f.live.sessions.filter((x) => x.state === 'working').length;
|
|
131
137
|
return [`${SPINNER[t % SPINNER.length]} ${n > 1 ? `×${n} ` : ''}WORKING`, C.amberBold];
|
|
132
138
|
}
|
|
133
|
-
if (f.live.
|
|
139
|
+
if (f.live.background.length) return [`${CLOCK[Math.floor(t / 4) % CLOCK.length]} BACKGROUND`, C.cyan];
|
|
134
140
|
if (f.next.who === 'agent') return ['AGENT', C.amberDim];
|
|
135
141
|
const who = WHO[f.next.who];
|
|
136
142
|
return who ? [who.label, who.color] : ['', null];
|
|
@@ -164,7 +170,8 @@ function drawTrack(line, x, f, seg, t) {
|
|
|
164
170
|
const who = WHO[f.next.who];
|
|
165
171
|
const skipped = (i) => f.type === 'fix' && i < 3 && cur >= 3;
|
|
166
172
|
// The comet only runs while a session is actually working on this feature.
|
|
167
|
-
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;
|
|
168
175
|
const idleAgent = f.next.who === 'agent' && !f.blocked && !f.live?.waiting;
|
|
169
176
|
for (let i = 0; i < TRACK.length; i++) {
|
|
170
177
|
const nx = x + i * seg;
|
|
@@ -174,6 +181,7 @@ function drawTrack(line, x, f, seg, t) {
|
|
|
174
181
|
else if (i === cur && f.blocked) node = ['✖', t % 10 < 5 ? C.red : C.faint];
|
|
175
182
|
else if (i === cur && working) node = [['◉', '◎', '◉', '●'][Math.floor(t / 3) % 4], C.amberBold];
|
|
176
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];
|
|
177
185
|
else if (i === cur && idleAgent) node = ['◉', C.amberDim];
|
|
178
186
|
else if (i === cur) node = ['◉', t % 12 < 8 ? who?.color || C.amber : C.faint];
|
|
179
187
|
line.put(nx, node[0], node[1]);
|
|
@@ -206,8 +214,9 @@ function render(st, ui, W, H) {
|
|
|
206
214
|
const active = st.all.filter((f) => f.status !== 'done');
|
|
207
215
|
const shipped = st.all.length - active.length;
|
|
208
216
|
const tasks = active.flatMap((f) => f.tasks || []);
|
|
209
|
-
const
|
|
210
|
-
const
|
|
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;
|
|
211
220
|
|
|
212
221
|
// header
|
|
213
222
|
const tall = W >= 96 && H >= 26;
|
|
@@ -298,13 +307,13 @@ function render(st, ui, W, H) {
|
|
|
298
307
|
const by = top + lineH;
|
|
299
308
|
const bh = H - 1 - by;
|
|
300
309
|
if (bh >= 5) {
|
|
301
|
-
const split = W >= 100 ? Math.floor(W * 0.52) : W;
|
|
310
|
+
const split = W >= 100 && ui.events !== false ? Math.floor(W * 0.52) : W;
|
|
302
311
|
drawInspector(screen, st, sel, 0, by, split, bh);
|
|
303
312
|
if (split < W) drawEvents(screen, st, ui, split, by, W - split, bh);
|
|
304
313
|
}
|
|
305
314
|
|
|
306
315
|
// footer
|
|
307
|
-
const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['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']];
|
|
308
317
|
let fx = 1;
|
|
309
318
|
const foot = screen[H - 1];
|
|
310
319
|
if (ui.message && ui.messageUntil > Date.now()) fx = foot.put(1, ` ${ui.message} `, C.flash);
|
|
@@ -342,15 +351,23 @@ function drawInspector(screen, st, f, x, y, w, h) {
|
|
|
342
351
|
lines.slice(0, 4).forEach((l, i) => add([i ? ' ' : '✖ blocked ', C.red], [l, C.bold]));
|
|
343
352
|
}
|
|
344
353
|
for (const ses of f.live?.sessions || []) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
)
|
|
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
|
+
}
|
|
354
371
|
}
|
|
355
372
|
const ts = f.tasks || [];
|
|
356
373
|
if (ts.length) {
|
|
@@ -446,8 +463,10 @@ export async function watch(root, opts = {}) {
|
|
|
446
463
|
}
|
|
447
464
|
const ui = { root, tick: 0, sel: 0, scroll: 0, flash: new Map(), freshEvents: new Map(), shipped: false, message: '', messageUntil: 0 };
|
|
448
465
|
let st = loadState(root, ui.shipped);
|
|
466
|
+
ui.sound = opts.sound !== false && soundOn(st.config);
|
|
449
467
|
let lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
|
|
450
468
|
let seen = new Set(st.events.map((e) => e.key));
|
|
469
|
+
let needs = st.needs;
|
|
451
470
|
|
|
452
471
|
out.write('\x1b[?1049h\x1b[?25l');
|
|
453
472
|
if (opts.intro !== false) await boot(out, st, root);
|
|
@@ -462,6 +481,8 @@ export async function watch(root, opts = {}) {
|
|
|
462
481
|
if (lastStatus.has(f.id) && lastStatus.get(f.id) !== f.status) ui.flash.set(f.id, Date.now() + 2500);
|
|
463
482
|
}
|
|
464
483
|
for (const e of st.events) if (!seen.has(e.key)) ui.freshEvents.set(e.key, Date.now() + 4000);
|
|
484
|
+
if (newAttention(needs, st.needs).length && ui.sound) chime(out);
|
|
485
|
+
needs = st.needs;
|
|
465
486
|
lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
|
|
466
487
|
seen = new Set(st.events.map((e) => e.key));
|
|
467
488
|
ui.sel = Math.max(0, Math.min(ui.sel, st.features.length - 1));
|
|
@@ -496,6 +517,8 @@ export async function watch(root, opts = {}) {
|
|
|
496
517
|
if (k === '\x1b[A' || k === 'k') ui.sel = Math.max(0, ui.sel - 1);
|
|
497
518
|
else if (k === '\x1b[B' || k === 'j') ui.sel = Math.min(st.features.length - 1, ui.sel + 1);
|
|
498
519
|
else if (k === 's') (ui.shipped = !ui.shipped), refresh();
|
|
520
|
+
else if (k === 'e') (ui.events = ui.events === false), out.write('\x1b[2J');
|
|
521
|
+
else if (k === 'm') (ui.sound = !ui.sound), say(ui.sound ? '♪ sound on' : 'sound muted until you quit');
|
|
499
522
|
else if (k === 'u') return done({ action: 'ui' });
|
|
500
523
|
else if ((k === '\r' || k === '\n') && f) {
|
|
501
524
|
if (f.status === 'done') say(`${f.id} has shipped`);
|
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); } }
|
|
@@ -152,6 +154,7 @@ dialog h2 { margin: 0 0 12px; font-size: 17px; }
|
|
|
152
154
|
<button role="tab" aria-selected="false" data-view="context">Context</button>
|
|
153
155
|
</nav>
|
|
154
156
|
<span class="spacer"></span>
|
|
157
|
+
<button class="btn" id="soundBtn" aria-pressed="true"></button>
|
|
155
158
|
<button class="btn" id="whoami" title="Name used for approvals"></button>
|
|
156
159
|
<button class="btn primary" id="newBtn">+ New</button>
|
|
157
160
|
</header>
|
|
@@ -241,21 +244,25 @@ function card(f) {
|
|
|
241
244
|
</button>`;
|
|
242
245
|
}
|
|
243
246
|
const stationModel = (station) => { const m = S.config.models || {}; const v = m[station] ?? m.default; return v && v !== 'inherit' ? v : ''; };
|
|
244
|
-
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.
|
|
245
|
-
|
|
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. */
|
|
246
250
|
function liveLine(f) {
|
|
247
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>`;
|
|
248
254
|
const ses = f.live?.sessions || [];
|
|
249
255
|
const on = ses.filter((x) => x.state === 'working');
|
|
250
256
|
if (f.live?.working) {
|
|
251
257
|
const s = on[0];
|
|
252
258
|
if (!s) return `<div class="live working"><i class="dot"></i><span><b>Working</b> · files changing</span></div>`;
|
|
253
259
|
const model = String(s.model || stationModel(f.status) || '').replace(/^claude-/, '');
|
|
254
|
-
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(' · ');
|
|
255
261
|
const doing = s.tool ? `${esc(s.tool)} <code>${esc(s.detail || '')}</code>` : 'thinking';
|
|
256
|
-
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>`;
|
|
257
263
|
}
|
|
258
|
-
|
|
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>`;
|
|
259
266
|
return '';
|
|
260
267
|
}
|
|
261
268
|
function renderBoard() {
|
|
@@ -287,6 +294,10 @@ async function render() {
|
|
|
287
294
|
$('#project').textContent = S.config.project;
|
|
288
295
|
$('#autonomy').textContent = S.autonomyLabel || `L${S.config.autonomy}`;
|
|
289
296
|
$('#whoami').textContent = me() ? `👤 ${me()}` : 'Set your name';
|
|
297
|
+
const on = soundOn();
|
|
298
|
+
$('#soundBtn').textContent = on ? '♪ Sound on' : '♪ Sound off';
|
|
299
|
+
$('#soundBtn').setAttribute('aria-pressed', on);
|
|
300
|
+
$('#soundBtn').title = on ? 'Chimes when a feature needs you or ships. Click to mute.' : 'Click to chime when a feature needs you or ships.';
|
|
290
301
|
document.querySelectorAll('nav.tabs button').forEach((b) => b.setAttribute('aria-selected', b.dataset.view === view));
|
|
291
302
|
if (!(view === 'context' && ctxEditing && $('#ctxEditor'))) $('#main').innerHTML = view === 'board' ? renderBoard() : await renderContext();
|
|
292
303
|
if (openId) await renderDrawer();
|
|
@@ -343,6 +354,7 @@ $('#newDialog').addEventListener('close', async () => {
|
|
|
343
354
|
});
|
|
344
355
|
$('#newBtn').onclick = newFeature;
|
|
345
356
|
$('#whoami').onclick = askName;
|
|
357
|
+
$('#soundBtn').onclick = () => { try { localStorage.setItem('buildline.sound', soundOn() ? 'off' : 'on'); } catch {} render(); if (soundOn()) chime(true); };
|
|
346
358
|
$('#backdrop').onclick = closeDrawer;
|
|
347
359
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openId && !editing) closeDrawer(); });
|
|
348
360
|
document.addEventListener('click', (e) => {
|
|
@@ -352,15 +364,42 @@ document.addEventListener('click', (e) => {
|
|
|
352
364
|
const x = e.target.closest('[data-ctx]'); if (x) { ctxPath = x.dataset.ctx; ctxEditing = false; render(); }
|
|
353
365
|
});
|
|
354
366
|
|
|
367
|
+
// ---- sound: one chime when a feature needs a person or ships ----
|
|
368
|
+
let audio = null, needs = null, lastChime = 0;
|
|
369
|
+
const soundOn = () => { let v = null; try { v = localStorage.getItem('buildline.sound'); } catch {} return v ? v === 'on' : S?.config.sound !== false; };
|
|
370
|
+
const attention = (f) => (f.status === 'done' ? 'done' : f.blocked || f.live?.waiting ? 'waiting' : null);
|
|
371
|
+
// Browsers only allow sound after the page has been touched, so the audio starts on the first click or key.
|
|
372
|
+
const unlock = () => { audio ||= new (window.AudioContext || window.webkitAudioContext)(); audio.resume(); };
|
|
373
|
+
['pointerdown', 'keydown'].forEach((e) => document.addEventListener(e, unlock, { once: true }));
|
|
374
|
+
function chime(force = false) {
|
|
375
|
+
if (!audio || (!force && Date.now() - lastChime < 3000)) return;
|
|
376
|
+
lastChime = Date.now();
|
|
377
|
+
const t = audio.currentTime, out = audio.createGain();
|
|
378
|
+
out.connect(audio.destination);
|
|
379
|
+
out.gain.setValueAtTime(0.0001, t);
|
|
380
|
+
out.gain.exponentialRampToValueAtTime(0.25, t + 0.01);
|
|
381
|
+
out.gain.exponentialRampToValueAtTime(0.0001, t + 1.2);
|
|
382
|
+
for (const [hz, level] of [[880, 1], [1320, 0.35], [1760, 0.15]]) {
|
|
383
|
+
const o = audio.createOscillator(), g = audio.createGain();
|
|
384
|
+
o.frequency.value = hz; g.gain.value = level;
|
|
385
|
+
o.connect(g).connect(out); o.start(t); o.stop(t + 1.25);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function listen() {
|
|
389
|
+
const now = new Map(S.features.map((f) => [f.id, attention(f)]));
|
|
390
|
+
if (needs && soundOn() && [...now].some(([id, a]) => a && needs.get(id) !== a)) chime();
|
|
391
|
+
needs = now;
|
|
392
|
+
}
|
|
393
|
+
|
|
355
394
|
async function refresh() {
|
|
356
395
|
try {
|
|
357
396
|
const next = await api('/api/state');
|
|
358
397
|
const changed = JSON.stringify(next) !== JSON.stringify(S);
|
|
359
|
-
S = next; if (changed) await render();
|
|
398
|
+
S = next; listen(); if (changed) await render();
|
|
360
399
|
} catch (e) { toast('Dashboard lost connection: is `buildline ui` still running?'); }
|
|
361
400
|
}
|
|
362
401
|
refresh();
|
|
363
|
-
setInterval(() => { if (!
|
|
402
|
+
setInterval(() => { if (!editing && !ctxEditing) refresh(); }, 2000);
|
|
364
403
|
</script>
|
|
365
404
|
</body>
|
|
366
405
|
</html>
|
package/templates/bin/hook.mjs
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
|
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) {
|