@lamemind/loom-deck 0.73.0 → 0.74.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/dist/actions.js CHANGED
@@ -24,11 +24,20 @@ import { cut, cutMiddle } from './width.js';
24
24
  import { dropSessionFiles, sessionArtifacts } from './session-drop.js';
25
25
  import { saveView, viewFilePath } from './view-store.js';
26
26
  import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, } from './spawn.js';
27
+ import { MODEL_DEFAULT } from './spawn-catalog.js';
28
+ import { modelAlias } from './glyphs.js';
27
29
  import { resolveSpawnId } from './spawn-config.js';
28
30
  import { useTaskOps } from './task-ops.js';
29
31
  import { inboxPrompt, inboxWords } from './inbox.js';
30
32
  import { docTargetKind, docWords } from './doc-tree.js';
31
33
  import { wrapWords } from './wrap-scan.js';
34
+ /**
35
+ * T181 — l'attesa massima di un passo del restore prima di passare al
36
+ * successivo. Un `deck-run` esce appena `ptyxis --tab` ha consegnato la tab
37
+ * all'istanza già aperta, cioè ben sotto questa soglia: il timeout esiste per il
38
+ * figlio che non esce mai, non per scandire la serie.
39
+ */
40
+ const RESTORE_STEP_TIMEOUT_MS = 3_000;
32
41
  export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overrides, catalog, setNote, }) {
33
42
  // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
34
43
  // lo si scriverebbe in bash, invece di una parafrasi.
@@ -200,6 +209,64 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
200
209
  const spawned = spawnDeckResume(bound, cwd, sessionId, modelKind, model.sessionNotes.get(sessionId));
201
210
  spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
202
211
  noteSpawn(spawned);
212
+ return spawned;
213
+ }
214
+ /**
215
+ * T181 — `^T` e il bottone in testata: riapre le conversazioni dell'ultimo
216
+ * insieme vivo, una tab per conversazione, ognuna col proprio binding di
217
+ * task, la propria nota e il modello con cui girava.
218
+ *
219
+ * Passa da `resumeSession`, terzo chiamante dopo la lista e la ricerca, e
220
+ * come la ricerca non ha una superficie di scelta del modello: lo eredita
221
+ * dalla conversazione (`modelAlias(session.model) ?? MODEL_DEFAULT`, T148/D1).
222
+ *
223
+ * IN SERIE, e non tutte insieme (P7 preflight): la tab successiva parte
224
+ * quando il `deck-run` della precedente è uscito, così le tab si aprono
225
+ * nell'ordine dell'insieme e la finestra riaperta ricalca da sinistra a
226
+ * destra quella chiusa. Il timeout per passo tiene la serie in moto anche se
227
+ * un figlio non esce mai — uno spawn inerte dei test, un `ptyxis` che resta
228
+ * appeso: una tab fuori ordine è meglio di un restore che si ferma a metà.
229
+ *
230
+ * La tenuta (`holdLive`) va due volte: su tutte all'avvio, perché il bottone
231
+ * sparisca subito e un secondo `^T` non riapra tutto di nuovo; su ognuna alla
232
+ * partenza della sua tab, perché la scadenza si conti da lì. Nessuna
233
+ * conferma (P6): ricostruisce uno stato che l'utente aveva, ed è reversibile
234
+ * chiudendo le tab.
235
+ */
236
+ function restoreSessions() {
237
+ const list = model.restorable;
238
+ if (list.length === 0) {
239
+ setNote(model.restoring ? '^T → restore già in corso' : '^T → nessuna conversazione da riaprire');
240
+ return;
241
+ }
242
+ model.holdLive(list.map((s) => s.sessionId));
243
+ const step = (i) => {
244
+ if (i >= list.length) {
245
+ // L'ultimo annuncio in-tab arriva asincrono e scriverebbe sopra il
246
+ // riepilogo: avanzare la sequenza lo rende vecchio prima che arrivi.
247
+ spawnSeq.current++;
248
+ setNote(`↻ restore: ${list.length} ${list.length === 1 ? 'conversazione riaperta' : 'conversazioni riaperte'}`);
249
+ return;
250
+ }
251
+ const s = list[i];
252
+ model.holdLive([s.sessionId]);
253
+ const { child } = resumeSession(s.sessionId, modelAlias(s.model) ?? MODEL_DEFAULT);
254
+ let done = false;
255
+ const next = () => {
256
+ if (done)
257
+ return;
258
+ done = true;
259
+ clearTimeout(timer);
260
+ step(i + 1);
261
+ };
262
+ // `unref`: il timer non deve tenere vivo il processo se il deck esce a
263
+ // metà serie — lo schermo sarebbe già restituito al terminale.
264
+ const timer = setTimeout(next, RESTORE_STEP_TIMEOUT_MS);
265
+ timer.unref();
266
+ child.once('exit', next);
267
+ child.once('error', next);
268
+ };
269
+ step(0);
203
270
  }
