@lovinka/vitrinka 1.15.1 → 1.17.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/CHANGELOG.md +32 -0
- package/dist/cli.js +110 -21
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +13 -1
- package/dist/mcp.js.map +1 -1
- package/dist/work.js +8 -1
- package/dist/work.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,38 @@
|
|
|
3
3
|
`vitrinka update` prints the sections newer than your previous version after
|
|
4
4
|
updating — keep entries short and user-facing.
|
|
5
5
|
|
|
6
|
+
## 1.17.0 — 2026-07-20
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- `get_questions` MCP tool — durable post-send retrieval of a board's answered
|
|
10
|
+
decisions (options, notes, ⌖ refs). Choices ride the work wire exactly once,
|
|
11
|
+
so a send with no listening session used to look unrecoverable; now any
|
|
12
|
+
session can re-read the full answer record at any time.
|
|
13
|
+
- `scrape_board` digest now carries a compact `questions[]`
|
|
14
|
+
(`{id, prompt, answer?, note?, staged?}`) — one call reads the decision
|
|
15
|
+
state alongside cards/flow/sections; section scopes keep only their own.
|
|
16
|
+
- `vitrinka watch` keepalive: while a human actually has the board open, the
|
|
17
|
+
leased watch emits a quiet `· keepalive` heartbeat (default every 3000 s,
|
|
18
|
+
`--keepalive 0` off) so the listening session's prompt cache stays warm and
|
|
19
|
+
the next annotation lands cheap. An abandoned board costs nothing.
|
|
20
|
+
|
|
21
|
+
## 1.16.0 — 2026-07-19
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Listener takeover is automatic on the same machine — arming `vitrinka watch`
|
|
25
|
+
for a scope another of YOUR sessions already holds displaces the older
|
|
26
|
+
listener (its in-flight items go back to the queue) instead of failing with
|
|
27
|
+
a 409 and a manual handoff. The displaced watch prints one stand-down line
|
|
28
|
+
and exits cleanly. Live leases on a *different* machine still refuse the
|
|
29
|
+
claim; `--takeover` still steals expired leases only.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- `vitrinka watch` and the background daemon no longer swallow answered
|
|
33
|
+
brainstorm choices — a pure choice batch used to be drained by the notifier
|
|
34
|
+
and marked delivered while the listening session never woke. Notifier lanes
|
|
35
|
+
now peek without consuming, and watch announces each answer as a
|
|
36
|
+
`№<id> [choice]` line; the acting lane keeps exactly-once delivery.
|
|
37
|
+
|
|
6
38
|
## 1.15.1 — 2026-07-18
|
|
7
39
|
|
|
8
40
|
### Added
|
package/dist/cli.js
CHANGED
|
@@ -3415,13 +3415,14 @@ export const COMMANDS = [
|
|
|
3415
3415
|
},
|
|
3416
3416
|
{
|
|
3417
3417
|
name: 'watch', summary: 'Monitor the work queue (listen v2); auto-scopes to repo+branch, leases the scope (one listener each), one line per new item',
|
|
3418
|
-
usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--once] [--base <url>]',
|
|
3418
|
+
usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--keepalive <sec>] [--once] [--base <url>]',
|
|
3419
3419
|
flags: [{ f: '--board', arg: '<slug>', d: 'lease one board (else the repo project+branch is inferred)' },
|
|
3420
3420
|
{ f: '--project', arg: '<p>', d: 'override the inferred project scope' },
|
|
3421
3421
|
{ f: '--branch', arg: '<b>', d: 'override the inferred branch scope (with --project)' },
|
|
3422
3422
|
{ f: '--all', d: 'firehose: observe every board (leases a conflict-free scope, claims nothing)' },
|
|
3423
3423
|
{ f: '--takeover', d: 'steal an EXPIRED lease (a dead session); never steals a live one' },
|
|
3424
3424
|
{ f: '--quiet-grace', arg: '<sec>', d: 'drain poll interval (default 5)' },
|
|
3425
|
+
{ f: '--keepalive', arg: '<sec>', d: 'cache keep-alive wake interval while a viewer has a scope board open (default 3000, 0 = off)' },
|
|
3425
3426
|
{ f: '--once', d: 'single deterministic poll (exit 0 = work, 3 = idle)' }, BASE_FLAG],
|
|
3426
3427
|
dynamic: 'boards',
|
|
3427
3428
|
examples: ['vitrinka watch', 'vitrinka watch --board payout-flow', 'vitrinka watch --takeover'],
|
|
@@ -4282,6 +4283,47 @@ export function computeAnnouncements(prev, currentQueue) {
|
|
|
4282
4283
|
}
|
|
4283
4284
|
return { lines, nextMap };
|
|
4284
4285
|
}
|
|
4286
|
+
// choiceLine renders the single stdout line for one answered question:
|
|
4287
|
+
// №<id> [choice] <board>: <question> → <option>[ · <note>] (≤120 chars tail)
|
|
4288
|
+
export function choiceLine(c) {
|
|
4289
|
+
const board = c.board || '?';
|
|
4290
|
+
let tail = `${String(c.question ?? '').replace(/\s+/g, ' ').trim()} → ${String(c.option ?? '').replace(/\s+/g, ' ').trim()}`;
|
|
4291
|
+
if (c.note)
|
|
4292
|
+
tail += ` · ${String(c.note).replace(/\s+/g, ' ').trim()}`;
|
|
4293
|
+
if (tail.length > 120)
|
|
4294
|
+
tail = tail.slice(0, 120) + '…';
|
|
4295
|
+
return `№${c.id} [choice] ${board}: ${tail}`;
|
|
4296
|
+
}
|
|
4297
|
+
// computeChoiceAnnouncements is the choice twin of computeAnnouncements (PURE).
|
|
4298
|
+
// A choice is "new" when its id wasn't announced or its answer/note changed
|
|
4299
|
+
// (re-answer + re-dispatch re-arms delivered and must re-announce). A drained
|
|
4300
|
+
// choice drops off the wait wire and thus out of nextMap.
|
|
4301
|
+
export function computeChoiceAnnouncements(prev, choices) {
|
|
4302
|
+
const nextMap = {};
|
|
4303
|
+
const lines = [];
|
|
4304
|
+
for (const c of choices) {
|
|
4305
|
+
if (c == null || c.id === undefined || c.id === null)
|
|
4306
|
+
continue;
|
|
4307
|
+
const key = String(c.id);
|
|
4308
|
+
const before = prev[key];
|
|
4309
|
+
if (!before || before.option !== c.option || before.note !== c.note) {
|
|
4310
|
+
lines.push(choiceLine(c));
|
|
4311
|
+
}
|
|
4312
|
+
nextMap[key] = { option: c.option, note: c.note };
|
|
4313
|
+
}
|
|
4314
|
+
return { lines, nextMap };
|
|
4315
|
+
}
|
|
4316
|
+
// shouldKeepalive is the presence-gated cache keep-alive decision (PURE —
|
|
4317
|
+
// unit-tested). A listening Claude Code session's prompt cache expires after a
|
|
4318
|
+
// TTL of idle; a wake just before expiry re-reads the cache (~10% of input
|
|
4319
|
+
// price) instead of paying a full-price cache re-write on the next annotation.
|
|
4320
|
+
// That gamble only pays while an annotation is plausibly imminent, so the wake
|
|
4321
|
+
// fires ONLY when the server reports a human actually has a scope board open
|
|
4322
|
+
// (viewerPresent on the idle wait response). Every stdout line wakes the
|
|
4323
|
+
// session, so any emitted line resets the clock.
|
|
4324
|
+
export function shouldKeepalive(nowMs, lastWakeMs, keepaliveSec, viewerPresent) {
|
|
4325
|
+
return keepaliveSec > 0 && viewerPresent && nowMs - lastWakeMs >= keepaliveSec * 1000;
|
|
4326
|
+
}
|
|
4285
4327
|
const watchEmit = (line) => { process.stdout.write(line + '\n'); };
|
|
4286
4328
|
const watchSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4287
4329
|
function watchBaseUrl(args) {
|
|
@@ -4337,6 +4379,8 @@ async function watchFetchJson(url, timeoutMs) {
|
|
|
4337
4379
|
async function watchCmd(args) {
|
|
4338
4380
|
const base = watchBaseUrl(args);
|
|
4339
4381
|
const graceSec = numArg(args['quiet-grace'], 5, '--quiet-grace');
|
|
4382
|
+
// --keepalive 0 disables the heartbeat (numArg itself rejects non-positives).
|
|
4383
|
+
const keepaliveSec = strArg(args.keepalive) === '0' ? 0 : numArg(args.keepalive, 3000, '--keepalive');
|
|
4340
4384
|
const once = args.once === true;
|
|
4341
4385
|
// Repo context for scope inference (main worktree basename = project; current
|
|
4342
4386
|
// branch). '.' cwd is where the session runs (the app repo).
|
|
@@ -4352,7 +4396,7 @@ async function watchCmd(args) {
|
|
|
4352
4396
|
// --once: a single deterministic probe (tests). Lease-free (no session), so it
|
|
4353
4397
|
// never claims scope. Returns exit 0 with announcements, else 3 (idle).
|
|
4354
4398
|
if (once) {
|
|
4355
|
-
const waitUrl = `${base}/api/v1/work/wait?${new URLSearchParams({ timeout: '2', ...sq })}`;
|
|
4399
|
+
const waitUrl = `${base}/api/v1/work/wait?${new URLSearchParams({ timeout: '2', peek: '1', ...sq })}`;
|
|
4356
4400
|
let waited;
|
|
4357
4401
|
try {
|
|
4358
4402
|
waited = await watchFetchJson(waitUrl, 12_000);
|
|
@@ -4361,19 +4405,24 @@ async function watchCmd(args) {
|
|
|
4361
4405
|
console.error(`watch --once: vitrinka unreachable: ${e instanceof Error ? e.message : e}`);
|
|
4362
4406
|
process.exit(3);
|
|
4363
4407
|
}
|
|
4364
|
-
|
|
4408
|
+
const onceChoices = Array.isArray(waited?.choices) ? waited.choices : [];
|
|
4409
|
+
const hasWork = Array.isArray(waited?.work) && waited.work.length > 0;
|
|
4410
|
+
if (!waited || waited.idle || (!hasWork && onceChoices.length === 0)) {
|
|
4365
4411
|
process.exit(3);
|
|
4366
4412
|
}
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
list
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4413
|
+
const lines = computeChoiceAnnouncements({}, onceChoices).lines;
|
|
4414
|
+
if (hasWork) {
|
|
4415
|
+
let list;
|
|
4416
|
+
try {
|
|
4417
|
+
list = await watchFetchJson(listUrl(), 30_000);
|
|
4418
|
+
}
|
|
4419
|
+
catch (e) {
|
|
4420
|
+
console.error(`watch --once: list failed: ${e instanceof Error ? e.message : e}`);
|
|
4421
|
+
process.exit(3);
|
|
4422
|
+
}
|
|
4423
|
+
const items = Array.isArray(list?.work) ? list.work : [];
|
|
4424
|
+
lines.push(...computeAnnouncements({}, items).lines);
|
|
4374
4425
|
}
|
|
4375
|
-
const items = Array.isArray(list?.work) ? list.work : [];
|
|
4376
|
-
const { lines } = computeAnnouncements({}, items);
|
|
4377
4426
|
for (const l of lines)
|
|
4378
4427
|
watchEmit(l);
|
|
4379
4428
|
process.exit(lines.length > 0 ? 0 : 3);
|
|
@@ -4385,7 +4434,9 @@ async function watchCmd(args) {
|
|
|
4385
4434
|
const session = `${hostname()}:${process.pid}`;
|
|
4386
4435
|
const takeover = args.takeover === true;
|
|
4387
4436
|
const leasedWaitUrl = (secs) => {
|
|
4388
|
-
|
|
4437
|
+
// peek=1: the watch is a NOTIFIER — the server must leave choices pending
|
|
4438
|
+
// for the acting session's MCP wait_for_work drain (which has no peek).
|
|
4439
|
+
const q = new URLSearchParams({ timeout: String(secs), peek: '1', session, ...sq });
|
|
4389
4440
|
if (actor)
|
|
4390
4441
|
q.set('actor', actor);
|
|
4391
4442
|
if (takeover)
|
|
@@ -4415,12 +4466,17 @@ async function watchCmd(args) {
|
|
|
4415
4466
|
process.on('SIGINT', () => { void shutdown(0); });
|
|
4416
4467
|
process.on('SIGTERM', () => { void shutdown(0); });
|
|
4417
4468
|
let announced = {};
|
|
4469
|
+
let announcedChoices = {};
|
|
4418
4470
|
let downSince = null;
|
|
4419
4471
|
let degradedAt = null;
|
|
4420
4472
|
let backoff = 1000;
|
|
4473
|
+
// Keep-alive clock: the session was active when it armed the monitor, and
|
|
4474
|
+
// every stdout line re-wakes it — so any emit resets the clock.
|
|
4475
|
+
let lastWakeMs = Date.now();
|
|
4476
|
+
const emit = (line) => { watchEmit(line); lastWakeMs = Date.now(); };
|
|
4421
4477
|
const noteSuccess = () => {
|
|
4422
4478
|
if (degradedAt !== null)
|
|
4423
|
-
|
|
4479
|
+
emit('✓ vitrinka reachable again');
|
|
4424
4480
|
downSince = null;
|
|
4425
4481
|
degradedAt = null;
|
|
4426
4482
|
backoff = 1000;
|
|
@@ -4431,7 +4487,7 @@ async function watchCmd(args) {
|
|
|
4431
4487
|
downSince = now;
|
|
4432
4488
|
const downMs = now - downSince;
|
|
4433
4489
|
if (downMs >= 120_000 && (degradedAt === null || now - degradedAt >= 600_000)) {
|
|
4434
|
-
|
|
4490
|
+
emit(`⚠ vitrinka unreachable for ${Math.round(downMs / 1000)}s — listener degraded`);
|
|
4435
4491
|
degradedAt = now;
|
|
4436
4492
|
}
|
|
4437
4493
|
const wait = backoff;
|
|
@@ -4451,11 +4507,12 @@ async function watchCmd(args) {
|
|
|
4451
4507
|
continue;
|
|
4452
4508
|
}
|
|
4453
4509
|
if (resp.status === 409) {
|
|
4454
|
-
// Scope
|
|
4455
|
-
//
|
|
4456
|
-
//
|
|
4510
|
+
// Scope held by an unstealable lease: a LIVE listener on ANOTHER machine
|
|
4511
|
+
// (same-machine live leases are displaced automatically — newest wins),
|
|
4512
|
+
// or an expired lease without --takeover. Surface it and exit (the skill
|
|
4513
|
+
// reads exit 2 as "another session owns this scope").
|
|
4457
4514
|
const h = resp.body?.holder ?? {};
|
|
4458
|
-
const live = resp.body?.live ? 'live' : 'expired';
|
|
4515
|
+
const live = resp.body?.live ? 'live, another machine' : 'expired';
|
|
4459
4516
|
const scopeStr = h.scopeKind === 'project' ? `${h.scope}/${h.branch}` : (h.scope || h.scopeKind || scope.label);
|
|
4460
4517
|
const since = h.lastSeen ? ` (since ${h.lastSeen})` : '';
|
|
4461
4518
|
watchEmit(`⚠ listener already active (${live}): ${h.actor || '?'}@${h.session || '?'} on ${scopeStr}${since}`);
|
|
@@ -4467,12 +4524,44 @@ async function watchCmd(args) {
|
|
|
4467
4524
|
await noteFailure();
|
|
4468
4525
|
continue;
|
|
4469
4526
|
}
|
|
4527
|
+
if (resp.body?.revoked) {
|
|
4528
|
+
// Newest-wins takeover: another session on this machine armed a listener
|
|
4529
|
+
// for our scope. Stand down — one notice, clean exit, and NEVER re-claim
|
|
4530
|
+
// (the takeover is the operator's routing decision).
|
|
4531
|
+
const t = resp.body.takenBy ?? {};
|
|
4532
|
+
watchEmit(`⚠ listener for ${scope.label} taken over by ${t.actor || '?'}@${t.session || '?'} — standing down`);
|
|
4533
|
+
process.exit(0);
|
|
4534
|
+
}
|
|
4470
4535
|
noteSuccess();
|
|
4471
4536
|
if (typeof resp.body?.listener === 'number')
|
|
4472
4537
|
leaseId = resp.body.listener;
|
|
4473
4538
|
const waited = resp.body;
|
|
4474
|
-
|
|
4539
|
+
const waitedWork = Array.isArray(waited?.work) ? waited.work : [];
|
|
4540
|
+
const waitedChoices = Array.isArray(waited?.choices) ? waited.choices : [];
|
|
4541
|
+
if (!waited || waited.idle || (waitedWork.length === 0 && waitedChoices.length === 0)) {
|
|
4542
|
+
announced = {};
|
|
4543
|
+
announcedChoices = {};
|
|
4544
|
+
// Presence-gated cache keep-alive: the idle response says whether a
|
|
4545
|
+
// human has a scope board open; if so and the session has been idle
|
|
4546
|
+
// ~one cache TTL, one heartbeat line wakes it to refresh the cache.
|
|
4547
|
+
// The skill treats the line as a no-op wake (do nothing, end turn).
|
|
4548
|
+
if (shouldKeepalive(Date.now(), lastWakeMs, keepaliveSec, waited?.viewerPresent === true)) {
|
|
4549
|
+
emit(`· keepalive ${scope.label}`);
|
|
4550
|
+
}
|
|
4551
|
+
continue;
|
|
4552
|
+
}
|
|
4553
|
+
// Choices announce straight from the wait body — with peek they stay
|
|
4554
|
+
// pending on the wire (invisible to the plain work list), so this response
|
|
4555
|
+
// is their only sighting until the acting session drains them.
|
|
4556
|
+
{
|
|
4557
|
+
const { lines, nextMap } = computeChoiceAnnouncements(announcedChoices, waitedChoices);
|
|
4558
|
+
announcedChoices = nextMap;
|
|
4559
|
+
for (const l of lines)
|
|
4560
|
+
emit(l);
|
|
4561
|
+
}
|
|
4562
|
+
if (waitedWork.length === 0) {
|
|
4475
4563
|
announced = {};
|
|
4564
|
+
await watchSleep(graceSec * 1000);
|
|
4476
4565
|
continue;
|
|
4477
4566
|
}
|
|
4478
4567
|
// Queue non-empty → diff the full open list and announce new items. The next
|
|
@@ -4483,7 +4572,7 @@ async function watchCmd(args) {
|
|
|
4483
4572
|
const { lines, nextMap } = computeAnnouncements(announced, items);
|
|
4484
4573
|
announced = nextMap;
|
|
4485
4574
|
for (const l of lines)
|
|
4486
|
-
|
|
4575
|
+
emit(l);
|
|
4487
4576
|
}
|
|
4488
4577
|
catch {
|
|
4489
4578
|
await noteFailure();
|