@lamemind/loom-deck 0.76.0 → 0.77.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.
@@ -0,0 +1,233 @@
1
+ // T176 — la COMPOSIZIONE di una tab di conversazione: dai quattro assi di
2
+ // `deck-run` (`tab-args.ts`) a titolo, flag di `claude`, env di sessione e
3
+ // comando in-tab.
4
+ //
5
+ // Un composer solo per Linux e Windows (D1 preflight). Cambia per piattaforma
6
+ // solo CHI riceve il risultato: su Linux una riga per `bash -lc` dentro una tab
7
+ // Ptyxis, su Windows l'argv di `claude` in una variabile d'ambiente letta dal
8
+ // wrapper della tab (`windows-terminal.ts`). Titolo, modello, permission mode e
9
+ // prompt si risolvono qui una volta, per entrambe.
10
+ //
11
+ // Modulo PURO: il file di progetto, il catalogo dei prompt e l'env arrivano già
12
+ // letti. Le regole e i messaggi sono quelli dello script bash che questo modulo
13
+ // ha sostituito, e il gate di parità su Linux (`test/deck-run.test.ts`) li
14
+ // misura sul comando in-tab.
15
+ import { saneNote } from './sane-note.js';
16
+ import { MODEL_DEFAULT, MODELS } from './spawn-catalog.js';
17
+ import { shellQuote } from './spawn.js';
18
+ export const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan'];
19
+ /** L'alfabeto della chiave di stato: il valore entra in `bash -lc "…"` e il
20
+ * file di progetto è editabile a mano, quindi si scarta invece di quotare. */
21
+ const STATE_KEY_RE = /^[A-Za-z0-9_-]+$/;
22
+ /**
23
+ * Un campo del file di progetto come lo leggeva `jq -r '.campo // empty'`:
24
+ * `null`, `false` o assente danno vuoto, una stringa passa com'è, ogni altro
25
+ * valore diventa il suo JSON (`jq -r` stampa così numeri e oggetti).
26
+ */
27
+ function configField(config, key) {
28
+ if (!config || typeof config !== 'object' || Array.isArray(config))
29
+ return '';
30
+ const v = config[key];
31
+ if (v === undefined || v === null || v === false)
32
+ return '';
33
+ return typeof v === 'string' ? v : JSON.stringify(v);
34
+ }
35
+ /**
36
+ * La label del progetto, `{emoji} {name}`: la chiave con cui compass riconosce
37
+ * la finestra cercando nel titolo della tab attiva. Vuota se il file manca o
38
+ * non porta entrambi i campi, e il titolo ricade su `cc`.
39
+ */
40
+ export function projectLabel(config) {
41
+ const emoji = configField(config, 'emoji');
42
+ const name = configField(config, 'name');
43
+ return emoji && name ? `${emoji} ${name}` : '';
44
+ }
45
+ /**
46
+ * Il titolo della tab: label + `· <task>` + nota + `· fork`.
47
+ *
48
+ * La label sta in TESTA perché il match di compass è un `.includes` sulla
49
+ * chiave, e tutto il resto va in coda per non spezzarla. Il suffisso task
50
+ * distingue le tab di più task, la nota (T64) quelle di più conversazioni sulla
51
+ * stessa task, `· fork` (T28) un ramo dalla sua origine. La nota è l'unico testo
52
+ * libero del titolo: non si quota, si RIDUCE (`saneNote`) — e ridotta a vuoto
53
+ * non lascia nessun suffisso.
54
+ */
55
+ export function tabTitle(req, label) {
56
+ let title;
57
+ if (req.noTask)
58
+ title = label || 'cc';
59
+ else
60
+ title = label ? `${label} · ${req.task}` : `cc ${req.task}`;
61
+ if (req.titleNote) {
62
+ const note = saneNote(req.titleNote);
63
+ if (note)
64
+ title = `${title} ${note}`;
65
+ }
66
+ if (req.fork)
67
+ title = `${title} · fork`;
68
+ return title;
69
+ }
70
+ /**
71
+ * Il permission mode: env `LOOM_DECK_PERMISSION_MODE` > campo del file > `manual`.
72
+ *
73
+ * Passato SEMPRE al CLI, anche `manual`: lo spawn resta deterministico invece
74
+ * di dipendere da un default che cambia fra versioni. Un valore fuori elenco
75
+ * non arriva al CLI — produrrebbe una tab con un comando rotto — e degrada a
76
+ * `manual` con un avviso.
77
+ */
78
+ function permissionMode(ctx, warnings) {
79
+ const raw = ctx.env.LOOM_DECK_PERMISSION_MODE || configField(ctx.config, 'permissionMode');
80
+ if (!raw)
81
+ return 'manual';
82
+ if (PERMISSION_MODES.includes(raw))
83
+ return raw;
84
+ warnings.push(`permissionMode ignoto: '${raw}' → fallback 'manual'`);
85
+ return 'manual';
86
+ }
87
+ /**
88
+ * Il kind di catalogo effettivo: solo sul ramo bound senza prompt letterale.
89
+ * `--no-task` e `--prompt` non hanno una riga di catalogo da consultare, né per
90
+ * il prompt né per il modello.
91
+ */
92
+ function catalogKind(req) {
93
+ return !req.promptGiven && !req.noTask ? req.promptKind || 'recap' : '';
94
+ }
95
+ /**
96
+ * Il modello: `--model` > env `LOOM_DECK_MODEL` > modello del kind nel catalogo
97
+ * > `MODEL_DEFAULT`.
98
+ *
99
+ * Il deck TUI passa SEMPRE `--model` già risolto (T161), quindi dal deck la
100
+ * cascata si ferma al primo gradino: i tre sotto servono a chi invoca a mano.
101
+ * Le voci restano ALIAS fino dentro `claude --model`: un id versionato cablato
102
+ * qui diventerebbe un modello inesistente al primo cambio di generazione. Un
103
+ * valore ignoto degrada al default con un avviso — arriverebbe al CLI, che lo
104
+ * rifiuterebbe all'avvio.
105
+ */
106
+ function modelOf(req, ctx, warnings) {
107
+ let model = req.model || ctx.env.LOOM_DECK_MODEL || '';
108
+ const kind = catalogKind(req);
109
+ if (!model && kind && kind !== 'none')
110
+ model = ctx.catalog.get(kind)?.model ?? '';
111
+ if (!model)
112
+ return MODEL_DEFAULT;
113
+ if (MODELS.includes(model))
114
+ return model;
115
+ warnings.push(`modello ignoto: '${model}' → fallback '${MODEL_DEFAULT}' (usa ${MODELS.join('|')})`);
116
+ return MODEL_DEFAULT;
117
+ }
118
+ /**
119
+ * Il prompt iniziale, vuoto = nessun prompt.
120
+ *
121
+ * `--prompt` vince su tutto ed è usato così com'è. Una sessione nuda senza
122
+ * `--prompt` non ha prompt: il default `recap` del catalogo non si applica,
123
+ * perché ogni template interpola `{TASK}` e con la task vuota sarebbe un
124
+ * comando monco. Sul ramo bound il kind sceglie il template; un catalogo che
125
+ * non ha la voce è un'installazione mutila, e la sessione parte senza prompt
126
+ * dicendolo. `LOOM_DECK_ENTER_PROMPT` vince sul kind, tranne su `none`.
127
+ */
128
+ function promptOf(req, ctx, warnings) {
129
+ if (req.promptGiven)
130
+ return req.promptText;
131
+ if (req.noTask)
132
+ return '';
133
+ const kind = catalogKind(req);
134
+ if (kind === 'none')
135
+ return '';
136
+ const enter = ctx.env.LOOM_DECK_ENTER_PROMPT;
137
+ const template = ctx.catalog.get(kind)?.template;
138
+ if (template === undefined) {
139
+ warnings.push(`catalogo prompt non leggibile ('${ctx.catalogPath}'): sessione senza prompt iniziale`);
140
+ }
141
+ const text = enter || template || '';
142
+ return text.replaceAll('{TASK}', req.task);
143
+ }
144
+ /**
145
+ * La chiave di stato per compass, solo su Linux: env `LOOM_DECK_STATE_PROFILE`
146
+ * (anche vuota = nessun annuncio) > `id` del file di progetto.
147
+ *
148
+ * Lo stato di una sessione viaggia verso compass via D-Bus keyed su
149
+ * `PTYXIS_PROFILE`, e compass lo associa a un progetto solo se la chiave è l'`id`
150
+ * di un progetto del registry. Senza la variabile forzata in-tab, Ptyxis ne
151
+ * esporterebbe una col valore del profilo di default, e il badge resterebbe
152
+ * fermo senza nessun errore. Il nome della variabile è storico: non contiene
153
+ * l'UUID di un profilo. Su Windows compass non esiste, e la chiave non serve.
154
+ */
155
+ function stateKey(ctx, warnings) {
156
+ if (ctx.platform === 'win32')
157
+ return '';
158
+ const raw = ctx.env.LOOM_DECK_STATE_PROFILE !== undefined
159
+ ? ctx.env.LOOM_DECK_STATE_PROFILE
160
+ : configField(ctx.config, 'id');
161
+ if (raw && !STATE_KEY_RE.test(raw)) {
162
+ warnings.push(`profilo di stato ignorato (caratteri non ammessi): '${raw}'`);
163
+ return '';
164
+ }
165
+ return raw;
166
+ }
167
+ /** Un testo come UN argomento di una shell POSIX: sempre fra apici singoli, con
168
+ * l'apice stesso chiuso, escapato e riaperto (`'\''`). Dentro gli apici nessun
169
+ * altro carattere resta speciale, newline compresi. */
170
+ export function posixQuote(text) {
171
+ return `'${text.replace(/'/g, `'\\''`)}'`;
172
+ }
173
+ /**
174
+ * La composizione intera.
175
+ *
176
+ * Ordine degli argomenti di `claude`: nome, permission mode e modello prima dei
177
+ * flag di continuità, il prompt sempre ultimo. Il modello sta accanto al
178
+ * permission mode perché sono la stessa cosa — la scelta di chi apre la
179
+ * sessione — e non una proprietà della task né della ripresa.
180
+ *
181
+ * Su una ripresa il prompt salta anche sul ramo bound: riprendere significa
182
+ * continuare la conversazione, non iniettarle un messaggio nuovo; il fork è una
183
+ * ripresa con id nuovo e ricade nello stesso ramo. Il `--session-id` vale invece
184
+ * anche sul ramo nudo: nel fork di una sessione spot rende noto in anticipo
185
+ * l'id del ramo, senza il quale il lineage non sarebbe registrabile.
186
+ */
187
+ export function composeTab(req, ctx) {
188
+ const warnings = [];
189
+ const title = tabTitle(req, projectLabel(ctx.config));
190
+ const mode = permissionMode(ctx, warnings);
191
+ const model = modelOf(req, ctx, warnings);
192
+ const key = stateKey(ctx, warnings);
193
+ const prompt = promptOf(req, ctx, warnings);
194
+ const withPrompt = prompt !== '' && !req.resumeId;
195
+ const flags = ['--permission-mode', mode, '--model', model];
196
+ if (req.sessionId)
197
+ flags.push('--session-id', req.sessionId);
198
+ if (req.resumeId)
199
+ flags.push('--resume', req.resumeId);
200
+ if (req.fork)
201
+ flags.push('--fork-session');
202
+ const sessionEnv = {};
203
+ if (key)
204
+ sessionEnv.PTYXIS_PROFILE = key;
205
+ if (!req.noTask)
206
+ sessionEnv.LOOM_TASK = req.task;
207
+ // La riga in-tab nella grafia storica dello script: ogni flag seguito da uno
208
+ // spazio, il prompt in coda fra apici. Il gate di parità su Linux confronta
209
+ // questa riga byte per byte, spazio finale compreso quando il prompt manca.
210
+ const envPrefix = Object.entries(sessionEnv)
211
+ .map(([k, v]) => `${k}=${shellQuote(v)} `)
212
+ .join('');
213
+ const flagText = flags.map((f) => `${f} `).join('');
214
+ const inTab = `${envPrefix}claude --name ${posixQuote(title)} ${flagText}` +
215
+ (withPrompt ? posixQuote(prompt) : '');
216
+ return {
217
+ title,
218
+ claudeArgs: ['--name', title, ...flags, ...(withPrompt ? [prompt] : [])],
219
+ sessionEnv,
220
+ inTab,
221
+ warnings,
222
+ };
223
+ }
224
+ /**
225
+ * L'argv di Ptyxis: una tab nella finestra attiva (quella col focus, cioè il
226
+ * deck), o una finestra nuova con `--new-window` (T162) per chi chiama da fuori
227
+ * dalla finestra del progetto. Il comando gira dentro `bash -lc`, e le env di
228
+ * sessione viaggiano DENTRO il comando: `ptyxis --tab` consegna la tab
229
+ * all'istanza già aperta, che non eredita l'env di chi la chiede.
230
+ */
231
+ export function ptyxisArgv(newWindow, workdir, title, inTab) {
232
+ return [newWindow ? '--new-window' : '--tab', '-d', workdir, '-T', title, '--', 'bash', '-lc', inTab];
233
+ }
@@ -0,0 +1,169 @@
1
+ // T176 — `deck-run` come programma: legge l'argv, compone la tab, la annuncia e
2
+ // la apre nel terminale della piattaforma.
3
+ //
4
+ // Il contratto verso chi lo lancia è quello dello script bash che ha sostituito:
5
+ // - uscita 2 e messaggio su stderr per un errore d'uso, nessun annuncio;
6
+ // - prima di aprire la tab, su stdout, la riga `LOOM_DECK_INTAB <comando>` col
7
+ // comando che gira nella tab — il deck la legge e la mostra nella riga di
8
+ // stato (`onInTabCommand`), invece di ricomporla;
9
+ // - poi il terminale, e l'uscita è la sua: `ptyxis --tab` e `wt.exe new-tab`
10
+ // escono appena hanno consegnato la tab all'istanza già aperta.
11
+ //
12
+ // L'I/O verso l'esterno passa da `DeckRunIo`, così il test della composizione
13
+ // completa non apre tab. Il filesystem no: file di progetto e catalogo si
14
+ // leggono dal disco anche nei test, su cartelle temporanee.
15
+ import { spawn } from 'node:child_process';
16
+ import { existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { constants } from 'node:os';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { isUsageError, parseTabArgs } from './tab-args.js';
21
+ import { composeTab, ptyxisArgv } from './tab-compose.js';
22
+ import { loadPromptCatalog, PROMPT_CATALOG } from './prompt-catalog.js';
23
+ import { configFilePath } from './config.js';
24
+ import { resolveGitBash } from './git-bash.js';
25
+ import { semicolonArg, TAB_ARGV_ENV, windowsTabEnv, WT_CMD, wtNewTabArgs, wtProfile, } from './windows-terminal.js';
26
+ /** Il wrapper che gira dentro una tab di Windows Terminal, nel build: lo esegue
27
+ * il node del deck, e `src/` sotto `tsx` non ha un `.js` da dargli. */
28
+ export const TAB_WRAPPER_JS = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'tab-wrapper.js');
29
+ /**
30
+ * La cartella di lavoro della tab: `LOOM_DECK_WORKDIR`, altrimenti la cartella
31
+ * corrente.
32
+ *
33
+ * Su Linux la cartella corrente è quella LOGICA, come il `$PWD` di bash: `PWD`
34
+ * se punta alla stessa cartella di `cwd` (un symlink resta un symlink nel
35
+ * titolo e nel `-d` di Ptyxis), `cwd` altrimenti. Su Windows `PWD` arriva da
36
+ * Git Bash in forma `/c/…`, che `wt -d` non capisce: vale solo `cwd`.
37
+ */
38
+ export function workdirOf(io) {
39
+ if (io.env.LOOM_DECK_WORKDIR)
40
+ return io.env.LOOM_DECK_WORKDIR;
41
+ const pwd = io.env.PWD;
42
+ if (io.platform !== 'win32' && pwd) {
43
+ try {
44
+ const a = statSync(pwd);
45
+ const b = statSync(io.cwd);
46
+ if (a.dev === b.dev && a.ino === b.ino)
47
+ return pwd;
48
+ }
49
+ catch {
50
+ // PWD che non esiste più → la cartella vera
51
+ }
52
+ }
53
+ return io.cwd;
54
+ }
55
+ /** La cartella che contiene `.claude/loom-works.json`, risalendo da `start`
56
+ * fino alla radice compresa; `null` fuori da un progetto loom. */
57
+ export function findProjectRoot(start) {
58
+ let dir = resolve(start);
59
+ for (;;) {
60
+ if (existsSync(configFilePath(dir)))
61
+ return dir;
62
+ const up = dirname(dir);
63
+ if (up === dir)
64
+ return null;
65
+ dir = up;
66
+ }
67
+ }
68
+ /** Il file di progetto parsato, o `null` se manca o è malformato: il titolo
69
+ * ricade su `cc`, il permission mode su `manual`, e lo spawn parte lo stesso. */
70
+ function loadConfig(root) {
71
+ if (!root)
72
+ return null;
73
+ try {
74
+ return JSON.parse(readFileSync(configFilePath(root), 'utf8'));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /** Un argv come una shell POSIX lo leggerebbe: per le diagnostiche. */
81
+ function describe(cmd, args) {
82
+ return [cmd, ...args].map((a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : JSON.stringify(a))).join(' ');
83
+ }
84
+ export async function runDeckRun(argv, io) {
85
+ const req = parseTabArgs(argv);
86
+ if (isUsageError(req)) {
87
+ for (const line of req.lines)
88
+ io.stderr(`${line}\n`);
89
+ return 2;
90
+ }
91
+ const workdir = workdirOf(io);
92
+ const catalogPath = io.env.LOOM_DECK_PROMPT_CATALOG || PROMPT_CATALOG;
93
+ const plan = composeTab(req, {
94
+ env: io.env,
95
+ config: loadConfig(findProjectRoot(workdir)),
96
+ catalog: loadPromptCatalog(catalogPath),
97
+ catalogPath,
98
+ platform: io.platform,
99
+ });
100
+ for (const w of plan.warnings)
101
+ io.stderr(`${w}\n`);
102
+ let cmd;
103
+ let args;
104
+ let env;
105
+ if (io.platform === 'win32') {
106
+ cmd = WT_CMD;
107
+ args = wtNewTabArgs({
108
+ newWindow: req.newWindow,
109
+ profile: wtProfile(io.env),
110
+ cwd: workdir,
111
+ title: plan.title,
112
+ command: [io.nodePath, TAB_WRAPPER_JS],
113
+ });
114
+ const bad = semicolonArg(args);
115
+ if (bad !== null) {
116
+ io.stderr(`argomento con ';' non passabile a ${WT_CMD}, che lo legge come separatore: '${bad}'\n`);
117
+ return 2;
118
+ }
119
+ env = {
120
+ ...windowsTabEnv(io.env, req.noTask ? null : req.task, resolveGitBash(io.env, existsSync)),
121
+ [TAB_ARGV_ENV]: JSON.stringify(plan.claudeArgs),
122
+ };
123
+ io.stdout(`LOOM_DECK_INTAB ${plan.inTab}\n`);
124
+ }
125
+ else {
126
+ // L'override esiste per i probe empirici (un comando non-CC nella tab): è
127
+ // una riga per bash, quindi vale solo dove la tab la esegue con bash.
128
+ const inTab = io.env.LOOM_DECK_INTAB_CMD || plan.inTab;
129
+ cmd = 'ptyxis';
130
+ args = ptyxisArgv(req.newWindow, workdir, plan.title, inTab);
131
+ io.stdout(`LOOM_DECK_INTAB ${inTab}\n`);
132
+ }
133
+ try {
134
+ return await io.launch(cmd, args, env);
135
+ }
136
+ catch (e) {
137
+ const code = e.code ?? String(e);
138
+ io.stderr(`deck-run: ${cmd} non lanciabile (${code}): ${describe(cmd, args)}\n`);
139
+ return 127;
140
+ }
141
+ }
142
+ /**
143
+ * Il lancio vero: stdio ereditato, così chi legge lo stdout di `deck-run` vede
144
+ * anche quello del terminale, come quando lo script finiva con `exec`.
145
+ *
146
+ * `windowsHide: false` e non il `true` degli altri spawn del deck: `wt.exe` è
147
+ * un'applicazione GUI, e con `true` la finestra di `-w new` partirebbe nascosta.
148
+ * Un processo GUI non apre console, quindi non c'è niente da nascondere.
149
+ */
150
+ export function launchTerminal(cmd, args, env) {
151
+ return new Promise((resolveExit, reject) => {
152
+ const child = spawn(cmd, args, { stdio: 'inherit', env: env, windowsHide: false });
153
+ child.on('error', reject);
154
+ child.on('exit', (code, signal) => {
155
+ resolveExit(code ?? 128 + (signal ? (constants.signals[signal] ?? 0) : 0));
156
+ });
157
+ });
158
+ }
159
+ export function realIo() {
160
+ return {
161
+ env: process.env,
162
+ cwd: process.cwd(),
163
+ platform: process.platform,
164
+ nodePath: process.execPath,
165
+ stdout: (s) => process.stdout.write(s),
166
+ stderr: (s) => process.stderr.write(s),
167
+ launch: launchTerminal,
168
+ };
169
+ }
@@ -0,0 +1,44 @@
1
+ // T176 — il programma che gira DENTRO una tab di Windows Terminal aperta dal
2
+ // deck: legge l'argv di `claude` dalla variabile della tab e lo lancia.
3
+ //
4
+ // Esiste perché il prompt non deve passare da nessun parser: `wt.exe` legge `;`
5
+ // come separatore di sottocomandi, cmd e PowerShell interpretano `' " & % ^ |`
6
+ // (P3 preflight). L'argv arriva come JSON nell'env, che la tab eredita da chi
7
+ // lancia `wt.exe`, e da qui va a `claude` come array, senza shell.
8
+ //
9
+ // La variabile si toglie dall'env PRIMA del lancio: è un canale verso questo
10
+ // programma, e `claude` la passerebbe a ogni hook e a ogni comando della
11
+ // sessione.
12
+ //
13
+ // `claude` è quello di `claudeLauncher` (T175): l'eseguibile nativo, lo shim npm
14
+ // letto, o `LOOM_DECK_CLAUDE_CMD`.
15
+ //
16
+ // Ctrl+C appartiene a `claude`. In una console Windows il tasto arriva a ogni
17
+ // processo attaccato, quindi anche qui: senza un handler questo processo
18
+ // uscirebbe, e la tab si chiuderebbe sotto una sessione viva. Si esce solo
19
+ // quando esce `claude`, col suo codice — e la tab si chiude o resta aperta
20
+ // secondo il `closeOnExit` del profilo (D3 preflight).
21
+ import { spawn } from 'node:child_process';
22
+ import { claudeLauncher } from './claude-cmd.js';
23
+ import { parseTabArgv, TAB_ARGV_ENV } from './windows-terminal.js';
24
+ const raw = process.env[TAB_ARGV_ENV];
25
+ delete process.env[TAB_ARGV_ENV];
26
+ const args = parseTabArgv(raw);
27
+ if (!args) {
28
+ process.stderr.write(`loom-deck: ${TAB_ARGV_ENV} assente o malformata — questa tab non sa quale conversazione aprire\n`);
29
+ process.exitCode = 2;
30
+ }
31
+ else {
32
+ process.on('SIGINT', () => { });
33
+ const claude = claudeLauncher();
34
+ // `windowsHide: false`: il figlio condivide la console della tab, che è
35
+ // proprio la finestra in cui deve comparire.
36
+ const child = spawn(claude.cmd, [...claude.prefix, ...args], { stdio: 'inherit', windowsHide: false });
37
+ child.on('error', (e) => {
38
+ process.stderr.write(`loom-deck: '${claude.cmd}' non lanciabile (${e.code ?? e.message})\n`);
39
+ process.exitCode = 127;
40
+ });
41
+ child.on('exit', (code) => {
42
+ process.exit(code ?? 1);
43
+ });
44
+ }
package/dist/tasks.js CHANGED
@@ -129,7 +129,7 @@ export function loadTasks(path) {
129
129
  * sciolti in spazi, cioè il buco `{slug}` dei template di titolo.
130
130
  *
131
131
  * Viene dal NOME del file e non dalla descrizione in `tasks.md` per una ragione
132
- * di alfabeto: il nome è già dentro quello di `_sane_note` per costruzione —
132
+ * di alfabeto: il nome è già dentro quello di `saneNote` per costruzione —
133
133
  * minuscolo, separato da trattini, senza punteggiatura — mentre la descrizione
134
134
  * porta apostrofi e `/`, che la riduzione toglie senza sostituto saldando le
135
135
  * parole (`l'epica/doc` → `lepicadoc`).
@@ -0,0 +1,153 @@
1
+ // T176 — le tab di WINDOWS TERMINAL: argv di `wt.exe`, env della tab, il canale
2
+ // che porta l'argv di `claude` al wrapper.
3
+ //
4
+ // Su Windows una conversazione si apre con
5
+ //
6
+ // wt.exe -w 0 new-tab -p loom-deck -d <root> --title <titolo>
7
+ // --suppressApplicationTitle -- <node> <tab-wrapper.js>
8
+ //
9
+ // e l'argv di `claude` (nome, flag, prompt) NON sta su quella riga: viaggia come
10
+ // JSON nella variabile `LOOM_DECK_TAB_ARGV`, perché la tab eredita l'env del
11
+ // processo che lancia `wt.exe` (T174 verdetto 5, rimisurato con `-p`). Il
12
+ // wrapper la legge, la toglie e lancia `claude` senza shell (P3 preflight).
13
+ // Così il prompt non passa né dal parser di `wt` né da una shell: `wt` legge `;`
14
+ // come separatore di sottocomandi in QUALUNQUE argomento, anche dopo `--` e
15
+ // anche escapato `\;` (misurato su Windows Terminal 1.24: la coda dopo il `;`
16
+ // diventa una tab a sé, col proprio errore), e cmd o PowerShell
17
+ // interpreterebbero `' " & % ^ |`.
18
+ //
19
+ // Modulo PURO: l'env arriva come valore, il bash di Git come stringa già
20
+ // risolta.
21
+ /** Il lanciatore di Windows Terminal: l'alias d'esecuzione in `WindowsApps`,
22
+ * che `spawn` trova nel `PATH` e lancia senza shell (misurato). */
23
+ export const WT_CMD = 'wt.exe';
24
+ /** Il profilo delle tab del deck (P9 preflight): uno solo per tutti i
25
+ * progetti, nascosto dal menu, creato a mano dall'utente nel `settings.json` di
26
+ * Windows Terminal. Porta ciò che i flag non dicono — font, `closeOnExit`, la
27
+ * shell del tasto `t`; titolo, cartella e comando li passa il deck. */
28
+ export const WT_PROFILE_DEFAULT = 'loom-deck';
29
+ /** La variabile con cui l'argv di `claude` arriva al wrapper della tab. */
30
+ export const TAB_ARGV_ENV = 'LOOM_DECK_TAB_ARGV';
31
+ /**
32
+ * I marker che una sessione Claude Code inietta nei processi figli (P4
33
+ * preflight, misurati nella shell di una sessione su Windows).
34
+ *
35
+ * La tab eredita tutto l'env del deck, e un deck avviato da una shell aperta
36
+ * dentro Claude Code li porterebbe nella conversazione nuova: con
37
+ * `CLAUDE_CODE_CHILD_SESSION` la sessione non salva il transcript («Transcript
38
+ * saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker») e non compare
39
+ * nello storico del deck. Lista chiusa e non ogni `CLAUDE*`: un
40
+ * `CLAUDE_CONFIG_DIR` o un `CLAUDE_CODE_GIT_BASH_PATH` dell'utente sparirebbero
41
+ * senza segnale, mentre un marker nuovo di una versione futura si annuncia da
42
+ * sé, nel messaggio di avvio di `claude` che lo nomina.
43
+ */
44
+ export const SESSION_MARKERS = [
45
+ 'CLAUDECODE',
46
+ 'CLAUDE_CODE_CHILD_SESSION',
47
+ 'CLAUDE_CODE_SESSION_ID',
48
+ 'CLAUDE_PID',
49
+ 'CLAUDE_EFFORT',
50
+ 'CLAUDE_CODE_MESSAGING_SOCKET',
51
+ 'CLAUDE_CODE_MESSAGING_TOKEN',
52
+ 'CLAUDE_CODE_ENTRYPOINT',
53
+ 'CLAUDE_CODE_SESSION_ATTENDED',
54
+ 'CLAUDE_CODE_EXECPATH',
55
+ ];
56
+ const GIT_BASH_ENV = 'CLAUDE_CODE_GIT_BASH_PATH';
57
+ /** Il profilo da passare a `-p`: `LOOM_DECK_WT_PROFILE` se impostata (vuota =
58
+ * nessun `-p`, il profilo di default di Windows Terminal), altrimenti
59
+ * `loom-deck`. Un profilo che non esiste non è un errore: Windows Terminal
60
+ * apre la tab col profilo di default e non dice niente (misurato), quindi il
61
+ * deck lo passa sempre senza verificarlo. */
62
+ export function wtProfile(env) {
63
+ const v = env.LOOM_DECK_WT_PROFILE;
64
+ if (v === undefined)
65
+ return WT_PROFILE_DEFAULT;
66
+ return v || null;
67
+ }
68
+ /** Il titolo come lo riceve `wt`: senza `;`, che spezzerebbe il comando. Il
69
+ * titolo è composto dal deck (label, TaskID, nota ridotta) e un `;` può
70
+ * venire solo dal nome del progetto: si toglie invece di fallire. */
71
+ export function wtTitle(title) {
72
+ return title.replace(/;/g, '');
73
+ }
74
+ /**
75
+ * L'argv di `wt.exe` per una tab nuova.
76
+ *
77
+ * `-w 0` è la finestra di Windows Terminal usata per ultima — quella da cui si
78
+ * lavora col deck —, `-w new` una finestra nuova (l'equivalente di
79
+ * `--new-window` di Ptyxis). `--suppressApplicationTitle` tiene il titolo del
80
+ * deck contro quello che il programma della tab imposta (misurato): è ciò che su
81
+ * Linux fa `-T` di Ptyxis contro l'OSC del prompt. Senza titolo il flag non si
82
+ * passa, e la tab prende quello della shell.
83
+ */
84
+ export function wtNewTabArgs(tab) {
85
+ const args = ['-w', tab.newWindow ? 'new' : '0', 'new-tab'];
86
+ if (tab.profile)
87
+ args.push('-p', tab.profile);
88
+ args.push('-d', tab.cwd);
89
+ if (tab.title !== null)
90
+ args.push('--title', wtTitle(tab.title), '--suppressApplicationTitle');
91
+ if (tab.command)
92
+ args.push('--', ...tab.command);
93
+ return args;
94
+ }
95
+ /**
96
+ * Il primo argomento di `wt` che contiene un `;`, o `null`.
97
+ *
98
+ * Il titolo è già ripulito; restano i path (la cartella, node, il wrapper) e il
99
+ * nome del profilo. Un path col `;` è legale su NTFS e `wt` non lo sa portare:
100
+ * lo spawn si ferma dicendolo, invece di aprire una tab d'errore con metà
101
+ * comando.
102
+ */
103
+ export function semicolonArg(args) {
104
+ return args.find((a) => a.includes(';')) ?? null;
105
+ }
106
+ /** Toglie una chiave ignorando le maiuscole: su Windows l'env non le distingue,
107
+ * e una copia di `process.env` può portare `Path` invece di `PATH`. */
108
+ function deleteKey(env, key) {
109
+ const want = key.toUpperCase();
110
+ for (const k of Object.keys(env))
111
+ if (k.toUpperCase() === want)
112
+ delete env[k];
113
+ }
114
+ function hasKey(env, key) {
115
+ const want = key.toUpperCase();
116
+ return Object.keys(env).some((k) => k.toUpperCase() === want && env[k]);
117
+ }
118
+ /**
119
+ * L'env di una tab aperta dal deck su Windows: quello del deck, meno i marker di
120
+ * sessione, con la task della conversazione e il bash di Git.
121
+ *
122
+ * `task` = `null` per le tab senza task (`--no-task`, il tasto `t`): una
123
+ * `LOOM_TASK` ereditata legherebbe a una task una sessione che non ne ha, e
124
+ * l'hook `SessionStart` ne inietterebbe il task file. `CLAUDE_CODE_GIT_BASH_PATH`
125
+ * entra quando manca e il bash si trova: la tab eredita anche il `PATH`, e col
126
+ * `PATH` di Git Bash `claude` eseguirebbe gli hook del plugin in PowerShell
127
+ * (T174 verdetto 9), cioè senza la task in contesto.
128
+ */
129
+ export function windowsTabEnv(env, task, gitBash) {
130
+ const out = { ...env };
131
+ for (const marker of SESSION_MARKERS)
132
+ deleteKey(out, marker);
133
+ deleteKey(out, 'LOOM_TASK');
134
+ deleteKey(out, TAB_ARGV_ENV);
135
+ if (task)
136
+ out.LOOM_TASK = task;
137
+ if (gitBash && !hasKey(env, GIT_BASH_ENV))
138
+ out[GIT_BASH_ENV] = gitBash;
139
+ return out;
140
+ }
141
+ /** L'argv di `claude` letto dalla variabile della tab, o `null` se manca o non
142
+ * è un array di stringhe. */
143
+ export function parseTabArgv(raw) {
144
+ if (!raw)
145
+ return null;
146
+ try {
147
+ const v = JSON.parse(raw);
148
+ return Array.isArray(v) && v.every((a) => typeof a === 'string') ? v : null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
package/dist/wrap-scan.js CHANGED
@@ -87,7 +87,7 @@ export const WRAP_DEFAULT_PATH = '.';
87
87
  * T161 — il buco `{path}` del titolo e del prompt di una sessione di
88
88
  * srotolamento: il path reso a parole.
89
89
  *
90
- * Le barre diventano spazi e l'estensione cade QUI, prima di `_sane_note`, il
90
+ * Le barre diventano spazi e l'estensione cade QUI, prima di `saneNote`, il
91
91
  * cui alfabeto non ha né `/` né `.`: lasciandoceli cadere le parole si
92
92
  * salderebbero (`runtimereferencedoc-systemmd`), e il titolo della tab
93
93
  * divergerebbe da quello che la lista del deck legge dal sidecar.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.76.0",
3
+ "version": "0.77.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": {
@@ -13,6 +13,7 @@
13
13
  "scripts": {
14
14
  "dev": "tsx src/cli.tsx",
15
15
  "build": "tsc",
16
+ "pretest": "tsc",
16
17
  "test": "node test/run.mjs",
17
18
  "start": "node dist/cli.js",
18
19
  "prepare": "tsc"