@lamemind/loom-deck 0.75.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hooks.js CHANGED
@@ -20,6 +20,7 @@ import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } fro
20
20
  import { purgeTargets } from './purge.js';
21
21
  import { loadSpawnOverrides, saveSpawnOverrides, } from './spawn-config.js';
22
22
  import { POLL_MS } from './model.js';
23
+ import { isAvailable } from './platform.js';
23
24
  // Dimensioni del terminale, live sul resize.
24
25
  //
25
26
  // Non è una comodità di layout: senza `rows` il frame non ha tetto, e un frame
@@ -203,9 +204,12 @@ export function useSessions(projectRoot, core) {
203
204
  // un refresh più lento mostrerebbe `idle` su una sessione che lavora.
204
205
  // Il try è separato perché il registry è una fonte indipendente: se manca
205
206
  // (versione del CLI che non lo scrive) la lista deve restare, senza vive.
207
+ // T175 — dove la liveness manca non si interroga nemmeno il registry: la
208
+ // mappa vuota è il dato onesto, e chi ne dipende per una guardia (`CANC`)
209
+ // legge la mappa delle funzioni, non questa.
206
210
  let live;
207
211
  try {
208
- live = discoverLiveSessions(projectRoot);
212
+ live = isAvailable('liveSessions') ? discoverLiveSessions(projectRoot) : new Map();
209
213
  }
210
214
  catch {
211
215
  live = new Map();
@@ -400,7 +404,9 @@ export function useDirtyFolders(idsSig, tasksDir, projectRoot) {
400
404
  }, [idsSig, tasksDir, projectRoot]);
401
405
  return dirty;
402
406
  }
403
- export function useInboxScan(projectRoot, docsRoot) {
407
+ /** T175 — `enabled` falso = la funzione è spenta su questa piattaforma: nessuno
408
+ * spawn, né all'avvio né a richiesta, e lo stato resta quello di partenza. */
409
+ export function useInboxScan(projectRoot, docsRoot, enabled = true) {
404
410
  const [state, setState] = useState({
405
411
  files: [],
406
412
  ok: true,
@@ -437,10 +443,12 @@ export function useInboxScan(projectRoot, docsRoot) {
437
443
  });
438
444
  }, [projectRoot, docsRoot]);
439
445
  useEffect(() => {
446
+ if (!enabled)
447
+ return;
440
448
  run();
441
449
  const id = setInterval(run, INBOX_SCAN_INTERVAL_MS);
442
450
  return () => clearInterval(id);
443
- }, [run, epoch]);
451
+ }, [run, epoch, enabled]);
444
452
  // Lo scan a richiesta RESETTA il timer, come quello del wrap: senza,
445
453
  // l'intervallo continuerebbe a scorrere dall'ultimo tick automatico e potrebbe
446
454
  // rimisurare pochi istanti dopo un dato appena misurato.
@@ -493,7 +501,11 @@ export function useDocScan(projectRoot, docsRoot, enabled) {
493
501
  }, []);
494
502
  return { ...state, scan };
495
503
  }
