@lamemind/loom-deck 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,6 +62,7 @@ Assegnazioni correnti:
62
62
  | modale | `E` | edit priorità/stato della task selezionata (salva + commit) |
63
63
  | modale | `S` | sort chain |
64
64
  | modale | `F` | filtri |
65
+ | immediata | `f` | **forka** la sessione selezionata (solo col focus sul pane Sessions) |
65
66
  | immediata | `t` | terminale @project-root (surface standard launch) |
66
67
  | immediata | `c` | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
67
68
  | immediata | `w` | salva la vista corrente su disco |
@@ -77,7 +78,30 @@ sono configurate ma non raggiungibili (e la legenda lo dice).
77
78
 
78
79
  `t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa
79
80
  finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
80
- (maiuscola, modale create-task) restano distinte per la regola sopra.
81
+ (maiuscola, modale create-task) restano distinte per la regola sopra — così come
82
+ `f` (fork) e `F` (filtri).
83
+
84
+ ### `f` — forkare una conversazione
85
+
86
+ Il fork rama la sessione selezionata: `claude --resume <origine> --fork-session`
87
+ apre un **sessionId nuovo** con il transcript copiato, lasciando l'origine
88
+ intatta. Serve quando vuoi ripartire da un certo stato senza perdere il ramo
89
+ originale — e siccome i due id sono distinti, non esistono mai due processi che
90
+ scrivono lo stesso file (il vincolo *single-writer* dello store di Claude Code).
91
+
92
+ Il nuovo id lo genera il deck e lo pinna con `--session-id`, per due ragioni:
93
+
94
+ - il ramo **eredita la task** dell'origine (senza id noto in anticipo il fork
95
+ di una sessione scoped comparirebbe come spot);
96
+ - il **lineage** finisce nel sidecar `.claude/loom/session-tasks.jsonl` come
97
+ campo `forkOf`. Serve perché il transcript del fork **non nomina** la sessione
98
+ d'origine da nessuna parte: è una copia verbatim (stessi uuid dei messaggi) e
99
+ `parentUuid` incatena i messaggi dentro un transcript, non le sessioni fra
100
+ loro. Senza quel record un ramo sarebbe una riga gemella dell'originale, di
101
+ cui eredita anche il titolo.
102
+
103
+ Un ramo si riconosce dal marker `⑂` nella lista e dalla riga `⑂ da <id>` nel
104
+ pannello di dettaglio; la sua tab Ptyxis titola `<label> · <task> · fork`.
81
105
 
82
106
  > **Nota di migrazione (0.6.0)**: `c` → **`C`** per creare una task, e le voci
83
107
  > `codium`/`idea` non hanno più una lettera dedicata (erano `C`/`I` hardcoded):
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
4
- import { useState, useEffect, useMemo } from 'react';
4
+ import { useState, useEffect, useMemo, useRef } from 'react';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { statSync } from 'node:fs';
7
7
  import { randomUUID } from 'node:crypto';
@@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
11
11
  import { discoverProjectSessions } from './sessions.js';
12
- import { appendTaskBinding, loadTaskBindings } from './task-index.js';
12
+ import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
13
+ import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
13
14
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
14
15
  import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
15
16
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
@@ -63,6 +64,27 @@ function spawnDeckResume(taskId, cwd, sessionId) {
63
64
  child.unref();
64
65
  return child;
65
66
  }
67
+ // T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
68
+ // <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
69
+ // id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
70
+ // due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
71
+ // Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
72
+ // conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
73
+ // scrivere né il binding task né il record di lineage (il transcript del fork
74
+ // non nomina da nessuna parte la sessione d'origine).
75
+ function spawnDeckFork(taskId, cwd, originId, newId) {
76
+ const args = [
77
+ ...(taskId ? [taskId] : ['--no-task']),
78
+ '--resume',
79
+ originId,
80
+ '--fork',
81
+ '--session-id',
82
+ newId,
83
+ ];
84
+ const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
85
+ child.unref();
86
+ return child;
87
+ }
66
88
  // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
67
89
  // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
68
90
  // task non c'è nulla da legare). Funzione separata e non un parametro opzionale
@@ -240,33 +262,48 @@ function useSessions(projectRoot) {
240
262
  const [state, setState] = useState({
241
263
  sessions: [],
242
264
  bindings: new Map(),
265
+ forkOf: new Map(),
266
+ pinned: new Map(),
243
267
  });
268
+ // T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
269
+ // prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
270
+ // richiama senza risottoscrivere l'intervallo.
271
+ const reloadRef = useRef(() => { });
244
272
  useEffect(() => {
245
273
  let lastSig = '';
246
274
  const reload = () => {
247
275
  let sessions;
248
- let bindings;
276
+ let index;
249
277
  try {
250
278
  sessions = discoverProjectSessions(projectRoot);
251
- bindings = loadTaskBindings(projectRoot);
279
+ index = loadSessionIndex(projectRoot);
252
280
  }
253
281
  catch {
254
282
  sessions = [];
255
- bindings = new Map();
283
+ index = { bindings: new Map(), forkOf: new Map(), pinned: new Map() };
256
284
  }
285
+ const { bindings, forkOf, pinned } = index;
286
+ // La signature copre anche fork e pin: un record di lineage o un toggle di
287
+ // pin appena scritto cambia la lista renderizzata, quindi deve forzare il
288
+ // re-render come farebbe un binding nuovo.
257
289
  const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
258
290
  '#' +
259
- [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',');
291
+ [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
292
+ '#' +
293
+ [...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
294
+ '#' +
295
+ [...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',');
260
296
  if (sig === lastSig)
261
297
  return;
262
298
  lastSig = sig;
263
- setState({ sessions, bindings });
299
+ setState({ sessions, bindings, forkOf, pinned });
264
300
  };
301
+ reloadRef.current = reload;
265
302
  reload();
266
303
  const id = setInterval(reload, POLL_MS);
267
304
  return () => clearInterval(id);
268
305
  }, [projectRoot]);
269
- return state;
306
+ return { ...state, reload: () => reloadRef.current() };
270
307
  }
271
308
  // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
272
309
  // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
@@ -302,6 +339,13 @@ const forceEmojiWidth = normalizeEmoji;
302
339
  // larghi 1 e restano intatti.
303
340
  const CARET = normalizeEmoji('▶ ');
304
341
  const CARET_OFF = ' ';
342
+ // T50 — glifo warning della riga pin-stale: BMP largo 2 senza VS16 → va timbrato
343
+ // (come CARET), altrimenti la riga slitta di una colonna.
344
+ const WARN = normalizeEmoji('⚠');
345
+ // T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
346
+ // largo 1 sia per string-width sia per il terminale → nessun timbro. Corto +
347
+ // wrap="truncate-end" così non va mai a capo nel pane al 50%.
348
+ const SESSION_SEP = '─'.repeat(16);
305
349
  // Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
306
350
  // default: string-width — quindi Ink, sia per il layout sia per il troncamento —
307
351
  // lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
@@ -346,14 +390,17 @@ const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
346
390
  function Deck({ cwd, tasksPath, tasksDir }) {
347
391
  const { exit } = useApp();
348
392
  const { tasks, loadError } = useTasks(tasksPath);
349
- const { sessions, bindings } = useSessions(cwd);
393
+ const { sessions, bindings, forkOf, pinned, reload: reloadSessions } = useSessions(cwd);
350
394
  const [focus, setFocus] = useState('tasks');
351
395
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
352
396
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
353
397
  // grezzo per posizione spawnerebbe la task sbagliata, in silenzio. `null` = la
354
398
  // riga meta "spot", sempre in testa alla lista.
355
399
  const [selId, setSelId] = useState(null);
356
- const [selSession, setSelSession] = useState(0);
400
+ // T50 selezione del pane sessioni KEYED SU sessionId (non indice): la lista
401
+ // a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
402
+ // alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
403
+ const [selSessionId, setSelSessionId] = useState(null);
357
404
  const [note, setNote] = useState('');
358
405
  // Modali: catturano i tasti e corto-circuitano la navigazione normale.
359
406
  // T30 create · T39 sort/filter.
@@ -396,12 +443,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
396
443
  }
397
444
  // Figli della selezione: sessioni bound alla task selezionata, oppure (spot)
398
445
  // le sessioni senza binding. sessions è già ts desc → l'ordine si eredita.
399
- const childSessions = sessions.filter((s) => {
446
+ // Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
447
+ // input: l'effect di validità della selezione non rigira a vuoto.
448
+ const childSessions = useMemo(() => sessions.filter((s) => {
400
449
  const bound = bindings.get(s.sessionId);
401
450
  return selectedTaskId ? bound === selectedTaskId : !bound;
402
- });
403
- const visibleSessions = childSessions.slice(0, MAX_SESSIONS);
404
- const hiddenSessions = childSessions.length - visibleSessions.length;
451
+ }), [sessions, bindings, selectedTaskId]);
452
+ // T50 lista a due gruppi: pinnate (sempre, in cima) + separatore +
453
+ // contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
454
+ // orfane. Core PURO in session-list.ts (testabile senza Ink).
455
+ const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, MAX_SESSIONS), [childSessions, sessions, pinned]);
456
+ const sessionRows = assembled.rows;
457
+ const selSessionObj = selectedSession(sessionRows, selSessionId);
405
458
  // T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
406
459
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
407
460
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
@@ -410,13 +463,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
410
463
  setSelId(viewTasks[0]?.id ?? null);
411
464
  }
412
465
  }, [viewTasks, selId]);
413
- // Cambio padre riparti dalla prima sessione figlia.
414
- useEffect(() => {
415
- setSelSession(0);
416
- }, [selId]);
466
+ // T50 la selezione (id) resta valida sotto la vista a due gruppi: se l'id
467
+ // non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
468
+ // sessione sparita) cade sulla prima riga — fallback deterministico, mai una
469
+ // posizione a caso. Sostituisce il reset-a-0 e il clamp index-based.
417
470
  useEffect(() => {
418
- setSelSession((s) => Math.min(s, Math.max(0, visibleSessions.length - 1)));
419
- }, [visibleSessions.length]);
471
+ if (rowIndexOf(sessionRows, selSessionId) < 0) {
472
+ setSelSessionId(firstSelectableId(sessionRows));
473
+ }
474
+ }, [sessionRows, selSessionId]);
420
475
  // T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
421
476
  // skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
422
477
  // pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
@@ -640,13 +695,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
640
695
  if (focus === 'tasks')
641
696
  moveTaskSel(-1);
642
697
  else
643
- setSelSession((i) => Math.max(0, i - 1));
698
+ setSelSessionId((id) => moveSelection(sessionRows, id, -1));
644
699
  }
645
700
  else if (key.downArrow) {
646
701
  if (focus === 'tasks')
647
702
  moveTaskSel(1);
648
703
  else
649
- setSelSession((i) => Math.min(visibleSessions.length - 1, i + 1));
704
+ setSelSessionId((id) => moveSelection(sessionRows, id, 1));
650
705
  }
651
706
  else if (key.return) {
652
707
  if (focus === 'tasks') {
@@ -669,9 +724,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
669
724
  else {
670
725
  // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
671
726
  // dal sidecar (non dal padre selezionato): vale anche per le spot.
672
- const s = visibleSessions[selSession];
727
+ const s = selSessionObj;
673
728
  if (!s) {
674
- setNote('nessuna sessione da riprendere');
729
+ setNote(selSessionId ? 'pin stale: transcript non più presente' : 'nessuna sessione da riprendere');
675
730
  }
676
731
  else {
677
732
  const bound = bindings.get(s.sessionId) ?? null;
@@ -702,6 +757,56 @@ function Deck({ cwd, tasksPath, tasksDir }) {
702
757
  setNote('');
703
758
  setMode('filter');
704
759
  }
760
+ else if (input === 'f') {
761
+ // T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
762
+ // azione immediata, nessun modale — la `F` maiuscola resta ai filtri.
763
+ // Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
764
+ // e senza focus lì non ce n'è una selezionata su cui agire.
765
+ if (focus !== 'sessions') {
766
+ setNote('f → fork: seleziona una sessione (←→ per il pane)');
767
+ }
768
+ else {
769
+ const s = selSessionObj;
770
+ if (!s) {
771
+ setNote(selSessionId ? 'f → pin stale: niente da forkare' : 'f → nessuna sessione da forkare');
772
+ }
773
+ else {
774
+ // L'id del ramo nasce qui, prima dello spawn: pinnandolo posso
775
+ // scrivere subito binding e lineage. Il binding task si eredita
776
+ // dall'origine (un ramo appartiene alla stessa task), il lineage
777
+ // registra la provenienza che il transcript non porta.
778
+ const newId = randomUUID();
779
+ const bound = bindings.get(s.sessionId) ?? null;
780
+ appendSessionRecord(cwd, {
781
+ sessionId: newId,
782
+ ...(bound ? { taskId: bound } : {}),
783
+ forkOf: s.sessionId,
784
+ });
785
+ const child = spawnDeckFork(bound, cwd, s.sessionId, newId);
786
+ child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
787
+ setNote(`⑂ fork ${s.sessionId.slice(0, 8)} → ${newId.slice(0, 8)}${bound ? ` (${bound})` : ' (spot)'}`);
788
+ }
789
+ }
790
+ }
791
+ else if (input === 'p') {
792
+ // T50 — pin/unpin della conversazione selezionata. Minuscola = azione
793
+ // immediata (convenzione T39), gemella di `f`: opera sulla riga
794
+ // selezionata del pane sessioni, quindi vive solo lì. Vale anche su una
795
+ // pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
796
+ // subito, senza attendere il tick del poll.
797
+ if (focus !== 'sessions') {
798
+ setNote('p → pin: seleziona una sessione (←→ per il pane)');
799
+ }
800
+ else if (!selSessionId) {
801
+ setNote('p → nessuna sessione da pinnare');
802
+ }
803
+ else {
804
+ const isPinned = pinned.has(selSessionId);
805
+ appendPin(cwd, selSessionId, !isPinned);
806
+ reloadSessions();
807
+ setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
808
+ }
809
+ }
705
810
  else if (input === 't') {
706
811
  const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
707
812
  const child = spawnTerminal(cwd, title);
@@ -742,11 +847,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
742
847
  exit();
743
848
  }
744
849
  });
745
- const selSessionObj = visibleSessions[selSession] ?? null;
746
- const selSessionId = selSessionObj?.sessionId;
747
850
  const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
748
851
  const canSpawn = focus === 'tasks' && !isSpot;
749
852
  const canResume = focus === 'sessions' && selSessionObj !== null;
853
+ // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
854
+ // spinnarla); basta il focus sul pane e una selezione.
855
+ const canPin = focus === 'sessions' && selSessionId !== null;
750
856
  // Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
751
857
  // ricalcola con lo stesso re-render che ridimensiona i pane.
752
858
  const legend = launchLegend(launch, columns);
@@ -774,20 +880,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
774
880
  sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
775
881
  sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
776
882
  });
777
- // Finestre di rendering. Le liste "logiche" (viewTasks, visibleSessions)
883
+ // Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
778
884
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
779
885
  // quelle, la finestra è solo ciò che finisce a schermo.
780
886
  const taskWin = windowRange(viewTasks.length, selIndex - 1, budget.taskRows);
781
887
  const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
782
- const sessionWin = windowRange(visibleSessions.length, selSession, budget.sessionRows);
783
- const windowSessions = visibleSessions.slice(sessionWin.start, sessionWin.end);
888
+ const selRowIndex = rowIndexOf(sessionRows, selSessionId);
889
+ const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
890
+ const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
784
891
  // Sotto la soglia minima il layout a box non entra a nessun costo: si scende
785
892
  // a una riga sola. Perdere il deck per un terminale basso è meglio che
786
893
  // sporcare la cronologia del terminale a ogni poll.
787
894
  if (budget.compact) {
788
895
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
789
896
  }
790
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: windowSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions', above: sessionWin.start, below: visibleSessions.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
897
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin' : '', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
791
898
  }
792
899
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
793
900
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -832,11 +939,27 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
832
939
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", forceEmojiWidth(task.pri), " ", displayProg(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
833
940
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
834
941
  }
835
- function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, }) {
836
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (sessions.map((s) => {
837
- const sel = s.sessionId === selectedId;
838
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isSpot ? _jsx(Text, { dimColor: true, children: "\u25CB" }) : _jsx(Text, { color: "green", children: "\uD83D\uDD17" }), ' ', truncate(s.title, 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
839
- })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns })) : null] }));
942
+ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
943
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
944
+ // T50 separatore leggero fra pinnate e contestuali: riga dim, non un
945
+ // box pesante (coerente con lo styling delle Done dimmate).
946
+ if (row.kind === 'separator') {
947
+ return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
948
+ }
949
+ const sel = row.sessionId === selectedId;
950
+ // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
951
+ // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
952
+ if (row.kind === 'pinned' && row.stale) {
953
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) })] }, row.sessionId));
954
+ }
955
+ const s = row.session; // non-stale → session presente
956
+ const isPinnedRow = row.kind === 'pinned';
957
+ // T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
958
+ // righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
959
+ // la troncatura non arriva mai.
960
+ const forked = forkOf.has(s.sessionId);
961
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : isSpot ? (_jsx(Text, { dimColor: true, children: "\u25CB" })) : (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, truncate(s.title, forked ? 42 : 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
962
+ })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null })) : null] }));
840
963
  }
841
964
  // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
842
965
  // task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
@@ -846,11 +969,11 @@ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId
846
969
  // — senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
847
970
  // preflight). Le righe rese non superano mai il riservato dal budget
848
971
  // (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
849
- function SessionDetailPane({ s, firstLines, lastLines, columns, }) {
972
+ function SessionDetailPane({ s, firstLines, lastLines, columns, origin, }) {
850
973
  const width = detailTextWidth(columns);
851
974
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
852
975
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
853
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: s.title }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts)] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
976
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: s.title }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
854
977
  }
855
978
  /**
856
979
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -0,0 +1,80 @@
1
+ // T50 — Assemblaggio della lista sessioni a DUE gruppi: pinnate (sempre in
2
+ // lista, in cima) + separatore + contestuali (figlie del parent selezionato).
3
+ // Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale
4
+ // (il pacchetto non ha infrastruttura TUI di test, solo unit sui core puri).
5
+ //
6
+ // Invarianti (decisioni congelate al preflight):
7
+ // - D2: dentro il blocco pinnate l'ordine è di PIN stabile (ultima pinnata in
8
+ // cima), dato dal rango del sidecar `session-tasks.jsonl` — non `ts desc`.
9
+ // - Dedup: una sessione sia pinnata sia contestuale compare SOLO fra le pinnate.
10
+ // - Cap: `maxContext` limita SOLO le contestuali; le pinnate sono esenti. Il
11
+ // conteggio delle troncate (`contextHidden`) resta esposto (non-silenzioso).
12
+ // - D3: una pinnata il cui transcript non esiste più è una riga STALE (session
13
+ // null), navigabile e spinnabile — non sparisce in silenzio.
14
+ // - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
15
+ // lista è una vista trasformata (due gruppi + separatore): un indice grezzo
16
+ // identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
17
+ export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
18
+ // Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
19
+ // figlie del parent: una pinnata può appartenere a un'altra task. Assente
20
+ // dallo store → stale.
21
+ const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
22
+ const pinnedIds = [...pinned.entries()]
23
+ .sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D2)
24
+ .map(([id]) => id);
25
+ const pinnedRows = pinnedIds.map((id) => {
26
+ const session = byId.get(id) ?? null;
27
+ return { kind: 'pinned', sessionId: id, session, stale: session === null };
28
+ });
29
+ const pinnedSet = new Set(pinnedIds);
30
+ const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
31
+ const shownContext = dedupedContext.slice(0, Math.max(0, maxContext));
32
+ const contextRows = shownContext.map((s) => ({
33
+ kind: 'context',
34
+ sessionId: s.sessionId,
35
+ session: s,
36
+ }));
37
+ const rows = [...pinnedRows];
38
+ // Separatore SOLO fra due gruppi entrambi non vuoti: senza pinnate o senza
39
+ // contestuali non c'è confine da segnare.
40
+ if (pinnedRows.length > 0 && contextRows.length > 0)
41
+ rows.push({ kind: 'separator' });
42
+ rows.push(...contextRows);
43
+ return {
44
+ rows,
45
+ pinnedCount: pinnedRows.length,
46
+ contextTotal: dedupedContext.length,
47
+ contextHidden: dedupedContext.length - shownContext.length,
48
+ };
49
+ }
50
+ function isSelectable(r) {
51
+ return r.kind !== 'separator';
52
+ }
53
+ /** Primo id selezionabile (salta il separatore); null = lista senza righe. */
54
+ export function firstSelectableId(rows) {
55
+ return rows.find(isSelectable)?.sessionId ?? null;
56
+ }
57
+ /** Indice della riga con quel sessionId nell'ARRAY COMPLETO (per il windowing);
58
+ * -1 se assente o id null. Il separatore non ha id → mai matchato. */
59
+ export function rowIndexOf(rows, sessionId) {
60
+ if (sessionId === null)
61
+ return -1;
62
+ return rows.findIndex((r) => isSelectable(r) && r.sessionId === sessionId);
63
+ }
64
+ /** Sposta la selezione di `delta` fra le sole righe selezionabili (attraversa il
65
+ * separatore senza fermarcisi). Id perso → prima riga; lista vuota → null. */
66
+ export function moveSelection(rows, currentId, delta) {
67
+ const selectable = rows.filter(isSelectable);
68
+ if (selectable.length === 0)
69
+ return null;
70
+ const cur = selectable.findIndex((r) => r.sessionId === currentId);
71
+ if (cur < 0)
72
+ return selectable[0].sessionId;
73
+ const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
74
+ return selectable[next].sessionId;
75
+ }
76
+ /** Session della riga selezionata; null se stale o nessuna selezione. */
77
+ export function selectedSession(rows, sessionId) {
78
+ const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
79
+ return r && isSelectable(r) ? r.session : null;
80
+ }
@@ -1,46 +1,60 @@
1
1
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- // SIDECAR sessionId ↔ taskId (T27).
4
- //
5
- // Lo store JSONL di CC NON registra LOOM_TASK/taskId: la classificazione
6
- // spot vs scoped non può derivare dal transcript. La verità è QUESTO indice,
7
- // che il deck popola allo spawn — quando pinna `--session-id <uuid>` (D1
8
- // preflight) il sessionId è già noto, quindi il binding è deterministico.
9
- //
10
- // Store (D3 preflight): project-local `<root>/.claude/loom/session-tasks.jsonl`,
11
- // JSONL append-only. Append (non read-modify-write) = concurrency-safe fra
12
- // spawn concorrenti; last-wins in lettura copre eventuali re-pin.
13
3
  export function taskIndexPath(projectRoot) {
14
4
  return join(projectRoot, '.claude', 'loom', 'session-tasks.jsonl');
15
5
  }
16
- export function appendTaskBinding(projectRoot, sessionId, taskId) {
6
+ export function appendSessionRecord(projectRoot, rec) {
17
7
  const path = taskIndexPath(projectRoot);
18
8
  mkdirSync(dirname(path), { recursive: true });
19
- const record = { sessionId, taskId, ts: new Date().toISOString() };
20
- appendFileSync(path, JSON.stringify(record) + '\n');
9
+ appendFileSync(path, JSON.stringify({ ...rec, ts: new Date().toISOString() }) + '\n');
10
+ }
11
+ export function appendTaskBinding(projectRoot, sessionId, taskId) {
12
+ appendSessionRecord(projectRoot, { sessionId, taskId });
13
+ }
14
+ /** T50 — pin/unpin (toggle): append immediato, il reader risolve last-wins. */
15
+ export function appendPin(projectRoot, sessionId, pinned) {
16
+ appendSessionRecord(projectRoot, { sessionId, pinned });
21
17
  }
22
- // sessionId taskId, last-wins (un re-pin dello stesso sessionId sovrascrive).
23
- export function loadTaskBindings(projectRoot) {
18
+ // Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
19
+ // ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
20
+ // Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
21
+ // campi sono indipendenti — un record di solo `forkOf` non cancella un binding
22
+ // task scritto prima per lo stesso sessionId.
23
+ export function loadSessionIndex(projectRoot) {
24
24
  const bindings = new Map();
25
+ const forkOf = new Map();
26
+ const pinned = new Map();
25
27
  let content;
26
28
  try {
27
29
  content = readFileSync(taskIndexPath(projectRoot), 'utf8');
28
30
  }
29
31
  catch {
30
- return bindings;
32
+ return { bindings, forkOf, pinned };
31
33
  }
34
+ let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
32
35
  for (const line of content.split('\n')) {
33
36
  if (!line.trim())
34
37
  continue;
35
38
  try {
36
39
  const d = JSON.parse(line);
37
- if (typeof d.sessionId === 'string' && typeof d.taskId === 'string') {
40
+ if (typeof d.sessionId !== 'string')
41
+ continue;
42
+ if (typeof d.taskId === 'string')
38
43
  bindings.set(d.sessionId, d.taskId);
44
+ if (typeof d.forkOf === 'string')
45
+ forkOf.set(d.sessionId, d.forkOf);
46
+ // last-wins per campo: un `pinned:false` finale rimuove il pin, un
47
+ // `pinned:true` (ri)assegna il rango con la posizione corrente nel file.
48
+ if (typeof d.pinned === 'boolean') {
49
+ if (d.pinned)
50
+ pinned.set(d.sessionId, order++);
51
+ else
52
+ pinned.delete(d.sessionId);
39
53
  }
40
54
  }
41
55
  catch {
42
56
  // riga corrotta → skip
43
57
  }
44
58
  }
45
- return bindings;
59
+ return { bindings, forkOf, pinned };
46
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.11.0",
3
+ "version": "0.13.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": {
package/scripts/deck-run CHANGED
@@ -10,6 +10,10 @@
10
10
  # Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
11
11
  # niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
12
12
  #
13
+ # Con --resume <sid> riprende una conversazione esistente; aggiungendo --fork la
14
+ # riprende RAMANDOLA (`--fork-session`): id nuovo, transcript copiato, l'origine
15
+ # resta intatta e scrivibile.
16
+ #
13
17
  # Modalità spawn (LOOM_DECK_SPAWN_MODE):
14
18
  # inline → ptyxis --tab -- <cmd> (default) comando inline, LOOM_TASK diretta, zero dconf
15
19
  # profile → ptyxis --tab-with-profile=<UUID> riusa il profilo, riscrive il custom-command via dconf
@@ -32,6 +36,7 @@ TASK=""
32
36
  SESSION_ID=""
33
37
  RESUME_ID=""
34
38
  NO_TASK=0
39
+ FORK=0
35
40
  # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
36
41
  # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
37
42
  # --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
@@ -43,6 +48,13 @@ NO_TASK=0
43
48
  # <sid>` (spot: resume nudo). Su resume niente prompt iniziale (si continua la
44
49
  # conversazione, non se ne inietta una nuova) e niente --session-id (l'id ce
45
50
  # l'ha già la sessione ripresa).
51
+ # --fork (T28): MODIFICATORE di --resume, speculare al `--fork-session` del CLI
52
+ # (che è definito allo stesso modo: "when resuming, create a new session ID").
53
+ # Non è una terza forma di spawn ma una variante della ripresa → richiede
54
+ # --resume, e senza di esso è un errore d'uso, non un fork "dell'ultima".
55
+ # Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
56
+ # esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
57
+ # dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
46
58
  while [[ $# -gt 0 ]]; do
47
59
  case "$1" in
48
60
  --session-id)
@@ -53,6 +65,8 @@ while [[ $# -gt 0 ]]; do
53
65
  RESUME_ID="${2:-}"; shift 2 ;;
54
66
  --resume=*)
55
67
  RESUME_ID="${1#*=}"; shift ;;
68
+ --fork)
69
+ FORK=1; shift ;;
56
70
  --no-task)
57
71
  NO_TASK=1; shift ;;
58
72
  *)