204
271
  /** T148/T154(P2,P3) — `m` col bersaglio RESUME: `input.ts` instrada qui solo
205
272
  * quando il focus è sulle sessioni E una riga è selezionata (P2), quindi il
@@ -596,6 +663,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
596
663
  spawnForTask,
597
664
  spawnTaskSession,
598
665
  resumeSession,
666
+ restoreSessions,
599
667
  cycleResumeModel,
600
668
  cycleBareModel,
601
669
  writeNote,
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import { MODEL_SHORT_LIST, modelAlias } from './glyphs.js';
23
23
  import { MODEL_DEFAULT, MODELS } from './spawn-catalog.js';
24
24
  import { loadPromptCatalog } from './prompt-catalog.js';
25
25
  import { resolveSpawnId } from './spawn-config.js';
26
- import { BARE_BUTTONS_WIDTH, BARE_LABEL, deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow, } from './frame.js';
26
+ import { BARE_BUTTONS_WIDTH, BARE_LABEL, HEADER_BUTTON_SEP, deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow, } from './frame.js';
27
27
  import { choiceButtons } from './ui/fields.js';
28
28
  import { useDeckModel } from './deck-model.js';
29
29
  import { useDeckActions } from './actions.js';
@@ -304,6 +304,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
304
304
  docCounts: model.docCounts,
305
305
  docViewId: model.docViewId,
306
306
  docHasDirs: model.docHasDirs,
307
+ restoreCount: model.restorable.length,
307
308
  });
308
309
  useDeckInput({
309
310
  cwd,
@@ -343,7 +344,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
343
344
  if (frame.budget.compact) {
344
345
  return (_jsx(CompactNotice, { dot: false, what: `v${VERSION} · ${model.viewTasks.length} task · sel ${model.selectedTaskId ?? model.parentLabel}`, rows: rows, columns: columns }));
345
346
  }
346
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(StatusHeadline, { name: model.projectCore ?? model.projectName, label: status.label, building: status.building, failed: status.failed, cols: headlineWidth(columns, frame.headerRight) }), _jsx(Text, { dimColor: true, children: frame.headerRight })] }), _jsx(HintBar, { mode: mode, purge: purge.draft, keyLegend: deckLegend({
347
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(StatusHeadline, { name: model.projectCore ?? model.projectName, label: status.label, building: status.building, failed: status.failed, cols: headlineWidth(columns, frame.headerBlock) }), frame.headerButton ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "yellow", bold: true, children: frame.headerButton }), _jsxs(Text, { dimColor: true, children: [HEADER_BUTTON_SEP, frame.headerRight] })] })) : (_jsx(Text, { dimColor: true, children: frame.headerRight }))] }), _jsx(HintBar, { mode: mode, purge: purge.draft, keyLegend: deckLegend({
347
348
  focus: model.focus,
348
349
  hasTask: model.selTask !== null,
349
350
  hasSession: model.selSessionObj !== null,
@@ -353,6 +354,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
353
354
  hasInbox: model.selInbox !== null,
354
355
  hasDoc: model.selDoc !== null,
355
356
  gitlink: gitlink.enabled,
357
+ restore: frame.headerButton !== null,
356
358
  }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, mode === 'drop' && drop.draft ? _jsx(DropModal, { draft: drop.draft, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [model.deckMode === 'doc' ? (_jsx(DocPane, { rows: frame.windowDoc, counts: model.docCounts, activeView: model.docViewId, paneCount: model.docRows.length, selectedPath: model.selDocPath, focused: model.focus === 'doctree', above: frame.docWin.start, below: model.docRows.length - frame.docWin.end, columns: columns, ok: model.docOk, scanned: model.docScanned, scanning: model.docScanning, hasDirs: model.docHasDirs })) : (_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark })), model.deckMode === 'doc' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, sessionPriority: model.sessionPriority, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'doc' && model.selDoc ? (_jsx(PreviewPane, { kind: "doc", row: model.selDoc, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
357
359
  }
358
360
  const cwd = process.cwd();
@@ -16,6 +16,7 @@ import { useEffect, useMemo, useState } from 'react';
16
16
  import { loadArchivableDays, loadIdentity, loadLaunch } from './config.js';
17
17
  import { useArchivable, useCommitTimes, useDirtyFolders, useDocScan, useEpicHierarchy, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
18
18
  import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
19
+ import { aliveIds, restoreSet } from './live-marks.js';
19
20
  import { cycleDocView, docCounts as deriveDocCounts, docView, selectDocRows, } from './doc-views.js';
20
21
  import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
21
22
  import { docsRootName } from './tasks.js';
@@ -129,7 +130,7 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
129
130
  // di STATO in fondo al frame (il feedback di un'azione). Due concetti diversi
130
131
  // a una lettera di distanza sarebbero una trappola di lettura — e di
131
132
  // scrittura, visto che `setNote` compare in quasi ogni ramo.
132
- const { sessions, bindings, forkOf, pinned, notes: sessionNotes, priority: sessionPriority, live, reload: reloadSessions, } = useSessions(cwd, projectCore);
133
+ const { sessions, bindings, forkOf, pinned, notes: sessionNotes, priority: sessionPriority, live, liveMarks, restoring, holdLive, reload: reloadSessions, } = useSessions(cwd, projectCore);
133
134
  const [focus, setFocus] = useState('tasks');
134
135
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
135
136
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
@@ -317,6 +318,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
317
318
  // non quella a schermo: il contatore di una voce del catalogo non può
318
319
  // dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
319
320
  const liveCount = useMemo(() => assembled.rows.filter((r) => live.has(r.sessionId)).length, [assembled, live]);
321
+ // T181 — le conversazioni che il bottone di restore riapre, nell'ordine di
322
+ // riapertura. L'insieme vivo è lo stesso incrocio `live ∩ sessions` che la
323
+ // convergenza usa per scrivere la marca (`aliveIds`): due definizioni
324
+ // diverse farebbero comparire il bottone accanto a una conversazione che per
325
+ // la marca è viva, o il contrario.
326
+ const restorable = useMemo(() => restoreSet({ marked: liveMarks, sessions, alive: aliveIds(live, sessions), restoring }), [liveMarks, sessions, live, restoring]);
320
327
  const sessionCounts = {
321
328
  total: assembled.contextTotal,
322
329
  live: liveCount,
@@ -544,6 +551,10 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
544
551
  live,
545
552
  reloadSessions,
546
553
  detail,
554
+ // T181 — restore delle conversazioni dell'ultimo insieme vivo
555
+ restorable,
556
+ restoring,
557
+ holdLive,
547
558
  // progetto
548
559
  launch,
549
560
  identity,
package/dist/frame.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // budget anche quando il render esce presto su una schermata sostitutiva:
17
17
  // aritmetica su array già in memoria, e `layoutBudget` accetta ogni `Mode`.
18
18
  import { docHeaderParts, headerItems, inboxHeaderParts, sessionHeaderParts, taskHeaderParts, } from './pane-header.js';
19
- import { FRAME_TEXT_COL, HINT_ROW, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
19
+ import { FRAME_TEXT_COL, HEADER_ROW, HINT_ROW, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
20
20
  import { cellWidth, launchLegend, LAUNCH_SEP } from './config.js';
21
21
  import { layoutBudget, windowRange } from './viewport.js';
22
22
  import { sanitize, termWidth } from './width.js';
@@ -85,6 +85,10 @@ export function deckLegend(state) {
85
85
  // spinnarla); basta il focus sul pane e una selezione.
86
86
  const canPin = state.focus === 'sessions' && state.hasSessionId;
87
87
  return sanitize([
88
+ // T181/P5 — in TESTA alla legenda, perché la legenda tronca dalla coda e
89
+ // questa voce vale proprio nel momento in cui il deck si è appena
90
+ // riaperto: è il primo gesto che si vuole fare, non uno fra venti.
91
+ ...(state.restore ? ['^T restore'] : []),
88
92
  ...(canSpawn
89
93
  ? ['⏎ detail', '^K/^P/^R spawn']
90
94
  : canResume
@@ -316,6 +320,39 @@ export function indicatorRow(state, columns) {
316
320
  const start = FRAME_TEXT_COL + Math.max(0, (columns || 80) - 4) - width;
317
321
  return { segments, text, regions: rowRegions(segments, INDICATOR_SEP, start), width };
318
322
  }
323
+ /**
324
+ * T181 — il bottone della testata: `[ restore 3 sessions ]`, a sinistra di
325
+ * risoluzione e versione.
326
+ *
327
+ * `key` come gli indicatori della riga legenda: la COMBO col prefisso `^`, e il
328
+ * click rientra dalla porta della tastiera premendola (`HEADER_ROW` in
329
+ * `mouse.ts`). La combo non sta nel testo del bottone (P5 preflight): la dice la
330
+ * legenda, come per gli indicatori di T134.
331
+ *
332
+ * Tre forme, dalla più lunga, e vince la prima che lascia al project status a
333
+ * sinistra almeno il pavimento di `headlineWidth`: il bottone entra nel budget
334
+ * della testata, e una forma che non ci sta spingerebbe la riga oltre il bordo.
335
+ * Nessuna delle tre ci sta → nessun bottone; `^T` resta valido, perché il
336
+ * tasto non dipende da cosa entra a schermo.
337
+ */
338
+ /** Il pavimento del budget del project status: sotto, non resta niente di
339
+ * leggibile. Il bottone di restore lo rispetta (`restoreButton`). */
340
+ const HEADLINE_MIN = 4;
341
+ export const RESTORE_KEY = '^t';
342
+ export const HEADER_BUTTON_SEP = ' ';
343
+ export function restoreButtonForms(n) {
344
+ return [`[ restore ${n} ${n === 1 ? 'session' : 'sessions'} ]`, `[ restore ${n} ]`, `[ ${n} ]`];
345
+ }
346
+ export function restoreButton(n, columns, headerRight) {
347
+ if (n <= 0)
348
+ return null;
349
+ for (const form of restoreButtonForms(n)) {
350
+ const block = termWidth(form) + termWidth(HEADER_BUTTON_SEP) + termWidth(headerRight);
351
+ if ((columns || 80) - 4 - block - 1 >= HEADLINE_MIN)
352
+ return form;
353
+ }
354
+ return null;
355
+ }
319
356
  /**
320
357
  * Il budget d'altezza e le finestre di rendering.
321
358
  *
@@ -381,6 +418,21 @@ export function frameGeometry(input) {
381
418
  docRows: windowDoc.length,
382
419
  mode: input.deckMode,
383
420
  };
421
+ // Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
422
+ // dentro un terminale vede solo la griglia di caratteri) e versione. La
423
+ // parte sinistra della testata riceve un budget derivato da questa stringa e
424
+ // non da una lunghezza fissa: la risoluzione cambia a ogni resize, quindi
425
+ // anche la larghezza che occupa.
426
+ const headerRight = `${input.columns}×${input.rows} · v${VERSION}`;
427
+ // T181 — il bottone sta a sinistra di `headerRight` nello stesso blocco
428
+ // ancorato a destra, quindi la sua prima colonna si ricava per sottrazione
429
+ // dal bordo, come per gli indicatori (`indicatorRow`).
430
+ const headerButton = restoreButton(input.restoreCount, input.columns, headerRight);
431
+ const headerBlock = headerButton ? headerButton + HEADER_BUTTON_SEP + headerRight : headerRight;
432
+ const blockStart = FRAME_TEXT_COL + Math.max(0, (input.columns || 80) - 4) - termWidth(headerBlock);
433
+ const headerRegions = headerButton
434
+ ? [{ key: RESTORE_KEY, start: blockStart, end: blockStart + termWidth(headerButton) - 1 }]
435
+ : [];
384
436
  return {
385
437
  budget,
386
438
  taskWin,
@@ -392,18 +444,16 @@ export function frameGeometry(input) {
392
444
  docWin,
393
445
  windowDoc,
394
446
  listGeometry,
395
- // Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
396
- // dentro un terminale vede solo la griglia di caratteri) e versione. La
397
- // parte sinistra della testata riceve un budget derivato da questa stringa e
398
- // non da una lunghezza fissa: la risoluzione cambia a ogni resize, quindi
399
- // anche la larghezza che occupa.
400
- headerRight: `${input.columns}×${input.rows} · v${VERSION}`,
447
+ headerRight,
448
+ headerButton,
449
+ headerBlock,
450
+ headerRegions,
401
451
  launchLine,
402
452
  };
403
453
  }
404
- /** Il budget della testata sinistra, per sottrazione dal segmento destro. */
454
+ /** Il budget della testata sinistra, per sottrazione dal blocco destro. */
405
455
  export function headlineWidth(columns, headerRight) {
406
- return Math.max(4, columns - 4 - termWidth(headerRight) - 1);
456
+ return Math.max(HEADLINE_MIN, columns - 4 - termWidth(headerRight) - 1);
407
457
  }
