@lamemind/loom-deck 0.11.0 → 0.12.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 +25 -1
- package/dist/cli.js +76 -13
- package/dist/task-index.js +19 -19
- package/package.json +1 -1
- package/scripts/deck-run +44 -5
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
|
@@ -9,7 +9,7 @@ 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,
|
|
12
|
+
import { appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
13
13
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
14
14
|
import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
|
|
15
15
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
@@ -63,6 +63,27 @@ function spawnDeckResume(taskId, cwd, sessionId) {
|
|
|
63
63
|
child.unref();
|
|
64
64
|
return child;
|
|
65
65
|
}
|
|
66
|
+
// T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
|
|
67
|
+
// <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
|
|
68
|
+
// id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
|
|
69
|
+
// due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
|
|
70
|
+
// Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
|
|
71
|
+
// conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
|
|
72
|
+
// scrivere né il binding task né il record di lineage (il transcript del fork
|
|
73
|
+
// non nomina da nessuna parte la sessione d'origine).
|
|
74
|
+
function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
75
|
+
const args = [
|
|
76
|
+
...(taskId ? [taskId] : ['--no-task']),
|
|
77
|
+
'--resume',
|
|
78
|
+
originId,
|
|
79
|
+
'--fork',
|
|
80
|
+
'--session-id',
|
|
81
|
+
newId,
|
|
82
|
+
];
|
|
83
|
+
const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
84
|
+
child.unref();
|
|
85
|
+
return child;
|
|
86
|
+
}
|
|
66
87
|
// T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
|
|
67
88
|
// sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
|
|
68
89
|
// task non c'è nulla da legare). Funzione separata e non un parametro opzionale
|
|
@@ -240,27 +261,34 @@ function useSessions(projectRoot) {
|
|
|
240
261
|
const [state, setState] = useState({
|
|
241
262
|
sessions: [],
|
|
242
263
|
bindings: new Map(),
|
|
264
|
+
forkOf: new Map(),
|
|
243
265
|
});
|
|
244
266
|
useEffect(() => {
|
|
245
267
|
let lastSig = '';
|
|
246
268
|
const reload = () => {
|
|
247
269
|
let sessions;
|
|
248
|
-
let
|
|
270
|
+
let index;
|
|
249
271
|
try {
|
|
250
272
|
sessions = discoverProjectSessions(projectRoot);
|
|
251
|
-
|
|
273
|
+
index = loadSessionIndex(projectRoot);
|
|
252
274
|
}
|
|
253
275
|
catch {
|
|
254
276
|
sessions = [];
|
|
255
|
-
|
|
277
|
+
index = { bindings: new Map(), forkOf: new Map() };
|
|
256
278
|
}
|
|
279
|
+
const { bindings, forkOf } = index;
|
|
280
|
+
// La signature copre anche i fork: un record di lineage appena scritto
|
|
281
|
+
// cambia il marker della riga, quindi deve forzare il re-render come
|
|
282
|
+
// farebbe un binding nuovo.
|
|
257
283
|
const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
|
|
258
284
|
'#' +
|
|
259
|
-
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',')
|
|
285
|
+
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
|
|
286
|
+
'#' +
|
|
287
|
+
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',');
|
|
260
288
|
if (sig === lastSig)
|
|
261
289
|
return;
|
|
262
290
|
lastSig = sig;
|
|
263
|
-
setState({ sessions, bindings });
|
|
291
|
+
setState({ sessions, bindings, forkOf });
|
|
264
292
|
};
|
|
265
293
|
reload();
|
|
266
294
|
const id = setInterval(reload, POLL_MS);
|
|
@@ -346,7 +374,7 @@ const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
|
|
|
346
374
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
347
375
|
const { exit } = useApp();
|
|
348
376
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
349
|
-
const { sessions, bindings } = useSessions(cwd);
|
|
377
|
+
const { sessions, bindings, forkOf } = useSessions(cwd);
|
|
350
378
|
const [focus, setFocus] = useState('tasks');
|
|
351
379
|
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
352
380
|
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
@@ -702,6 +730,37 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
702
730
|
setNote('');
|
|
703
731
|
setMode('filter');
|
|
704
732
|
}
|
|
733
|
+
else if (input === 'f') {
|
|
734
|
+
// T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
|
|
735
|
+
// azione immediata, nessun modale — la `F` maiuscola resta ai filtri.
|
|
736
|
+
// Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
|
|
737
|
+
// e senza focus lì non ce n'è una selezionata su cui agire.
|
|
738
|
+
if (focus !== 'sessions') {
|
|
739
|
+
setNote('f → fork: seleziona una sessione (←→ per il pane)');
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
const s = visibleSessions[selSession];
|
|
743
|
+
if (!s) {
|
|
744
|
+
setNote('f → nessuna sessione da forkare');
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
// L'id del ramo nasce qui, prima dello spawn: pinnandolo posso
|
|
748
|
+
// scrivere subito binding e lineage. Il binding task si eredita
|
|
749
|
+
// dall'origine (un ramo appartiene alla stessa task), il lineage
|
|
750
|
+
// registra la provenienza che il transcript non porta.
|
|
751
|
+
const newId = randomUUID();
|
|
752
|
+
const bound = bindings.get(s.sessionId) ?? null;
|
|
753
|
+
appendSessionRecord(cwd, {
|
|
754
|
+
sessionId: newId,
|
|
755
|
+
...(bound ? { taskId: bound } : {}),
|
|
756
|
+
forkOf: s.sessionId,
|
|
757
|
+
});
|
|
758
|
+
const child = spawnDeckFork(bound, cwd, s.sessionId, newId);
|
|
759
|
+
child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
|
|
760
|
+
setNote(`⑂ fork ${s.sessionId.slice(0, 8)} → ${newId.slice(0, 8)}${bound ? ` (${bound})` : ' (spot)'}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
705
764
|
else if (input === 't') {
|
|
706
765
|
const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
|
|
707
766
|
const child = spawnTerminal(cwd, title);
|
|
@@ -787,7 +846,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
787
846
|
if (budget.compact) {
|
|
788
847
|
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
848
|
}
|
|
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] }));
|
|
849
|
+
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' : '', " \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, forkOf: forkOf })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
791
850
|
}
|
|
792
851
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
793
852
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -832,11 +891,15 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
832
891
|
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
892
|
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
834
893
|
}
|
|
835
|
-
function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, }) {
|
|
894
|
+
function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
|
|
836
895
|
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
896
|
const sel = s.sessionId === selectedId;
|
|
838
|
-
|
|
839
|
-
|
|
897
|
+
// T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
|
|
898
|
+
// righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
|
|
899
|
+
// la troncatura non arriva mai.
|
|
900
|
+
const forked = forkOf.has(s.sessionId);
|
|
901
|
+
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" }), ' ', 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));
|
|
902
|
+
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null })) : null] }));
|
|
840
903
|
}
|
|
841
904
|
// T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
|
|
842
905
|
// task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
|
|
@@ -846,11 +909,11 @@ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId
|
|
|
846
909
|
// — senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
|
|
847
910
|
// preflight). Le righe rese non superano mai il riservato dal budget
|
|
848
911
|
// (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
849
|
-
function SessionDetailPane({ s, firstLines, lastLines, columns, }) {
|
|
912
|
+
function SessionDetailPane({ s, firstLines, lastLines, columns, origin, }) {
|
|
850
913
|
const width = detailTextWidth(columns);
|
|
851
914
|
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
852
915
|
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}`)))] }));
|
|
916
|
+
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
917
|
}
|
|
855
918
|
/**
|
|
856
919
|
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
package/dist/task-index.js
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
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
|
|
6
|
+
export function appendSessionRecord(projectRoot, rec) {
|
|
17
7
|
const path = taskIndexPath(projectRoot);
|
|
18
8
|
mkdirSync(dirname(path), { recursive: true });
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
appendFileSync(path, JSON.stringify({ ...rec, ts: new Date().toISOString() }) + '\n');
|
|
10
|
+
}
|
|
11
|
+
export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
12
|
+
appendSessionRecord(projectRoot, { sessionId, taskId });
|
|
21
13
|
}
|
|
22
|
-
//
|
|
23
|
-
|
|
14
|
+
// Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
|
|
15
|
+
// ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
|
|
16
|
+
// Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
|
|
17
|
+
// campi sono indipendenti — un record di solo `forkOf` non cancella un binding
|
|
18
|
+
// task scritto prima per lo stesso sessionId.
|
|
19
|
+
export function loadSessionIndex(projectRoot) {
|
|
24
20
|
const bindings = new Map();
|
|
21
|
+
const forkOf = new Map();
|
|
25
22
|
let content;
|
|
26
23
|
try {
|
|
27
24
|
content = readFileSync(taskIndexPath(projectRoot), 'utf8');
|
|
28
25
|
}
|
|
29
26
|
catch {
|
|
30
|
-
return bindings;
|
|
27
|
+
return { bindings, forkOf };
|
|
31
28
|
}
|
|
32
29
|
for (const line of content.split('\n')) {
|
|
33
30
|
if (!line.trim())
|
|
34
31
|
continue;
|
|
35
32
|
try {
|
|
36
33
|
const d = JSON.parse(line);
|
|
37
|
-
if (typeof d.sessionId
|
|
34
|
+
if (typeof d.sessionId !== 'string')
|
|
35
|
+
continue;
|
|
36
|
+
if (typeof d.taskId === 'string')
|
|
38
37
|
bindings.set(d.sessionId, d.taskId);
|
|
39
|
-
|
|
38
|
+
if (typeof d.forkOf === 'string')
|
|
39
|
+
forkOf.set(d.sessionId, d.forkOf);
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
42
42
|
// riga corrotta → skip
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
return bindings;
|
|
45
|
+
return { bindings, forkOf };
|
|
46
46
|
}
|
package/package.json
CHANGED
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 [[ -
|
|
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
|
|