@@ -64,7 +78,9 @@ done
64
78
  USAGE="uso: deck-run <TaskID> [--session-id <uuid>] (es. deck-run T18)
65
79
  | deck-run --no-task (sessione nuda, senza task)
66
80
  | deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
67
- | deck-run --no-task --resume <uuid> (riprende sessione spot)"
81
+ | deck-run --no-task --resume <uuid> (riprende sessione spot)
82
+ | deck-run <TaskID> --resume <uuid> --fork [--session-id <nuovo-uuid>]
83
+ (forka: ramo con id NUOVO)"
68
84
 
69
85
  if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
70
86
  echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
@@ -75,8 +91,14 @@ if [[ $NO_TASK -eq 0 && -z "$TASK" ]]; then
75
91
  echo "$USAGE" >&2
76
92
  exit 2
77
93
  fi
78
- if [[ -n "$RESUME_ID" && -n "$SESSION_ID" ]]; then
94
+ if [[ $FORK -eq 1 && -z "$RESUME_ID" ]]; then
95
+ echo "--fork richiede --resume <uuid>: si forka una conversazione esistente, non il nulla" >&2
96
+ echo "$USAGE" >&2
97
+ exit 2
98
+ fi
99
+ if [[ -n "$RESUME_ID" && -n "$SESSION_ID" && $FORK -eq 0 ]]; then
79
100
  echo "--resume e --session-id sono mutuamente esclusivi (la sessione ripresa ha già il suo id)" >&2