408
458
  /**
409
459
  * T134 — il budget della legenda tasti, per sottrazione del blocco indicatori
@@ -418,4 +468,4 @@ export function headlineWidth(columns, headerRight) {
418
468
  export function legendWidth(columns, indicatorW) {
419
469
  return Math.max(4, (columns || 80) - 4 - indicatorW - termWidth(INDICATOR_SEP));
420
470
  }
421
- export { HINT_ROW, LAUNCH_ROW };
471
+ export { HEADER_ROW, HINT_ROW, LAUNCH_ROW };
package/dist/hooks.js CHANGED
@@ -10,6 +10,7 @@ import { discoverProjectSessions } from './sessions.js';
10
10
  import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
11
  import { loadSessionIndex } from './task-index.js';
12
12
  import { fillSidecarGaps } from './session-meta.js';
13
+ import { aliveIds, liveMarkPlan, writeLiveMarks, RESTORE_HOLD_MS } from './live-marks.js';
13
14
  import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
14
15
  import { commitTimes } from './commit-times.js';
15
16
  import { scanEpicHierarchy, EMPTY_EPIC_HIERARCHY } from './epic-hierarchy.js';
@@ -147,6 +148,9 @@ export function useEpicHierarchy(tasksDir) {
147
148
  // vive già nel modello (`projectCore`), e una seconda copia toglierebbe dal
148
149
  // titolo scritto nel sidecar una stringa diversa da quella che la lista toglie
149
150
  // dalle proprie righe.
151
+ //
152
+ // T181 — ed è il posto dove CONVERGE la marca `live` del sidecar, per la stessa
153
+ // ragione: l'insieme vivo e la marca su disco sono in mano nello stesso giro.
150
154
  export function useSessions(projectRoot, core) {
151
155
  const [state, setState] = useState({
152
156
  sessions: [],
@@ -156,13 +160,24 @@ export function useSessions(projectRoot, core) {
156
160
  notes: new Map(),
157
161
  priority: new Set(),
158
162
  live: new Map(),
163
+ liveMarks: new Map(),
164
+ restoring: false,
159
165
  });
160
166
  // T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
161
167
  // prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
162
168
  // richiama senza risottoscrivere l'intervallo.
163
169
  const reloadRef = useRef(() => { });
170
+ // T181 — le conversazioni che il restore sta riaprendo, con la scadenza della
171
+ // tenuta. In un ref e non nell'effect perché chi le scrive è un gesto
172
+ // (`holdLive`, dal restore), non il giro del poll.
173
+ const holds = useRef(new Map());
164
174
  useEffect(() => {
165
175
  let lastSig = '';
176
+ // T181 — l'isteresi del ritiro (P3 preflight): le marcate viste mancare al
177
+ // giro precedente. È l'unico stato in memoria della convergenza, e parte
178
+ // vuoto a ogni avvio: un deck che riparte senza vive non scrive niente, e
179
+ // con delle vive rimisura da capo — un ritiro ritardato di un tick, mai perso.
180
+ let missingOnce = new Set();
166
181
  const reload = () => {
167
182
  let sessions;
168
183
  let index;
@@ -180,6 +195,7 @@ export function useSessions(projectRoot, core) {
180
195
  priority: new Set(),
181
196
  titles: new Map(),
182
197
  models: new Map(),
198
+ liveMarks: new Map(),
183
199
  };
184
200
  }
185
201
  // T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
@@ -222,6 +238,40 @@ export function useSessions(projectRoot, core) {
222
238
  models: index.models,
223
239
  core,
224
240
  });
241
+ // T181 — la marca `live`: il piano dei buchi, poi la scrittura. Fuori dal
242
+ // gate della signature come i campi derivati sopra, e per la stessa
243
+ // ragione: un append fallito si ritenta al giro dopo, e la signature
244
+ // invariata non lo farebbe mai ripartire.
245
+ //
246
+ // La tenuta del restore si pota qui, prima del piano: una conversazione
247
+ // tenuta che è già viva non ha più niente da cui essere protetta, e una
248
+ // tenuta scaduta torna sotto l'isteresi normale.
249
+ const alive = aliveIds(live, sessions);
250
+ const now = Date.now();
251
+ for (const [id, until] of holds.current) {
252
+ if (until <= now || alive.includes(id))
253
+ holds.current.delete(id);
254
+ }
255
+ const plan = liveMarkPlan({
256
+ alive,
257
+ marked: index.liveMarks,
258
+ missingOnce,
259
+ held: new Set(holds.current.keys()),
260
+ });
261
+ missingOnce = plan.missing;
262
+ let liveMarks = index.liveMarks;
263
+ // Rilettura solo quando ha scritto, cioè a ogni cambio dell'insieme vivo e
264
+ // mai a regime: la mappa a schermo è quella su disco, rango compreso, e
265
+ // non una seconda derivazione del rango in memoria che potrebbe divergere.
266
+ if (writeLiveMarks(projectRoot, plan) > 0) {
267
+ try {
268
+ liveMarks = loadSessionIndex(projectRoot).liveMarks;
269
+ }
270
+ catch {
271
+ // rilettura fallita: resta la mappa di prima, il giro dopo la rifà.
272
+ }
273
+ }
274
+ const restoring = holds.current.size > 0;
225
275
  // La signature copre anche fork, pin, note e marca di priorità: un record
226
276
  // di lineage, un toggle di pin, una nota appena scritta o un 🚨 acceso
227
277
  // cambiano la lista renderizzata, quindi devono forzare il re-render come
@@ -238,18 +288,35 @@ export function useSessions(projectRoot, core) {
238
288
  '#' +
239
289
  [...priority].sort().join(',') +
240
290
  '#' +
241
- liveSig(live);
291
+ liveSig(live) +
292
+ // T181 — la marca e il restore in corso decidono il bottone in testata.
293
+ '#' +
294
+ [...liveMarks.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
295
+ (restoring ? '#restoring' : '');
242
296
  if (sig === lastSig)
243
297
  return;
244
298
  lastSig = sig;
245
- setState({ sessions, bindings, forkOf, pinned, notes, priority, live });
299
+ setState({ sessions, bindings, forkOf, pinned, notes, priority, live, liveMarks, restoring });
246
300
  };
247
301
  reloadRef.current = reload;
248
302
  reload();
249
303
  const id = setInterval(reload, POLL_MS);
250
304
  return () => clearInterval(id);
251
305
  }, [projectRoot, core]);
252
- return { ...state, reload: () => reloadRef.current() };
306
+ /**
307
+ * T181 — tiene fuori dal ritiro le conversazioni che il restore sta per
308
+ * riaprire, per `RESTORE_HOLD_MS` da adesso. Il restore la chiama due volte:
309
+ * su tutte all'avvio, così il bottone sparisce subito e un secondo `^T` non
310
+ * riapre tutto una seconda volta; e su ognuna quando parte la sua tab, così la
311
+ * scadenza si conta da lì e non dall'inizio di una serie che può durare.
312
+ */
313
+ const holdLive = useCallback((ids) => {
314
+ const until = Date.now() + RESTORE_HOLD_MS;
315
+ for (const id of ids)
316
+ holds.current.set(id, until);
317
+ reloadRef.current();
318
+ }, []);
319
+ return { ...state, reload: () => reloadRef.current(), holdLive };
253
320
  }
