@lamemind/loom-deck 0.7.0 → 0.9.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
@@ -59,13 +59,25 @@ Assegnazioni correnti:
59
59
  | | Tasto | Cosa fa |
60
60
  |---|---|---|
61
61
  | modale | `C` | nuova task (create-task inline) |
62
+ | modale | `E` | edit priorità/stato della task selezionata (salva + commit) |
62
63
  | modale | `S` | sort chain |
63
64
  | modale | `F` | filtri |
65
+ | immediata | `t` | terminale @project-root (surface standard launch) |
66
+ | immediata | `c` | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
64
67
  | immediata | `w` | salva la vista corrente su disco |
65
68
  | launch | `1`…`9` | esegue il `command` della voce, con `cwd` = project root |
66
69
 
67
- Le minuscole sono deliberatamente quasi tutte libere: le consumeranno le azioni
68
- in arrivo (start/preflight/checkpoint, fork/resume, terminale @project-root).
70
+ Le voci `launch` sono elencate in una **riga di legenda** sotto il footer
71
+ (`launch 1 📝 codium · 2 ☕ idea`): l'indice da solo è opaco, perché le voci sono
72
+ custom per-progetto e non hanno una lettera fissa per app. Se non entrano in
73
+ larghezza, la legenda si ferma a voci intere e mostra il contatore di quelle
74
+ fuori riga — mai un troncamento silenzioso. Il cap a `9` è imposto dai tasti-cifra,
75
+ non dallo schema: un progetto può dichiarare più di 9 voci, quelle oltre la nona
76
+ sono configurate ma non raggiungibili (e la legenda lo dice).
77
+
78
+ `t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa
79
+ finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
80
+ (maiuscola, modale create-task) restano distinte per la regola sopra.
69
81
 
70
82
  > **Nota di migrazione (0.6.0)**: `c` → **`C`** per creare una task, e le voci
71
83
  > `codium`/`idea` non hanno più una lettera dedicata (erano `C`/`I` hardcoded):
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path';
10
10
  import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
11
11
  import { discoverProjectSessions } from './sessions.js';
12
12
  import { appendTaskBinding, loadTaskBindings } from './task-index.js';
13
- import { loadLaunch } from './config.js';
13
+ import { launchLegend, loadIdentity, loadLaunch } from './config.js';
14
14
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
15
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
16
16
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -50,6 +50,21 @@ function spawnDeck(id, cwd, sessionId) {
50
50
  child.unref();
51
51
  return child;
52
52
  }
53
+ // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
54
+ // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
55
+ // task non c'è nulla da legare). Funzione separata e non un parametro opzionale
56
+ // di spawnDeck: i tre argomenti mancano tutti insieme, un `if` per ciascuno
57
+ // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
58
+ // deck-run, perché il match compass è window-level e non sa nulla di task.
59
+ function spawnClaudeEmpty(cwd) {
60
+ const child = spawn(DECK_RUN, ['--no-task'], {
61
+ cwd,
62
+ detached: true,
63
+ stdio: 'ignore',
64
+ });
65
+ child.unref();
66
+ return child;
67
+ }
53
68
  // T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
54
69
  // Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
55
70
  // Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
@@ -67,6 +82,22 @@ function runLaunch(entry, cwd) {
67
82
  child.unref();
68
83
  return child;
69
84
  }
85
+ // T37 — surface STANDARD LAUNCH `terminal`: built-in e universale (nessuna
86
+ // dichiarazione in `launch[]`), ma di natura launch — fire-once, nessuno stato.
87
+ // Il deck gira già DENTRO una tab Ptyxis → `--tab` mette il terminale accanto a
88
+ // sé nella stessa finestra, invece di sparpagliare finestre.
89
+ // Nessun `-- CMD`: l'azione È aprire la shell (differenza dalle launch custom,
90
+ // che eseguono un comando dentro `bash -lic`).
91
+ // `-T <title>` col core `<owner> <name>` tiene la finestra matchabile da compass
92
+ // anche mentre la tab attiva è il terminale; senza identità nel file config si
93
+ // spawna senza titolo (la surface resta funzionante, il progetto risulta assente
94
+ // dal radar finché quella tab è in primo piano).
95
+ function spawnTerminal(cwd, title) {
96
+ const args = title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
97
+ const child = spawn('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
98
+ child.unref();
99
+ return child;
100
+ }
70
101
  // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
71
102
  const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
72
103
  // T30: create-task inline. Spawna CC HEADLESS (`-p`) con `--session-id` pinnato
@@ -286,6 +317,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
286
317
  const [editRow, setEditRow] = useState(0);
287
318
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
288
319
  const launch = useMemo(() => loadLaunch(cwd), [cwd]);
320
+ // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
321
+ const identity = useMemo(() => loadIdentity(cwd), [cwd]);
289
322
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
290
323
  // polling di tasks.md continua a funzionare senza saperne nulla.
291
324
  const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
@@ -604,6 +637,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
604
637
  setNote('');
605
638
  setMode('filter');
606
639
  }
640
+ else if (input === 't') {
641
+ const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
642
+ const child = spawnTerminal(cwd, title);
643
+ child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
644
+ setNote(`t → terminale su ${projectName}`);
645
+ }
646
+ else if (input === 'c') {
647
+ // Minuscola = azione immediata (convenzione T39), gemella di `t`: entrambe
648
+ // aprono una surface del cappello senza passare da un modale. `C` (create
649
+ // task) resta distinta — stessa lettera, ma la maiuscola è per i modali.
650
+ const child = spawnClaudeEmpty(cwd);
651
+ child.on('error', () => setNote(`⚠ c → spawn claude fallito (${DECK_RUN})`));
652
+ setNote(`c → claude nuda su ${projectName} (nessuna task)`);
653
+ }
607
654
  else if (input === 'w') {
608
655
  // Salvataggio ESPLICITO: comporre una vista non tocca il disco, così
609
656
  // sperimentare non sporca lo stato persistito.
@@ -633,8 +680,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
633
680
  const selSessionId = visibleSessions[selSession]?.sessionId;
634
681
  const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
635
682
  const canSpawn = focus === 'tasks' && !isSpot;
636
- const launchHint = launch.length > 0 ? `1-${Math.min(9, launch.length)} launch · ` : '';
637
- 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, 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, 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, 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, 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, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 ", launchHint, "q esci \u00B7 focus: ", _jsx(Text, { color: "cyan", children: focus })] })), 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: viewTasks, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: visibleSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions' })] }), note ? _jsx(Text, { color: "green", children: note }) : null] }));
683
+ // Larghezza letta a ogni render (non memoizzata): dopo un resize il primo
684
+ // re-render utile ricalcola la legenda senza bisogno di un listener dedicato.
685
+ const legend = launchLegend(launch, process.stdout.columns || 80);
686
+ 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, 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, 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, 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, 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, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \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, 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: viewTasks, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: visibleSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions' })] }), note ? _jsx(Text, { color: "green", children: note }) : null] }));
638
687
  }
639
688
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
640
689
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
package/dist/config.js CHANGED
@@ -39,3 +39,71 @@ export function loadLaunch(projectRoot) {
39
39
  return [];
40
40
  }
41
41
  }
42
+ // T43 — le voci launch si raggiungono con le CIFRE `1`..`9`: il cap non viene
43
+ // dallo schema config (che ne ammette quante se ne vogliono) ma dai tasti
44
+ // disponibili. Le voci oltre la nona restano configurate e non raggiungibili.
45
+ export const LAUNCH_MAX = 9;
46
+ // Larghezza in celle terminale, approssimata: emoji astrali (U+1F000+) e simboli
47
+ // BMP portati a presentazione emoji occupano 2 colonne, il VS16 è un modificatore
48
+ // a larghezza 0, tutto il resto 1. Serve solo a decidere quante voci stanno in
49
+ // riga — non deve essere esatta, deve non SOTTOstimare (sottostimare manderebbe
50
+ // la riga a capo, che è il difetto da evitare).
51
+ export function cellWidth(s) {
52
+ let w = 0;
53
+ for (const ch of s) {
54
+ const cp = ch.codePointAt(0);
55
+ if (cp === 0xfe0f)
56
+ continue;
57
+ w += cp >= 0x1f000 || (cp >= 0x2190 && cp <= 0x2bff) ? 2 : 1;
58
+ }
59
+ return w;
60
+ }
61
+ // T43 — legenda `indice → voce`. L'indice da solo è opaco (le launch sono voci
62
+ // custom per-progetto, non hanno una lettera fissa per app), quindi la resa deve
63
+ // esporre la mappa, non il conteggio. Degradazione mai silenziosa: ciò che non
64
+ // entra in larghezza finisce in un contatore esplicito, non troncato a metà.
65
+ export function launchLegend(entries, columns) {
66
+ const reachable = entries.slice(0, LAUNCH_MAX);
67
+ const unreachable = entries.length - reachable.length;
68
+ // bordo + padding del box + prefisso "launch " ≈ 12 celle; pavimento a 24 per
69
+ // non degenerare a legenda vuota su terminali strettissimi.
70
+ const budget = Math.max(24, columns - 12);
71
+ // I fallback di parseLaunch (`▸` per emoji, command per label) sono già
72
+ // applicati a monte: qui non si re-implementano né si assumono campi popolati.
73
+ const parts = reachable.map((e, i) => `${i + 1} ${e.emoji} ${e.label}`);
74
+ const fit = (reserve) => {
75
+ const taken = [];
76
+ let used = 0;
77
+ for (const p of parts) {
78
+ const cost = cellWidth(p) + (taken.length > 0 ? 3 : 0); // ' · '
79
+ if (used + cost > budget - reserve)
80
+ break;
81
+ taken.push(p);
82
+ used += cost;
83
+ }
84
+ return taken;
85
+ };
86
+ // Primo tentativo senza riserva: se entra tutto, nessuno spazio sprecato per un
87
+ // contatore che non servirebbe. Altrimenti si ripete riservando la coda.
88
+ let taken = fit(0);
89
+ if (taken.length < parts.length)
90
+ taken = fit(10);
91
+ return { shown: taken.join(' · '), overflow: parts.length - taken.length, unreachable };
92
+ }
93
+ export function parseIdentity(raw) {
94
+ if (!raw || typeof raw !== 'object')
95
+ return null;
96
+ const { owner, name } = raw;
97
+ if (typeof owner !== 'string' || !owner || typeof name !== 'string' || !name)
98
+ return null;
99
+ return { owner, name };
100
+ }
101
+ /** File assente o malformato → nessuna identità. Mai un throw. */
102
+ export function loadIdentity(projectRoot) {
103
+ try {
104
+ return parseIdentity(JSON.parse(readFileSync(configFilePath(projectRoot), 'utf8')));
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.7.0",
3
+ "version": "0.9.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
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # deck-run <TaskID> spike spawn primitive (UI-agnostico) di loom-deck.
3
+ # deck-run <TaskID> | deck-run --no-task spawn primitive (UI-agnostico) di loom-deck.
4
4
  #
5
5
  # Apre una tab Ptyxis nella window ATTIVA (quella col focus = il deck) e vi
6
6
  # avvia una sessione Claude Code già bound alla task via LOOM_TASK, dritta sul
7
7
  # prompt iniziale (default: prompt diretto "recap stato task <TaskID>" — no skill,
8
8
  # solo recap → più controllo). Riusabile identico da TUI (Ink) e da web.
9
9
  #
10
+ # Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
11
+ # niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
12
+ #
10
13
  # Modalità spawn (LOOM_DECK_SPAWN_MODE):
11
14
  # inline → ptyxis --tab -- <cmd> (default) comando inline, LOOM_TASK diretta, zero dconf
12
15
  # profile → ptyxis --tab-with-profile=<UUID> riusa il profilo, riscrive il custom-command via dconf
@@ -21,27 +24,42 @@
21
24
  # LOOM_DECK_WORKDIR dir di lavoro della tab (default: $PWD)
22
25
  # LOOM_DECK_ENTER_PROMPT prompt iniziale sessione, {TASK}=TaskID (default: "recap stato task <TaskID>")
23
26
  # LOOM_DECK_INTAB_CMD override comando in-tab (default: la sessione CC; usato per i test)
27
+ # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
24
28
  #
25
29
  set -euo pipefail
26
30
 
27
31
  TASK=""
28
32
  SESSION_ID=""
33
+ NO_TASK=0
29
34
  # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
30
35
  # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
36
+ # --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
37
+ # iniziale, nessun sessionId pinnato. Flag esplicito e non "positional opzionale"
38
+ # perché un `deck-run` a mani vuote resta un errore d'uso, non una sessione spot.
31
39
  while [[ $# -gt 0 ]]; do
32
40
  case "$1" in
33
41
  --session-id)
34
42
  SESSION_ID="${2:-}"; shift 2 ;;
35
43
  --session-id=*)
36
44
  SESSION_ID="${1#*=}"; shift ;;
45
+ --no-task)
46
+ NO_TASK=1; shift ;;
37
47
  *)
38
48
  if [[ -z "$TASK" ]]; then TASK="$1"; shift
39
49
  else echo "argomento inatteso: '$1'" >&2; exit 2; fi ;;
40
50
  esac
41
51
  done
42
52
 
43
- if [[ -z "$TASK" ]]; then
44
- echo "uso: deck-run <TaskID> [--session-id <uuid>] (es. deck-run T18)" >&2
53
+ USAGE="uso: deck-run <TaskID> [--session-id <uuid>] (es. deck-run T18)
54
+ | deck-run --no-task (sessione nuda, senza task)"
55
+
56
+ if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
57
+ echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
58
+ echo "$USAGE" >&2
59
+ exit 2
60
+ fi
61
+ if [[ $NO_TASK -eq 0 && -z "$TASK" ]]; then
62
+ echo "$USAGE" >&2
45
63
  exit 2
46
64
  fi
47
65
 
@@ -68,17 +86,47 @@ _find_project_root() { # <start-dir> → dir con .claude/loom-works.json, o vuo
68
86
  return 1
69
87
  }
70
88
 
71
- TITLE="cc ${TASK}"
89
+ # Project root risolta UNA volta: la usano sia la label sia permissionMode (T45).
90
+ _proot=""
72
91
  if command -v jq >/dev/null 2>&1; then
73
92
  _proot="$(_find_project_root "$WORKDIR" || true)"
74
- if [[ -n "${_proot:-}" ]]; then
75
- _label="$(jq -r 'if (.emoji and .owner and .name)
76
- then "\(.emoji) \(.owner) \(.name)" else empty end' \
77
- "${_proot}/.claude/loom-works.json" 2>/dev/null || true)"
78
- [[ -n "${_label:-}" ]] && TITLE="${_label} · ${TASK}"
93
+ fi
94
+ _cfg=""
95
+ [[ -n "$_proot" ]] && _cfg="${_proot}/.claude/loom-works.json"
96
+
97
+ # La label vale per OGNI surface claude, task o no (T42): il match compass è
98
+ # window-level e non sa nulla di task → legare la titolazione alla presenza di
99
+ # una task sarebbe un errore di layer. Cambia solo il suffisso: `· <task>` quando
100
+ # la sessione è bound, nessun suffisso quando è nuda.
101
+ if [[ $NO_TASK -eq 1 ]]; then TITLE="cc"; else TITLE="cc ${TASK}"; fi
102
+ if [[ -n "$_cfg" ]]; then
103
+ _label="$(jq -r 'if (.emoji and .owner and .name)
104
+ then "\(.emoji) \(.owner) \(.name)" else empty end' \
105
+ "$_cfg" 2>/dev/null || true)"
106
+ if [[ -n "${_label:-}" ]]; then
107
+ if [[ $NO_TASK -eq 1 ]]; then TITLE="${_label}"; else TITLE="${_label} · ${TASK}"; fi
79
108
  fi
80
109
  fi
81
110
 
111
+ # ── permissionMode (T45) ─────────────────────────────────────────────────────
112
+ # Precedenza: env LOOM_DECK_PERMISSION_MODE > campo del file config > 'manual'.
113
+ # Il flag è passato SEMPRE, anche per 'manual': lo spawn resta deterministico e
114
+ # leggibile nel process tree, invece di dipendere dal default del CLI (che può
115
+ # cambiare fra versioni). Un valore fuori enum NON viene passato al CLI (che
116
+ # rifiuterebbe, lasciando una tab con un comando rotto): fallback su 'manual'
117
+ # con segnalazione su stderr.
118
+ PERM_MODE="${LOOM_DECK_PERMISSION_MODE:-}"
119
+ if [[ -z "$PERM_MODE" && -n "$_cfg" ]]; then
120
+ PERM_MODE="$(jq -r '.permissionMode // empty' "$_cfg" 2>/dev/null || true)"
121
+ fi
122
+ case "${PERM_MODE:-}" in
123
+ acceptEdits|auto|bypassPermissions|manual|dontAsk|plan) ;;
124
+ '') PERM_MODE="manual" ;;
125
+ *) echo "permissionMode ignoto: '${PERM_MODE}' → fallback 'manual'" >&2
126
+ PERM_MODE="manual" ;;
127
+ esac
128
+ MODE_FLAG="--permission-mode ${PERM_MODE} "
129
+
82
130
  # --session-id per la tab: pinna il sessionId della sessione CC (vuoto → CC ne
83
131
  # genera uno). Lo spazio finale tiene la concatenazione pulita quando è assente.
84
132
  SID_FLAG=""
@@ -102,7 +150,15 @@ fi
102
150
  # `--name '<TITLE>'` = canale AUTORITATIVO del terminal-title (Ptyxis -T è solo il
103
151
  # titolo iniziale, prima che CC parta): CC lo setta e nomina anche la sessione nel
104
152
  # picker. Override-abile via LOOM_DECK_INTAB_CMD per i test empirici (probe non-CC).
105
- IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-LOOM_TASK=${TASK} claude --name '${TITLE}' ${SID_FLAG}'${PROMPT}'}"
153
+ # Ramo --no-task (T42): saltano i TRE elementi che legano la sessione alla task —
154
+ # iniezione LOOM_TASK, prompt iniziale, --session-id pinnato. Resta claude nudo,
155
+ # titolato e con il permission mode del progetto.
156
+ if [[ $NO_TASK -eq 1 ]]; then
157
+ _default_intab="claude --name '${TITLE}' ${MODE_FLAG}"
158
+ else
159
+ _default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}'${PROMPT}'"
160
+ fi
161
+ IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
106
162
 
107
163
  case "$MODE" in
108
164
  inline)