101
+ echo "usa --fork se vuoi un ramo con id nuovo" >&2
80
102
  echo "$USAGE" >&2
81
103
  exit 2
82
104
  fi
@@ -125,6 +147,11 @@ if [[ -n "$_cfg" ]]; then
125
147
  if [[ $NO_TASK -eq 1 ]]; then TITLE="${_label}"; else TITLE="${_label} · ${TASK}"; fi
126
148
  fi
127
149
  fi
150
+ # Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
151
+ # marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e
152
+ # non prefisso perché il match compass è `.includes(label)` e la label deve
153
+ # restare intatta in testa — stesso schema di `· <task>` e `· deck`.
154
+ [[ $FORK -eq 1 ]] && TITLE="${TITLE} · fork"
128
155
 
129
156
  # ── permissionMode (T45) ─────────────────────────────────────────────────────
130
157
  # Precedenza: env LOOM_DECK_PERMISSION_MODE > campo del file config > 'manual'.
@@ -155,6 +182,13 @@ SID_FLAG=""
155
182
  RES_FLAG=""
156
183
  [[ -n "$RESUME_ID" ]] && RES_FLAG="--resume ${RESUME_ID} "
157
184
 
185
+ # --fork-session per la tab (T28): CC apre un id NUOVO invece di riscrivere
186
+ # quello ripreso → mai due writer sullo stesso JSONL. Terzo gemello del pattern
187
+ # flag-opzionale, per la stessa ragione degli altri due: la variante non deve
188
+ # duplicare IN_TAB_CMD.
189
+ FORK_FLAG=""
190
+ [[ $FORK -eq 1 ]] && FORK_FLAG="--fork-session "
191
+
158
192
  # Prompt iniziale della sessione. Default: prompt DIRETTO (no skill) che chiede