254
321
  // T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
255
322
  //
package/dist/input.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { useEffect, useRef } from 'react';
21
21
  import { useApp, useInput } from 'ink';
22
22
  import { hitRegion, isWheel, listHit, takeMouse, wheelDir, WHEEL_LINES, } from './mouse.js';
23
- import { HINT_ROW, LAUNCH_ROW } from './frame.js';
23
+ import { HEADER_ROW, HINT_ROW, LAUNCH_ROW } from './frame.js';
24
24
  import { moveSelection } from './session-list.js';
25
25
  import { captures, inertNote, scrolls, taskModeKey, } from './input-modes.js';
26
26
  import { LEFT_PANE, META_ROWS, QUIT_WINDOW_MS, RIGHT_PANE } from './model.js';
@@ -186,6 +186,14 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
186
186
  onKey(hit.slice(1), { ...NO_MODIFIERS, ctrl: true });
187
187
  return;
188
188
  }
189
+ if (ev.row === HEADER_ROW) {
190
+ // T181 — il bottone di restore, stessa porta degli indicatori: la sua
191
+ // chiave è la combo `^t`, e il click la preme invece di chiamare l'azione.
192
+ const hit = hitRegion(frame.headerRegions, ev.col);
193
+ if (hit)
194
+ onKey(hit.slice(1), { ...NO_MODIFIERS, ctrl: true });
195
+ return;
196
+ }
189
197
  onListClick(ev);