496
- export function useWrapScan(projectRoot) {
504
+ /** T175 — `enabled` falso = scan spento su questa piattaforma: niente periodico
505
+ * e niente scan a richiesta. Il periodico sarebbe il peggiore dei due — uno
506
+ * spawn di `python3` ogni sei ore che nessuno ha chiesto, e su Windows quel
507
+ * nome risolve nell'alias dello Store. */
508
+ export function useWrapScan(projectRoot, enabled = true) {
497
509
  const path = useMemo(() => wrapCacheFile(projectRoot), [projectRoot]);
498
510
  const [cache, setCache] = useState(() => readWrapCache(path));
499
511
  const [ok, setOk] = useState(true);
@@ -505,7 +517,7 @@ export function useWrapScan(projectRoot) {
505
517
  // secondo lancio.
506
518
  const busy = useRef(false);
507
519
  const run = useCallback(() => {
508
- if (busy.current)
520
+ if (busy.current || !enabled)
509
521
  return;
510
522
  busy.current = true;
511
523
  setScanning(true);
@@ -518,11 +530,13 @@ export function useWrapScan(projectRoot) {
518
530
  if (good)
519
531
  setCache(readWrapCache(path));
520
532
  });
521
- }, [projectRoot, path]);
533
+ }, [projectRoot, path, enabled]);
522
534
  useEffect(() => {
535
+ if (!enabled)
536
+ return;
523
537
  const id = setInterval(run, SCAN_INTERVAL_MS);
524
538
  return () => clearInterval(id);
525
- }, [run, epoch]);
539
+ }, [run, epoch, enabled]);
526
540
  const scan = useCallback(() => {
527
541
  setEpoch((e) => e + 1);
528
542
  run();
package/dist/inbox.js CHANGED
@@ -248,7 +248,7 @@ export async function scanInbox(projectRoot, docsRoot) {
248
248
  if (!script)
249
249
  return { files: [], ok: false };
250
250
  try {
251
- const { stdout } = await execFileAsync(script, ['--docs-root', docsRoot, '--inbox', '--format', 'tsv'], { cwd: projectRoot, maxBuffer: 4 * 1024 * 1024 });
251
+ const { stdout } = await execFileAsync(script, ['--docs-root', docsRoot, '--inbox', '--format', 'tsv'], { cwd: projectRoot, maxBuffer: 4 * 1024 * 1024, windowsHide: true });
252
252
  return { files: parseInboxTsv(stdout), ok: true };
253
253
  }
254
254
  catch {
package/dist/input.js CHANGED
@@ -28,6 +28,7 @@ import { TASK_VIEWS, taskView } from './pane-views.js';
28
28
  import { loadTaskFileText } from './tasks.js';
29
29
  import { inboxPrompt, loadInboxText } from './inbox.js';
30
30
  import { dirListing, loadDocText } from './doc-tree.js';
31
+ import { refuse } from './platform.js';
31
32
  // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
32
33
  // finestra, non ricopiata: un numero scritto a mano in un testo che promette un
33
34
  // comportamento diventa falso il giorno che la costante cambia, e nessuno
@@ -461,6 +462,12 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
461
462
  model.setSelSessionId((id) => moveSelection(model.sessionRows, id, 1));
462
463
  }
463
464
  else if (key.return) {
465
+ // T175 — i due pane del modo doc li riempie uno script del plugin: dove non
466
+ // gira, `⏎` lo dice invece di rispondere «nessuna riga selezionata».
467
+ if ((model.focus === 'doctree' || model.focus === 'inbox') &&
468
+ refuse('pluginScripts', '⏎', setNote, model.focus === 'inbox' ? 'coda inbox' : 'albero doc')) {
469
+ return;
470
+ }
464
471
  if (model.focus === 'doctree') {
465
472
  // T160 — `⏎` apre il DETAIL del bersaglio, non la sessione: la sessione
466
473
  // la apre il `⏎` di dentro, come sul pane inbox. Il gesto è lo stesso su
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, readdirSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
+ import { isWithin } from './project-path.js';
4
5
  function liveSessionsRoot() {
5
6
  return join(homedir(), '.claude', 'sessions');
6
7
  }
@@ -132,7 +133,9 @@ export function discoverLiveSessions(projectRoot) {
132
133
  if (!parsed)
133
134
  continue;
134
135
  const { entry, procStart } = parsed;
135
- if (entry.cwd !== projectRoot && !entry.cwd.startsWith(projectRoot + '/'))
136
+ // T175 — `isWithin` regge le grafie di Windows: il registry scrive `C:\…`,
137
+ // e la root del deck può arrivare con la lettera del drive in un altro case.
138
+ if (!isWithin(entry.cwd, projectRoot))
136
139
  continue;
137
140
  if (!isAlive(pid, procStart))
138
141
  continue;
@@ -7,6 +7,7 @@
7
7
  // preflight, D1/P12 — nessuno split dentro questa task).
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
9
  import { attentionCount, bumpableCount, bumpGitlink, disalignedCount, gitlinkNote, GITLINK_SCAN_INTERVAL_MS, hasGitmodules, scanGitlink, } from '../gitlink.js';
10
+ import { isAvailable, refuse } from '../platform.js';
10
11
  export function useGitlink(deps) {
11
12
  const { cwd, setNote } = deps;
12
13
  /**
@@ -21,6 +22,12 @@ export function useGitlink(deps) {
21
22
  * qui invece che scoperto dopo.
22
23
  */
23
24
  const enabled = useMemo(() => hasGitmodules(cwd), [cwd]);
25
+ /** T175 — il segmento esiste (c'è `.gitmodules`) ma lo script che lo misura
26
+ * non gira su questa piattaforma: l'indicatore dice `n/d` al posto del
27
+ * valore, nessun timer parte, `^U` lo spiega. Due gate e non uno, perché sono
28
+ * due fatti diversi: un repo senza submodule e una piattaforma senza bash. */
29
+ const available = isAvailable('pluginScripts');
30
+ const active = enabled && available;
24
31
  const [members, setMembers] = useState([]);
25
32
  const [ok, setOk] = useState(true);
26
33
  /** Almeno un tentativo è stato fatto: distingue «non ho ancora misurato» da
@@ -65,12 +72,12 @@ export function useGitlink(deps) {
65
72
  });
66
73
  }, [cwd]);
67
74
  useEffect(() => {
68
- if (!enabled)
75
+ if (!active)
69
76
  return;
70
77
  run();
71
78
  const id = setInterval(run, GITLINK_SCAN_INTERVAL_MS);
72
79
  return () => clearInterval(id);
73
- }, [enabled, run, epoch]);
80
+ }, [active, run, epoch]);
74
81
  /**
75
82
  * Rimisura adesso e riparte col timer da zero.
76
83
  *
@@ -100,6 +107,8 @@ export function useGitlink(deps) {
100
107
  setNote('^U → questo progetto non ha submodule: nessun gitlink da bumpare');
101
108
  return;
102
109
  }
110
+ if (refuse('pluginScripts', '^U', setNote, 'bump del gitlink'))
111
+ return;
103
112
  if (busy.current || bumping) {
104
113
  setNote('^U → bump del gitlink già in corso');
105
114
  return;
@@ -140,6 +149,7 @@ export function useGitlink(deps) {
140
149
  }
141
150
  return {
142
151
  enabled,
152
+ available,
143
153
  members,
144
154
  ok,
145
155
  scanned,
@@ -15,6 +15,7 @@ import { pageStep, wrapListCapacity } from '../viewport.js';
15
15
  import { cpLen, insertAt, removeAt } from '../layout.js';
16
16
  import { sanitizeTyped } from '../glyphs.js';
17
17
  import { WRAP_DEFAULT_PATH } from '../wrap-scan.js';
18
+ import { isAvailable, refuse } from '../platform.js';
18
19
  export function useWrapOverlay(deps) {
19
20
  const { cwd, rows, setMode, setNote, onApply } = deps;
20
21
  // Lo stato dello scan vive QUI e non nel modello, sul precedente di
@@ -22,7 +23,10 @@ export function useWrapOverlay(deps) {
22
23
  // numeri si leggono in testata a schermata chiusa, e la lista è una delle sue
23
24
  // superfici invece che il suo contenuto. Tenerli in due posti significherebbe
24
25
  // che l'indicatore e la lista possono dire cose diverse dello stesso scan.
25
- const measure = useWrapScan(cwd);
26
+ // T175 — su una piattaforma senza lo scan, nessun periodico e nessuno spawn:
27
+ // l'indicatore dice `n/d` e i due tasti lo spiegano.
28
+ const available = isAvailable('hardWrap');
29
+ const measure = useWrapScan(cwd, available);
26
30
  /** Le righe FOTOGRAFATE all'apertura: uno scan che finisse mentre la lista è
27
31
  * a schermo la riscriverebbe sotto gli occhi, e la posizione di scroll non
28
32
  * avrebbe più un riferimento. Stessa regola del viewer del project status. */
@@ -41,6 +45,8 @@ export function useWrapOverlay(deps) {
41
45
  * uno solo. È la stessa forma di `^O` senza cache.
42
46
  */
43
47
  function open() {
48
+ if (refuse('hardWrap', '^W', setNote, 'lista hard-wrap'))
49
+ return;
44
50
  if (measure.files.length === 0) {
45
51
  setNote(measure.mtime === null
46
52
  ? '^W → hard-wrap non ancora misurato · ^E per lo scan'
@@ -117,6 +123,8 @@ export function useWrapOverlay(deps) {
117
123
  * l'albero del progetto intero, quindi dura, e un tasto che non desse segno
118
124
  * si leggerebbe come inerte. */
119
125
  function scan() {
126
+ if (refuse('hardWrap', '^E', setNote))
127
+ return;
120
128
  if (measure.scanning) {
121
129
  setNote('^E → scan hard-wrap già in corso');
122
130
  return;
@@ -136,6 +144,7 @@ export function useWrapOverlay(deps) {
136
144
  mtime: measure.mtime,
137
145
  ok: measure.ok,
138
146
  scanning: measure.scanning,
147
+ available,
139
148
  open,
140
149
  scan,
141
150
  onKey,
@@ -21,6 +21,7 @@
21
21
  // - `↑N`/`↓N` non stanno qui: contano elementi fuori finestra per altezza del
22
22
  // terminale, cioè una POSIZIONE, non un insieme.
23
23
  import { isVisible, groupHierarchy } from './view.js';
24
+ import { isAvailable, offText, OFF_MARK } from './platform.js';
24
25
  export const TASK_VIEWS = [
25
26
  {
26
27
  id: 'tasks',
@@ -78,12 +79,21 @@ export const SESSION_VIEWS = [
78
79
  empty: null,
79
80
  },
80
81
  {
82
+ // T175 — dove la liveness manca, la voce resta nel catalogo (D1 create: le
83
+ // voci non spariscono) ma il numero no: `●0 vive` direbbe «ho guardato e
84
+ // nessuna è aperta», cioè uno stato misurato che non esiste. Etichetta e
85
+ // nota vuota si leggono alla chiamata, non all'import, così la piattaforma
86
+ // dei test si cambia fra un caso e l'altro.
81
87
  id: 'live',
82
- label: (c) => `${LIVE_GLYPH}${c.live} vive`,
88
+ label: (c) => isAvailable('liveSessions') ? `${LIVE_GLYPH}${c.live} vive` : `${LIVE_GLYPH} vive ${OFF_MARK}`,
83
89
  count: (c) => c.live,
84
90
  rows: (ctx) => ctx.assembled.rows.filter((r) => ctx.isLive(r.sessionId)),
85
91
  color: 'green',
86
- empty: 'nessuna conversazione viva in questa lista',
92
+ get empty() {
93
+ return isAvailable('liveSessions')
94
+ ? 'nessuna conversazione viva in questa lista'
95
+ : offText('liveSessions');
96
+ },
87
97
  },
88
98
  {
89
99
  // T133 D7 — sola vista che NON filtra la lista: il suo insieme è più largo
@@ -0,0 +1,94 @@
1
+ // T175 — COSA il deck sa fare sulla piattaforma su cui gira.
2
+ //
3
+ // Un modulo solo e non un `process.platform` sparso nei file: la domanda «questa
4
+ // funzione c'è qui?» ha una risposta sola, e la mappa di cosa è spento e perché
5
+ // si legge in un posto invece che cercando dieci `if`. Le figlie di porting
6
+ // dell'epica T173 riaccendono una funzione TOGLIENDO UNA VOCE da `UNAVAILABLE`:
7
+ // i tasti, i pane e gli scan leggono la mappa, non la piattaforma.
8
+ //
9
+ // Modulo PURO: nessun React, nessun I/O. L'unico ingresso esterno è la
10
+ // piattaforma stessa, letta a ogni chiamata (`platform()`), così un test può
11
+ // cambiarla fra un caso e l'altro senza re-importare il modulo.
12
+ /**
13
+ * LA MAPPA: per piattaforma, le funzioni spente. Una piattaforma assente le ha
14
+ * tutte — è il comportamento di prima di T175 su Linux e su macOS.
15
+ *
16
+ * Il commento accanto a ogni voce nomina la figlia di T173 che la riaccende.
17
+ */
18
+ export const UNAVAILABLE = {
19
+ win32: [
20
+ 'spawn', // T176 — tab di Windows Terminal al posto di ptyxis
21
+ 'liveSessions', // T177 — liveness senza /proc
22
+ 'pluginScripts', // T178 — .sh del plugin via Git Bash
23
+ 'hardWrap', // T178 — python del wrap-scan
24
+ 'launch', // T179 — comando per piattaforma nelle voci launch[]
25
+ 'notify', // T180 — toast al posto di notify-send
26
+ ],
27
+ };
28
+ /** Come una funzione si nomina in un avviso, quando il chiamante non ne dà un
29
+ * nome più preciso (il tasto che l'ha chiesta, per esempio). */
30
+ const FEATURE_LABEL = {
31
+ spawn: 'apertura di tab e conversazioni',
32
+ liveSessions: 'sessioni vive',
33
+ pluginScripts: 'script del plugin',
34
+ hardWrap: 'scan hard-wrap',
35
+ launch: 'voci launch',
36
+ notify: 'notifiche',
37
+ };
38
+ const PLATFORM_NAME = {
39
+ win32: 'Windows',
40
+ linux: 'Linux',
41
+ darwin: 'macOS',
42
+ };
43
+ /**
44
+ * Il marcatore di una funzione spenta, al posto del valore: in un indicatore
45
+ * della testata (`gitlink n/d`), in un'etichetta di vista (`● vive n/d`).
46
+ *
47
+ * Il marcatore SOSTITUISCE il valore e non lo nasconde: un indicatore assente è
48
+ * già uno stato (il gitlink non disegnato vuol dire «repo senza `.gitmodules`»),
49
+ * e un `0` direbbe «ho misurato e non c'è niente».
50
+ */
51
+ export const OFF_MARK = 'n/d';
52
+ /**
53
+ * La piattaforma delle FUNZIONI. `LOOM_DECK_PLATFORM` la sostituisce, ed esiste
54
+ * per i test: rende verificabile su Linux il deck di Windows, avvisi compresi.
55
+ *
56
+ * Vale solo per la mappa delle funzioni. La semantica dei path (`project-path.ts`)
57
+ * legge la piattaforma vera, perché descrive il filesystem e non una scelta.
58
+ */
59
+ export function platform() {
60
+ return process.env.LOOM_DECK_PLATFORM || process.platform;
61
+ }
62
+ export function platformName(p = platform()) {
63
+ return PLATFORM_NAME[p] ?? p;
64
+ }
65
+ /** La funzione c'è su questa piattaforma. */
66
+ export function isAvailable(feature, p = platform()) {
67
+ return !(UNAVAILABLE[p] ?? []).includes(feature);
68
+ }
69
+ /** Il testo di una funzione spenta, per un pane o una vista: nomina la funzione
70
+ * e la piattaforma. `n/d` e non «non disponibile»: resta invariato davanti a un
71
+ * nome singolare o plurale. */
72
+ export function offText(feature, what, p = platform()) {
73
+ return `${what ?? FEATURE_LABEL[feature]} ${OFF_MARK} su ${platformName(p)}`;
74
+ }
75
+ /** La riga di stato di un tasto spento: il tasto, la funzione, la piattaforma. */
76
+ export function offNote(key, feature, what, p = platform()) {
77
+ return `⚠ ${key} → ${offText(feature, what, p)}`;
78
+ }
79
+ /**
80
+ * La GUARDIA di un tasto: `true` se la funzione è spenta, e in quel caso
81
+ * l'avviso è già nella riga di stato. Il chiamante esce e basta:
82
+ *
83
+ * if (refuse('spawn', '^K', setNote)) return;
84
+ *
85
+ * Un tasto spento che non facesse niente in silenzio sarebbe indistinguibile da
86
+ * un binding mancante, e uno che arrivasse fino al processo esterno fallirebbe
87
+ * con un messaggio che non nomina la piattaforma («ptyxis non lanciabile»).
88
+ */
89
+ export function refuse(feature, key, setNote, what) {
90
+ if (isAvailable(feature))
91
+ return false;
92
+ setNote(offNote(key, feature, what));
93
+ return true;
94
+ }
@@ -0,0 +1,71 @@
1
+ // T175 — i confronti di path che il deck fa sulla root del progetto, per
2
+ // piattaforma.
3
+ //
4
+ // Tre punti confrontavano path come stringhe POSIX: il filtro delle
5
+ // conversazioni del progetto (`sessions.ts`), quello delle sessioni vive
6
+ // (`live-sessions.ts`) e il nome del progetto (`deck-model.ts`). Su Linux la
7
+ // stringa È il path; su Windows no, per tre ragioni misurate (verdetto 1 di T174):
8
+ //
9
+ // - NTFS non distingue le maiuscole, e Claude Code non normalizza la grafia
10
+ // della cwd: VS Code passa `c:\…`, Windows Terminal `C:\…`, e lo stesso
11
+ // progetto ha due cartelle di transcript (`c--…` e `C--…`);
12
+ // - `\` e `/` sono entrambi separatori;
13
+ // - nei transcript `cwd` compare anche in forma MSYS (`/c/…`), quella di Git
14
+ // Bash.
15
+ //
16
+ // Modulo PURO. La piattaforma è un parametro con default quella VERA, e non
17
+ // quella di `LOOM_DECK_PLATFORM` (`platform.ts`): qui si descrive il filesystem
18
+ // su cui il deck gira, non una funzione che si può spegnere.
19
+ import { posix, win32 } from 'node:path';
20
+ /**
21
+ * La forma di un path su cui si confronta, mai quella che si mostra.
22
+ *
23
+ * Su win32: separatori portati a `\`, forma MSYS `/c/…` riportata a `C:\…`,
24
+ * tutto minuscolo, nessun separatore in coda (tranne la radice del drive, dove
25
+ * `C:` da solo vorrebbe dire «la cartella corrente di C:»). Altrove il path così
26
+ * com'è, senza il separatore in coda: il confronto resta case-sensitive come il
27
+ * filesystem.
28
+ */
29
+ export function comparablePath(p, platform = process.platform) {
30
+ if (platform !== 'win32')
31
+ return p.length > 1 ? p.replace(/\/+$/, '') : p;
32
+ let s = p.replace(/\//g, '\\');
33
+ // `\c\Users\…` (la forma MSYS dopo la sostituzione dei separatori) →
34
+ // `c:\Users\…`. Un UNC (`\\server\…`) non matcha: il secondo carattere è `\`.
35
+ const msys = /^\\([a-zA-Z])(?:\\|$)/.exec(s);
36
+ if (msys)
37
+ s = `${msys[1]}:\\${s.slice(msys[0].length)}`;
38
+ s = s.replace(/\\+$/, '');
39
+ if (/^[a-zA-Z]:$/.test(s))
40
+ s += '\\';
41
+ return s.toLowerCase();
42
+ }
43
+ /** `path` è la root stessa o sta sotto di lei. Mai un prefisso di nome: la root
44
+ * `C:\a\loom` non contiene `C:\a\loom-works`. */
45
+ export function isWithin(path, root, platform = process.platform) {
46
+ const p = comparablePath(path, platform);
47
+ const r = comparablePath(root, platform);
48
+ if (p === r)
49
+ return true;
50
+ const sep = platform === 'win32' ? '\\' : '/';
51
+ return p.startsWith(r.endsWith(sep) ? r : r + sep);
52
+ }
53
+ /** Il nome della cartella del progetto: l'ultimo segmento della root, con i
54
+ * separatori della piattaforma. Root senza segmenti (`/`, `C:\`) → la root. */
55
+ export function projectBaseName(cwd, platform = process.platform) {
56
+ return (platform === 'win32' ? win32 : posix).basename(cwd) || cwd;
57
+ }
58
+ /**
59
+ * Le cartelle di transcript del progetto, fra i nomi di `~/.claude/projects/`.
60
+ *
61
+ * Su Linux una sola, il nome esatto. Su win32 ogni nome che coincide ignorando
62
+ * le maiuscole: la stessa root avviata da VS Code (`c:\…`) e da Windows
63
+ * Terminal (`C:\…`) scrive in `c--…` e in `C--…`, e leggerne una sola perde le
64
+ * conversazioni dell'altra.
65
+ */
66
+ export function matchingDirNames(entries, name, platform = process.platform) {
67
+ if (platform !== 'win32')
68
+ return entries.filter((e) => e === name);
69
+ const want = name.toLowerCase();
70
+ return entries.filter((e) => e.toLowerCase() === want);
71
+ }
package/dist/purge.js CHANGED
@@ -80,6 +80,7 @@ export function folderSurvivors(projectRoot, folder) {
80
80
  const out = execFileSync('git', ['ls-files', '-o', '--', rel], {
81
81
  cwd: projectRoot,
82
82
  encoding: 'utf8',
83
+ windowsHide: true,
83
84
  });
84
85
  return out.split('\n').filter(Boolean);
85
86
  }
package/dist/sessions.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { sanitize } from './width.js';
5
+ import { isWithin, matchingDirNames } from './project-path.js';
5
6
  // CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
6
7
  // (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
7
8
  // (un '-' nel path e un '/' collassano nello stesso char) → il forward è
@@ -308,51 +309,84 @@ export function compareSessions(a, b) {
308
309
  return b.lastReplyTs - a.lastReplyTs;
309
310
  return b.ts - a.ts;
310
311
  }
311
- // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
312
- // T27): legge la project dir calcolata dal forward-transform, filtra per cwd
313
- // (difesa contro le collisioni lossy del naming), ordina con `compareSessions`.
314
- export function discoverProjectSessions(projectRoot) {
315
- const dir = join(claudeProjectsRoot(), projectDirName(projectRoot));
316
- let files;
312
+ /**
313
+ * T175 — le cartelle di transcript del progetto.
314
+ *
315
+ * Su Linux una sola, il nome esatto, e senza leggere la cartella madre: il poll
316
+ * gira ogni 1,5 s, e un `readdir` di `~/.claude/projects/` costerebbe a ogni
317
+ * tick per trovare un nome già noto. Su win32 tutte quelle che coincidono a
318
+ * meno delle maiuscole (`matchingDirNames`): Claude Code non normalizza la
319
+ * lettera del drive, e lo stesso progetto ne ha due.
320
+ */
321
+ function transcriptDirs(projectRoot) {
322
+ const root = claudeProjectsRoot();
323
+ const name = projectDirName(projectRoot);
324
+ if (process.platform !== 'win32')
325
+ return [join(root, name)];
326
+ let entries;
317
327
  try {
318
- files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
328
+ entries = readdirSync(root);
319
329
  }
320
330
  catch {
321
331
  return [];
322
332
  }
323
- const out = [];
333
+ return matchingDirNames(entries, name).map((e) => join(root, e));
334
+ }
335
+ // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
336
+ // T27): legge le project dir calcolate dal forward-transform, filtra per cwd
337
+ // (difesa contro le collisioni lossy del naming), ordina con `compareSessions`.
338
+ //
339
+ // D1 preflight T175 — restano fuori le conversazioni AVVIATE in una
340
+ // sottocartella, su ogni piattaforma: Claude Code ne scrive il transcript nella
341
+ // cartella che codifica la cwd di avvio, cioè un'altra. Il filtro accetta una
342
+ // cwd sotto la root perché non scarta niente di legittimo, non per raccoglierle.
343
+ export function discoverProjectSessions(projectRoot) {
344
+ // Keyed sul sessionId: su win32 due cartelle dello stesso progetto potrebbero
345
+ // portare lo stesso transcript, e due righe con un id solo romperebbero la
346
+ // selezione. Vince il più recente.
347
+ const out = new Map();
324
348
  const seen = new Set();
325
- for (const f of files) {
326
- const path = join(dir, f);
327
- seen.add(path);
328
- let mtime;
329
- let sizeBytes;
349
+ for (const dir of transcriptDirs(projectRoot)) {
350
+ let files;
330
351
  try {
331
- const st = statSync(path);
332
- mtime = st.mtimeMs;
333
- sizeBytes = st.size;
352
+ files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
334
353
  }
335
354
  catch {
336
355
  continue;
337
356
  }
338
- const cached = cache.get(path);
339
- let session;
340
- if (cached && cached.mtime === mtime) {
341
- session = cached.session;
342
- }
343
- else {
344
- session = parseSessionFile(path, mtime, sizeBytes);
345
- cache.set(path, { mtime, session });
346
- }
347
- if (session && (session.cwd === projectRoot || session.cwd.startsWith(projectRoot + '/'))) {
348
- out.push(session);
357
+ for (const f of files) {
358
+ const path = join(dir, f);
359
+ seen.add(path);
360
+ let mtime;
361
+ let sizeBytes;
362
+ try {
363
+ const st = statSync(path);
364
+ mtime = st.mtimeMs;
365
+ sizeBytes = st.size;
366
+ }
367
+ catch {
368
+ continue;
369
+ }
370
+ const cached = cache.get(path);
371
+ let session;
372
+ if (cached && cached.mtime === mtime) {
373
+ session = cached.session;
374
+ }
375
+ else {
376
+ session = parseSessionFile(path, mtime, sizeBytes);
377
+ cache.set(path, { mtime, session });
378
+ }
379
+ if (!session || !isWithin(session.cwd, projectRoot))
380
+ continue;
381
+ const prev = out.get(session.sessionId);
382
+ if (!prev || session.ts > prev.ts)
383
+ out.set(session.sessionId, session);
349
384
  }
350
385
  }
351
386
  for (const key of cache.keys())
352
387
  if (!seen.has(key))
353
388
  cache.delete(key);
354
- out.sort(compareSessions);
355
- return out;
389
+ return [...out.values()].sort(compareSessions);
356
390
  }
357
391
  // Raggruppa per gitBranch (D2: group-by-branch nel progetto corrente). Ordine
358
392
  // dei gruppi = la prima sessione di ciascuno, con `compareSessions`; dentro il
package/dist/spawn.js CHANGED
@@ -8,6 +8,9 @@ import { dirname, join } from 'node:path';
8
8
  // è DATO PURO e non importa questo file: la dipendenza va in un verso solo.
9
9
  // Qui restano i soli effetti verso l'esterno — comporre un argv e lanciarlo.
10
10
  import { MODEL_DEFAULT, PROJECT_STATUS_MODEL } from './spawn-catalog.js';
11
+ import { isAvailable } from './platform.js';
12
+ import { claudeLauncher } from './claude-cmd.js';
13
+ import { claudeEnv } from './git-bash.js';
11
14
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
12
15
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
13
16
  export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
@@ -27,9 +30,14 @@ export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scr
27
30
  * test misura resta identico.
28
31
  */
29
32
  export const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
33
+ // T175 — `windowsHide` su OGNI figlio, qui e non a ogni chiamante: su Windows
34
+ // un processo console lanciato senza apre una finestra (un `claude -p`, un
35
+ // `git`), e ogni sede di spawn che la dimenticasse ne farebbe lampeggiare una a
36
+ // ogni giro. Su Linux l'opzione non ha effetto. Un chiamante può ancora
37
+ // sovrascriverla, perché `opts` viene dopo.
30
38
  export function spawnOut(cmd, args, opts) {
31
39
  if (!NO_SPAWN)
32
- return spawn(cmd, args, opts);
40
+ return spawn(cmd, args, { windowsHide: true, ...opts });
33
41
  // Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
34
42
  // restano appesi senza mai scattare — che è esattamente "non è successo niente".
35
43
  const fake = new EventEmitter();
@@ -347,8 +355,11 @@ export function spawnTerminal(cwd, title) {
347
355
  child.unref();
348
356
  return child;
349
357
  }
350
- // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
351
- export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
358
+ // Il NOME di claude nelle note della riga di stato: l'override se c'è,
359
+ // `claude` altrimenti. Non è ciò che si esegue — quello lo risolve
360
+ // `claudeLauncher` (`claude-cmd.ts`), che su Windows può arrivare a
361
+ // `node …cli.js` da uno shim npm: nelle note resta il nome che l'utente conosce.
362
+ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD || 'claude';
352
363
  // T30: invocazione di una skill del plugin da CC HEADLESS (`-p`), con
353
364
  // `--session-id` pinnato. È la TERZA strada per eseguire uno script del plugin
354
365
  // da un consumer che non ha `CLAUDE_PLUGIN_ROOT`: il path version-pinned della
@@ -365,7 +376,12 @@ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
365
376
  // ha funzionato" — che su un'operazione distruttiva non basta a sapere se
366
377
  // qualcosa è stato rimosso.
367
378
  function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
368
- const child = spawnOut(CLAUDE_CMD, [
379
+ // T175 — il lanciatore risolto (shim npm compreso, mai una shell) e, su
380
+ // Windows, l'env col bash di Git: senza, un deck avviato da Git Bash passa al
381
+ // figlio il `PATH` che manda gli hook del plugin in PowerShell.
382
+ const claude = claudeLauncher();
383
+ const child = spawnOut(claude.cmd, [
384
+ ...claude.prefix,
369
385
  '-p',
370
386
  '--output-format',
371
387
  'stream-json',
@@ -374,7 +390,7 @@ function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
374
390
  sessionId,
375
391
  ...extraArgs,
376
392
  prompt,
377
- ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
393
+ ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() });
378
394
  let buf = '';
379
395
  let isError = null;
380
396
  let detail = '';
@@ -491,7 +507,13 @@ export const NOTIFY_CMD = process.env.LOOM_DECK_NOTIFY_CMD ?? 'notify-send';
491
507
  // Agganciato, la degradazione è MUTA (D6): un binario assente è una proprietà
492
508
  // stabile della macchina, e dirlo a ogni generazione sarebbe lo stesso messaggio
493
509
  // a ogni giro su una funzione che non è il deliverable.
510
+ //
511
+ // T175 — dove le notifiche non ci sono, nessuno spawn e nessun avviso: non
512
+ // hanno un tasto né un pane, e l'esito del project status è già nella riga di
513
+ // stato. Il `null` dice al chiamante che niente è partito.
494
514
  export function notifyDone(title, body) {
515
+ if (!isAvailable('notify'))
516
+ return null;
495
517
  const child = spawnOut(NOTIFY_CMD, ['--app-name=loom-deck', title, body], {
496
518
  detached: true,
497
519
  stdio: 'ignore',