159
193
  # a Claude un recap dello stato della task — l'utente rivede/steera prima di
160
194
  # lanciare (= più controllo), nessuna esecuzione automatica. Override completo via
@@ -177,13 +211,18 @@ fi
177
211
  # iniezione LOOM_TASK, prompt iniziale, --session-id pinnato. Resta claude nudo,
178
212
  # titolato e con il permission mode del progetto.
179
213
  # Su resume (T49) il prompt iniziale salta anche nel ramo task: riprendere una
180
- # conversazione significa continuarla, non iniettarle un nuovo messaggio.
214
+ # conversazione significa continuarla, non iniettarle un nuovo messaggio. Il
215
+ # fork (T28) ricade nello stesso ramo — è una ripresa, con id nuovo.
181
216
  PROMPT_ARG="'${PROMPT}'"
182
217
  [[ -n "$RESUME_ID" ]] && PROMPT_ARG=""
218
+ # Il SID_FLAG entra anche nel ramo --no-task, che altrimenti lo scarta insieme
219
+ # agli altri elementi task-bound: nel fork di una sessione SPOT il sessionId
220
+ # pinnato non serve a legare una task (non c'è) ma a rendere noto in anticipo
221
+ # l'id del ramo, senza il quale il lineage non sarebbe registrabile.
183
222
  if [[ $NO_TASK -eq 1 ]]; then
184
- _default_intab="claude --name '${TITLE}' ${MODE_FLAG}${RES_FLAG}"
223
+ _default_intab="claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
185
224
  else
186
- _default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${PROMPT_ARG}"
225
+ _default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
187
226
  fi
188
227
  IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
189
228