190
198
  }
191
199
  /**
@@ -386,6 +394,14 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
386
394
  // byte non arrivasse il test fallirebbe invece di lasciare il dubbio.
387
395
  overlays.spawn.openPage();
388
396
  }
397
+ else if (input === 't') {
398
+ // T181/P4 preflight — `^T` riapre le conversazioni dell'ultimo insieme
399
+ // vivo. La `t` nuda apre un terminale: stesso schema «una lettera, due
400
+ // bersagli» di `^F`/`f`, che il ramo `ctrl` anteposto rende sicuro. Non
401
+ // è un tasto del mondo task (`TASK_MODE_KEYS`): il bottone sta in
402
+ // testata, visibile in entrambi i modi del deck.
403
+ actions.restoreSessions();
404
+ }
389
405
  else if (input === 'b') {
390
406
  // T160 — `^B` scambia il MODO del deck, cioè entrambi gli slot insieme.
391
407
  // Erede del tasto di T134, che scambiava il solo pane destro: la
@@ -0,0 +1,166 @@
1
+ // T181 — la MARCA `live` del sidecar: quali conversazioni stavano nell'ultimo
2
+ // insieme vivo del progetto, e quindi quali riapre il bottone di restore.
3
+ //
4
+ // Stesso schema di `session-meta.ts`: CONVERGENZA, non evento. A ogni tick del
5
+ // poll il deck confronta la marca su disco con le vive del registro e appende
6
+ // solo i buchi. Nessuno stato sul «giro precedente» a parte l'isteresi qui
7
+ // sotto: un deck che riparte converge dal sidecar com'è, e un tick perso si
8
+ // recupera al tick dopo.
9
+ //
10
+ // Il giudizio è separato dalla scrittura (`liveMarkPlan` decide,
11
+ // `writeLiveMarks` scrive) per la stessa ragione di `sidecarGaps`: la parte non
12
+ // ovvia è la convergenza, e un piano che non converge appenderebbe a ogni tick
13
+ // del poll, per sempre, senza nessun errore a dirlo.
14
+ //
15
+ // Nessun import da ink/react: il chiamante è un hook, questo modulo no.
16
+ import { NO_SPAWN } from './spawn.js';
17
+ import { appendLive } from './task-index.js';
18
+ /**
19
+ * L'insieme vivo della feature: le vive del registro che hanno anche una
20
+ * `Session` nel deck (P2 preflight), ordinate per avvio del processo.
21
+ *
22
+ * L'incrocio è lo stesso del contatore «vive» e del rollup (`rollupChildren` in
23
+ * `deck-model.ts`), ed è l'unico insieme che `resumeSession` sa riprendere: lo
24
+ * spawn parte con cwd = root del progetto e prende il modello dalla `Session`.
25
+ * Ne restano fuori due casi, entrambi di proposito:
26
+ *
27
+ * - una conversazione aperta in una SOTTOCARTELLA del progetto — il registro
28
+ * la include (filtro `cwd` per prefisso), ma il transcript sta sotto un altro
29
+ * hash di progetto e il deck non ha una `Session` per lei. Non si marca, e la
30
+ * sua presenza non azzera l'insieme da ripristinare;
31
+ * - una conversazione appena nata, o appena passata da un `/clear` — il
32
+ * registro la conosce prima del suo primo record di transcript. Entra al
33
+ * primo prompt.
34
+ *
35
+ * L'ordine per `startedAt` decide il rango di chi entra nello stesso tick: è
36
+ * l'ordine in cui le tab sono state aperte, quindi quello che il restore deve
37
+ * ricalcare. A parità (registro senza il campo) decide il `sessionId`, perché un
38
+ * piano deve essere deterministico per essere testabile.
39
+ */
40
+ export function aliveIds(live, sessions) {
41
+ const known = new Set(sessions.map((s) => s.sessionId));
42
+ return [...live.entries()]
43
+ .filter(([id]) => known.has(id))
44
+ .sort(([a, x], [b, y]) => x.startedAt - y.startedAt || (a < b ? -1 : a > b ? 1 : 0))
45
+ .map(([id]) => id);
46
+ }
47
+ /**
48
+ * Quanto a lungo una conversazione in riapertura resta tenuta, contato dalla
49
+ * partenza della SUA tab.
50
+ *
51
+ * Il conto che lo regge: il registro vivo scrive il file del processo
52
+ * nell'ordine del secondo dopo l'avvio di `claude` (`startedAt` contro
53
+ * `procStart` dello stesso file), più il tempo di `deck-run` e della tab. Dieci
54
+ * secondi coprono quel ritardo con un margine largo, e restano brevi abbastanza
55
+ * da ritirare in tempi
56
+ * umani la marca di una conversazione che non si riaccende — un `--resume` che
57
+ * fallisce, una tab chiusa subito. Scaduta la tenuta, vale l'isteresi normale.
58
+ */
59
+ export const RESTORE_HOLD_MS = 10_000;
60
+ /**
61
+ * Il piano di questo tick.
62
+ *
63
+ * **Insieme vivo VUOTO → nessun append, e l'isteresi riparte.** È la regola
64
+ * dell'utente, «la traccia si sovrascrive solo quando ci sono conversazioni
65
+ * vive»: è ciò che fa sopravvivere l'ultimo insieme alla chiusura del deck,
66
+ * della finestra o della macchina, e che il bottone di restore legge alla
67
+ * riapertura. Svuotare `missing` qui invece di portarlo avanti allunga il
68
+ * ritiro, mai lo accorcia: il prezzo di una scelta prudente è un tick di
69
+ * ritardo, il prezzo di quella opposta sarebbe ritirare al primo tick non vuoto
70
+ * una conversazione vista mancare una volta sola, magari molto prima.
71
+ *
72
+ * **Insieme vivo non vuoto:**
73
+ * - ogni viva non marcata ENTRA, al primo tick in cui la si vede;
74
+ * - ogni marcata assente ESCE solo al SECONDO tick consecutivo di assenza
75
+ * (P3 preflight). Il primo la mette in `missing` e basta. Serve perché
76
+ * chiudere una finestra Ptyxis con N tab fa uscire N processi con tempi
77
+ * propri: un tick caduto in quella finestra vede un insieme non vuoto ma
78
+ * dimagrito, e senza isteresi ritirerebbe la marca alle già morte proprio
79
+ * mentre l'insieme stava per diventare vuoto — cioè l'insieme da salvare;
80
+ * - una marcata TENUTA (`held`) non esce e non conta come assente: il restore
81
+ * accende le tab una alla volta, e la prima viva non deve far ritirare le
82
+ * altre che stanno ancora partendo.
83
+ *
84
+ * Una viva già marcata non produce niente: è la convergenza — un sidecar
85
+ * allineato dà un piano vuoto, e il giro dopo non trova niente da fare.
86
+ */
87
+ export function liveMarkPlan(input) {
88
+ const { alive, marked, missingOnce, held } = input;
89
+ if (alive.length === 0)
90
+ return { enter: [], leave: [], missing: new Set() };
91
+ const aliveSet = new Set(alive);
92
+ const enter = alive.filter((id) => !marked.has(id));
93
+ const leave = [];
94
+ const missing = new Set();
95
+ for (const id of marked.keys()) {
96
+ if (aliveSet.has(id) || held.has(id))
97
+ continue;
98
+ if (missingOnce.has(id))
99
+ leave.push(id);
100
+ else
101
+ missing.add(id);
102
+ }
103
+ return { enter, leave, missing };
104
+ }
105
+ /**
106
+ * Scrive il piano. Ritorna quanti record ha appeso — zero è l'esito normale a
107
+ * regime, non un fallimento.
108
+ *
109
+ * `NO_SPAWN` frena anche questa scrittura, come `fillSidecarGaps` e per la
110
+ * stessa ragione: il gate su pseudo-terminale avvia il deck vero con cwd la
111
+ * project root del cappello, e senza freno ogni run della suite appenderebbe la
112
+ * marca nel sidecar reale di chi la lancia.
113
+ *
114
+ * Gli ingressi prima delle uscite: un tick che li porta entrambi è il passaggio
115
+ * da un insieme all'altro (`/clear`, una tab chiusa mentre un'altra si apre), e
116
+ * nell'ordine inverso un lettore che cade fra i due append vedrebbe per un
117
+ * istante un insieme più piccolo di entrambi.
118
+ *
119
+ * Un sidecar non scrivibile non è un errore da mostrare: il giro dopo trova gli
120
+ * stessi buchi e riprova.
121
+ */
122
+ export function writeLiveMarks(projectRoot, plan) {
123
+ if (NO_SPAWN)
124
+ return 0;
125
+ let scritti = 0;
126
+ try {
127
+ for (const id of plan.enter) {
128
+ appendLive(projectRoot, id, true);
129
+ scritti++;
130
+ }
131
+ for (const id of plan.leave) {
132
+ appendLive(projectRoot, id, false);
133
+ scritti++;
134
+ }
135
+ }
136
+ catch {
137
+ // sidecar non scrivibile: si riprova al giro dopo, niente a schermo.
138
+ }
139
+ return scritti;
140
+ }
141
+ /**
142
+ * Le conversazioni che il bottone riapre, nell'ordine di riapertura (rango di
143
+ * entrata crescente, dalla più vecchia — P7 preflight). Vuoto = niente bottone.
144
+ *
145
+ * Vuoto in due casi oltre alla marca vuota: c'è almeno una viva (il bottone
146
+ * vale solo finché non ce n'è nessuna, e appena una è viva la marca si
147
+ * riallinea a lei), oppure un restore è già in corso — senza, un secondo
148
+ * `^T` nei secondi prima che la prima tab si accenda riaprirebbe tutto due
149
+ * volte.
150
+ *
151
+ * Solo le marcate che hanno una `Session` (P2): una marcata senza transcript —
152
+ * cancellato, o di un altro hash di progetto — non si conta e non si segnala.
153
+ * Il suo `live:false` arriva al primo tick con delle vive, come per ogni altra
154
+ * marcata che non lo è.
155
+ */
156
+ export function restoreSet(input) {
157
+ if (input.alive.length > 0 || input.restoring || input.marked.size === 0)
158
+ return [];
159
+ const byId = new Map(input.sessions.map((s) => [s.sessionId, s]));
160
+ return [...input.marked.entries()]
161
+ .sort((a, b) => a[1] - b[1])
162
+ .flatMap(([id]) => {
163
+ const s = byId.get(id);
164
+ return s ? [s] : [];
165
+ });
166
+ }
@@ -89,6 +89,7 @@ export function parseLiveEntry(content, pid) {
89
89
  status: d.status === 'busy' ? 'busy' : 'idle',
90
90
  name: typeof d.name === 'string' ? d.name : '',
91
91
  cwd,
92
+ startedAt: typeof d.startedAt === 'number' ? d.startedAt : 0,
92
93
  },
93
94
  procStart,
94
95
  };
package/dist/mouse.js CHANGED
@@ -207,6 +207,14 @@ export const LAUNCH_ROW = 4;
207
207
  * tastiera con la combo che l'indicatore rappresenta.
208
208
  */
209
209
  export const HINT_ROW = LAUNCH_ROW - 1;
210
+ /**
211
+ * T181 — riga della TESTATA (project status a sinistra, risoluzione e versione
212
+ * a destra), subito sotto il bordo superiore. Entra nell'hit-test col bottone di
213
+ * restore, che le sta a sinistra di risoluzione e versione; come le altre due
214
+ * righe fisse è incondizionata in modalità normale, e il click rientra dalla
215
+ * porta della tastiera con la combo che il bottone rappresenta.
216
+ */
217
+ export const HEADER_ROW = HINT_ROW - 1;
210
218
  /** Colonna (1-based) del primo carattere di testo dentro il box esterno:
211
219
  * bordo + `paddingX={1}`. */
212
220
  export const FRAME_TEXT_COL = 3;
package/dist/task-edit.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // isolata in `writeTaskEdit`, così la grammatica delle sostituzioni è testabile
4
4
  // senza toccare il filesystem.
5
5
  import { readFileSync, writeFileSync } from 'node:fs';
6
- import { TASK_ID_RE, findTaskFile, headerColumns } from './tasks.js';
6
+ import { TASK_ID_RE, findTaskFile, overviewRows } from './tasks.js';
7
7
  // Il task file scrive la priorità per NOME (`- **Priority**: Med`), tasks.md la
8
8
  // scrive come GLIFO (colonna Pri). Due rappresentazioni dello stesso valore →
9
9
  // due mappe, entrambe keyed sul nome canonico di view.ts (unica fonte del rango).
@@ -83,10 +83,13 @@ export function initialDetail(current, prog, date = today()) {
83
83
  * toccate restano i token grezzi originali: nessun re-flow della tabella, il
84
84
  * diff resta di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
85
85
  *
86
- * Le posizioni vengono dall'HEADER (`headerColumns`, stessa fonte di
87
- * `parseTasks`), mai da un indice cablato: qui si SCRIVE su disco, quindi una
88
- * colonna in più o in meno non produrrebbe una lettura sbagliata ma un titolo
89
- * sovrascritto col glifo. Header assente → `ok:false`, non si tira a indovinare.
86
+ * Le righe candidate vengono da `overviewRows`, lo stesso confine di
87
+ * `parseTasks`: solo dentro la Tasks Overview, con le colonne del suo HEADER e
88
+ * mai un indice cablato. Qui si SCRIVE su disco, quindi senza confine una riga
89
+ * cross-deps con lo stesso id (`| T07 | — | ✔️T08 |`) sopra la tabella verrebbe
90
+ * riscritta al posto della riga task, con Pri/Prog/titolo sopra le celle Parent e
91
+ * Cross Deps; e una colonna in più o in meno darebbe un titolo sovrascritto col
92
+ * glifo. Header assente → nessuna riga → `ok:false`, non si tira a indovinare.
90
93
  *
91
94
  * `desc` undefined ≠ `desc` vuota: la prima non tocca la cella (chiamata
92
95
  * legacy, solo pri/prog), la seconda la svuota davvero.
@@ -104,23 +107,14 @@ export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
104
107
  if (!TASK_ID_RE.test(id))
105
108
  return { content, ok: false };
106
109
  const lines = content.split('\n');
107
- const cols = lines.reduce((found, l) => found ?? headerColumns(l), null);
108
- if (!cols)
109
- return { content, ok: false };
110
- let ok = false;
111
- const out = lines.map((line) => {
112
- if (ok)
113
- return line;
114
- if (!line.trim().startsWith('|'))
115
- return line;
110
+ for (const { index, line, id: rowId, cols } of overviewRows(lines)) {
111
+ if (rowId !== id)
112
+ continue;
116
113
  const cells = line.split('|');
117
- if ((cells[1] ?? '').trim() !== id)
118
- return line;
119
114
  // Serve la cella desc PIÙ la coda dopo il `|` finale: senza la coda lo
120
115
  // splice inserirebbe invece di sostituire.
121
116
  if (cells.length < cols.desc + 2)
122
- return line; // riga malformata → non toccarla
123
- ok = true;
117
+ continue; // riga malformata → non toccarla
124
118
  cells[cols.pri] = ` ${priGlyph} `;
125
119
  cells[cols.prog] = ` ${progGlyph} `;
126
120
  if (desc !== undefined) {
@@ -128,9 +122,10 @@ export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
128
122
  // ben formata) e va conservata, o la riga perderebbe il bordo destro.
129
123
  cells.splice(cols.desc, cells.length - cols.desc - 1, ` ${sanitizeCell(desc)} `);
130
124
  }
131
- return cells.join('|');
132
- });
133
- return { content: ok ? out.join('\n') : content, ok };
125
+ lines[index] = cells.join('|');
126
+ return { content: lines.join('\n'), ok: true };
127
+ }
128
+ return { content, ok: false };
134
129
  }
135
130
  /**
136
131
  * Rende un testo scrivibile dentro una CELLA di tabella markdown. Due caratteri
@@ -62,6 +62,16 @@ export function appendSessionMeta(projectRoot, sessionId, fields) {
62
62
  return;
63
63
  appendSessionRecord(projectRoot, rec);
64
64
  }
65
+ /**
66
+ * T181 — entrata (`true`) o uscita (`false`) di una conversazione dall'insieme
67
+ * vivo: stessa forma di `appendPin`. Il nome del campo è un contratto di
68
+ * formato come `priority`: i lettori esterni (compass, `announce-state.sh`) lo
69
+ * ignorano perché leggono i campi per nome, e rinominarlo dopo vorrebbe una
70
+ * migrazione dei file già scritti.
71
+ */
72
+ export function appendLive(projectRoot, sessionId, live) {
73
+ appendSessionRecord(projectRoot, { sessionId, live });
74
+ }
65
75
  // Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
66
76
  // ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
67
77
  // Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
@@ -75,14 +85,16 @@ export function loadSessionIndex(projectRoot) {
75
85
  const priority = new Set();
76
86
  const titles = new Map();
77
87
  const models = new Map();
88
+ const liveMarks = new Map();
78
89
  let content;
79
90
  try {
80
91
  content = readFileSync(taskIndexPath(projectRoot), 'utf8');
81
92
  }
82
93
  catch {
83
- return { bindings, forkOf, pinned, notes, priority, titles, models };
94
+ return { bindings, forkOf, pinned, notes, priority, titles, models, liveMarks };
84
95
  }
85
96
  let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
97
+ let liveOrder = 0; // T181 — idem per i record live:true → rango di entrata
86
98
  for (const line of content.split('\n')) {
87
99
  if (!line.trim())
88
100
  continue;
@@ -157,10 +169,20 @@ export function loadSessionIndex(projectRoot) {
157
169
  else
158
170
  models.delete(d.sessionId);
159
171
  }
172
+ // T181 — stesso last-wins di `pinned`, rango compreso. Un `live:true` su
173
+ // una conversazione già marcata RIASSEGNA il rango: la convergenza non lo
174
+ // scrive mai (appende solo i buchi), quindi succede solo con due deck
175
+ // sullo stesso progetto, e l'effetto è spostarla in coda al restore.
176
+ if (typeof d.live === 'boolean') {
177
+ if (d.live)
178
+ liveMarks.set(d.sessionId, liveOrder++);
179
+ else
180
+ liveMarks.delete(d.sessionId);
181
+ }
160
182
  }
161
183
  catch {
162
184
  // riga corrotta → skip
163
185
  }
164
186
  }
165
- return { bindings, forkOf, pinned, notes, priority, titles, models };
187
+ return { bindings, forkOf, pinned, notes, priority, titles, models, liveMarks };
166
188
  }
package/dist/tasks.js CHANGED
@@ -59,31 +59,60 @@ export function headerColumns(line) {
59
59
  return null;
60
60
  return { pri, prog, desc: prog + 1 };
61
61
  }
62
- // Estrae le righe `| Tnn | Pri | Prog | Task |` della Tasks Overview. Righe
63
- // header/separatore non matchano `^T\d+$` → scartate.
64
- //
65
- // Le righe si parsano solo DOPO aver incontrato l'header: senza indici derivati
66
- // non si tira a indovinare, la lista resta vuota. Un fallimento visibile invece
67
- // di una cella letta al posto sbagliato.
68
- export function parseTasks(content) {
69
- const tasks = [];
62
+ /**
63
+ * Le righe task della Tasks Overview: FONTE UNICA del confine della tabella,
64
+ * condivisa da `parseTasks` (lettura) e `updateTasksMdRow` (scrittura su disco),
65
+ * come `TASK_ID_RE` e `headerColumns` — due confini scritti in due posti
66
+ * divergerebbero, e quello dello scrittore è quello che fa danno.
67
+ *
68
+ * La tabella apre all'header (`headerColumns`) e chiude alla PRIMA riga che non
69
+ * inizia con `|`; un secondo header ne riapre una, con le proprie colonne. Il
70
+ * confine serve perché la prima cella `^T\d+$` non basta a riconoscere una riga
71
+ * task: le righe della tabella cross-deps dell'Execution Plan
72
+ * (`| T07 | — | ✔️T08 |`) hanno la stessa forma appena perdono il 🔒 davanti
73
+ * all'id, e senza confine diventavano task doppie con Pri = cella Parent e
74
+ * Prog = cella Cross Deps.
75
+ *
76
+ * Il titolo `## Tasks Overview` non entra nel criterio: il gate è l'header, così
77
+ * il deck non dipende dal nome della sezione. Il prezzo: una riga vuota o un
78
+ * commento DENTRO la tabella la chiude, e le righe sotto spariscono dal deck
79
+ * senza errore.
80
+ */
81
+ export function overviewRows(lines) {
82
+ const rows = [];
70
83
  let cols = null;
71
- for (const line of content.split('\n')) {
84
+ lines.forEach((line, index) => {
72
85
  const header = headerColumns(line);
73
86
  if (header) {
74
87
  cols = header;
75
- continue;
88
+ return;
76
89
  }
77
90
  if (!cols)
78
- continue;
91
+ return;
79
92
  const t = line.trim();
80
- if (!t.startsWith('|'))
81
- continue;
82
- const cells = t.split('|').map((c) => c.trim());
93
+ if (!t.startsWith('|')) {
94
+ cols = null;
95
+ return;
96
+ }
97
+ // cells[0] = '' (prima del primo |): l'id è la seconda cella. Header e
98
+ // separatore non matchano `^T\d+$` → scartati.
99
+ const id = (t.split('|')[1] ?? '').trim();
100
+ if (TASK_ID_RE.test(id))
101
+ rows.push({ index, line, id, cols });
102
+ });
103
+ return rows;
104
+ }
105
+ // Estrae le righe `| Tnn | Pri | Prog | Task |` della Tasks Overview — il
106
+ // confine della tabella sta in `overviewRows`.
107
+ //
108
+ // Le righe si parsano solo DOPO aver incontrato l'header: senza indici derivati
109
+ // non si tira a indovinare, la lista resta vuota. Un fallimento visibile invece
110
+ // di una cella letta al posto sbagliato.
111
+ export function parseTasks(content) {
112
+ const tasks = [];
113
+ for (const { line, id, cols } of overviewRows(content.split('\n'))) {
83
114
  // cells[0] = '' (prima del primo |), cells[last] = '' (dopo l'ultimo |)
84
- const id = cells[1];
85
- if (!TASK_ID_RE.test(id))
86
- continue;
115
+ const cells = line.trim().split('|').map((c) => c.trim());
87
116
  const pri = cells[cols.pri] ?? '';
88
117
  const prog = cells[cols.prog] ?? '';
89
118
  // desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {