@lamemind/loom-deck 0.78.0 → 0.79.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.
@@ -2,8 +2,10 @@ import { readFileSync, readdirSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { isWithin } from './project-path.js';
5
- function liveSessionsRoot() {
6
- return join(homedir(), '.claude', 'sessions');
5
+ /** La cartella del registry. `LOOM_DECK_LIVE_SESSIONS_ROOT` la sposta, per i
6
+ * test che costruiscono un registro finto invece di leggere quello vero. */
7
+ export function liveSessionsRoot(env = process.env) {
8
+ return env.LOOM_DECK_LIVE_SESSIONS_ROOT || join(homedir(), '.claude', 'sessions');
7
9
  }
8
10
  /**
9
11
  * Campo 22 (`starttime`) di `/proc/<pid>/stat`, o null se la riga non è
@@ -96,24 +98,16 @@ export function parseLiveEntry(content, pid) {
96
98
  };
97
99
  }
98
100
  /**
99
- * Le sessioni VIVE del solo progetto corrente, indicizzate per `sessionId` —
100
- * la stessa chiave del sidecar `session-tasks.jsonl` e di `deck-run --resume`,
101
- * quindi il join con la riga di lista è diretto.
102
- *
103
- * Il filtro per `cwd` è d'EFFICIENZA, non di correttezza: risparmia lo `stat`
104
- * di `/proc` sui processi degli altri progetti (il registry è globale, non
105
- * per-progetto). Il match resta sul `sessionId`.
101
+ * Le entry VIVE del registry, nell'ordine di `readdir`.
106
102
  *
107
- * Nessuna cache mtime-keyed come in `sessions.ts`: le entry sono poche e di
108
- * poche centinaia di byte, mentre `status` cambia a ogni turno — una cache qui
109
- * mostrerebbe `idle` su una sessione che sta lavorando, cioè il difetto che la
110
- * colonna esiste per non avere.
103
+ * `keep` scarta un'entry PRIMA del controllo di liveness: chi filtra per
104
+ * progetto risparmia così lo `stat` di `/proc` sui processi degli altri.
111
105
  */
112
- export function discoverLiveSessions(projectRoot) {
113
- const out = new Map();
106
+ function liveEntries(root, keep = () => true) {
107
+ const out = [];
114
108
  let files;
115
109
  try {
116
- files = readdirSync(liveSessionsRoot()).filter((f) => f.endsWith('.json'));
110
+ files = readdirSync(root).filter((f) => f.endsWith('.json'));
117
111
  }
118
112
  catch {
119
113
  return out; // registry assente (CLI mai avviato, o versione che non lo scrive)
@@ -124,7 +118,7 @@ export function discoverLiveSessions(projectRoot) {
124
118
  continue;
125
119
  let content;
126
120
  try {
127
- content = readFileSync(join(liveSessionsRoot(), f), 'utf8');
121
+ content = readFileSync(join(root, f), 'utf8');
128
122
  }
129
123
  catch {
130
124
  continue; // il processo può finire fra readdir e read
@@ -133,16 +127,101 @@ export function discoverLiveSessions(projectRoot) {
133
127
  if (!parsed)
134
128
  continue;
135
129
  const { entry, procStart } = parsed;
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))
130
+ if (!keep(entry))
139
131
  continue;
140
132
  if (!isAlive(pid, procStart))
141
133
  continue;
134
+ out.push(entry);
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * Le sessioni VIVE del solo progetto corrente, indicizzate per `sessionId` —
140
+ * la stessa chiave del sidecar `session-tasks.jsonl` e di `deck-run --resume`,
141
+ * quindi il join con la riga di lista è diretto.
142
+ *
143
+ * Il filtro per `cwd` è d'EFFICIENZA, non di correttezza: risparmia lo `stat`
144
+ * di `/proc` sui processi degli altri progetti (il registry è globale, non
145
+ * per-progetto). Il match resta sul `sessionId`.
146
+ *
147
+ * Nessuna cache mtime-keyed come in `sessions.ts`: le entry sono poche e di
148
+ * poche centinaia di byte, mentre `status` cambia a ogni turno — una cache qui
149
+ * mostrerebbe `idle` su una sessione che sta lavorando, cioè il difetto che la
150
+ * colonna esiste per non avere.
151
+ */
152
+ export function discoverLiveSessions(projectRoot) {
153
+ const out = new Map();
154
+ // T175 — `isWithin` regge le grafie di Windows: il registry scrive `C:\…`,
155
+ // e la root del deck può arrivare con la lettera del drive in un altro case.
156
+ for (const entry of liveEntries(liveSessionsRoot(), (e) => isWithin(e.cwd, projectRoot))) {
142
157
  out.set(entry.sessionId, entry);
143
158
  }
144
159
  return out;
145
160
  }
161
+ /**
162
+ * Il nome CHIESTO in un argv di `claude` — il valore di `--name` o del suo alias
163
+ * `-n`, staccato o con `=` — oppure `null` se l'argv non ne chiede uno.
164
+ *
165
+ * Non è il campo `name` del registry, ed è la ragione per cui serve: il registry
166
+ * porta il nome EFFETTIVO, e quando il nome chiesto è già preso il CLI rinomina
167
+ * la sessione (`🧵 loom-works` → `🧵 loom-works-cryptic-hopper`, con
168
+ * `nameSource: "collision"`), mentre il titolo della tab resta il nome chiesto.
169
+ *
170
+ * GEMELLA di `procRequestedName` in `loom-compass/compass@lamemind/model.js`:
171
+ * le due letture devono riconoscere le stesse quattro forme.
172
+ */
173
+ export function requestedNameIn(argv) {
174
+ for (let i = 0; i < argv.length; i++) {
175
+ const a = argv[i];
176
+ if (a === '--name' || a === '-n')
177
+ return argv[i + 1] ?? null;
178
+ if (a.startsWith('--name='))
179
+ return a.slice('--name='.length);
180
+ if (a.startsWith('-n='))
181
+ return a.slice('-n='.length);
182
+ }
183
+ return null;
184
+ }
185
+ /** Gli argomenti del processo: `/proc/<pid>/cmdline` li separa con NUL e ne
186
+ * mette uno anche in coda. Ogni argomento arriva intero, spazi compresi. */
187
+ function procArgv(pid) {
188
+ let raw;
189
+ try {
190
+ raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
191
+ }
192
+ catch {
193
+ return null; // processo finito fra la liveness e la lettura
194
+ }
195
+ const argv = raw.split('\0');
196
+ if (argv.length && argv[argv.length - 1] === '')
197
+ argv.pop();
198
+ return argv;
199
+ }
200
+ /**
201
+ * T182 — i nomi chiesti con `--name` dalle conversazioni vive di TUTTI i
202
+ * progetti: l'ingresso di `twinTitle` (`src/twin-title.ts`).
203
+ *
204
+ * Nessun filtro per `cwd`, al contrario di `discoverLiveSessions`: chi cerca la
205
+ * tab per titolo la cerca in tutte le finestre Ptyxis, quindi una conversazione
206
+ * dello stesso progetto aperta da un worktree di lane (altro `cwd`, stesso
207
+ * titolo) è un gemello a tutti gli effetti. Il titolo porta già la label del
208
+ * progetto, e il confronto per uguaglianza intera non scambia un progetto con
209
+ * un altro.
210
+ *
211
+ * Una viva senza `--name` non entra: il suo titolo di tab non è un nome
212
+ * chiesto. Dove `/proc` non esiste nessuna entry risulta viva, e la lista
213
+ * esce vuota: il lancio usa il nome base.
214
+ */
215
+ export function liveRequestedNames(root = liveSessionsRoot()) {
216
+ const names = [];
217
+ for (const entry of liveEntries(root)) {
218
+ const argv = procArgv(entry.pid);
219
+ const name = argv ? requestedNameIn(argv) : null;
220
+ if (name !== null)
221
+ names.push(name);
222
+ }
223
+ return names;
224
+ }
146
225
  /** Signature del poll: cambia quando cambia l'insieme delle vive O lo stato di
147
226
  * una di esse. Il `pid` ci sta dentro perché un `/clear` sposta lo stesso
148
227
  * `sessionId` su un processo diverso senza toccare né insieme né stato. */
@@ -15,6 +15,7 @@
15
15
  import { saneNote } from './sane-note.js';
16
16
  import { MODEL_DEFAULT, MODELS } from './spawn-catalog.js';
17
17
  import { shellQuote } from './spawn.js';
18
+ import { twinTitle } from './twin-title.js';
18
19
  export const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan'];
19
20
  /** L'alfabeto della chiave di stato: il valore entra in `bash -lc "…"` e il
20
21
  * file di progetto è editabile a mano, quindi si scarta invece di quotare. */
@@ -173,6 +174,12 @@ export function posixQuote(text) {
173
174
  /**
174
175
  * La composizione intera.
175
176
  *
177
+ * Il titolo è `tabTitle` più, solo se una conversazione viva ha già chiesto lo
178
+ * stesso nome, il carattere del gemello (`twinTitle`, T182). Il carattere entra
179
+ * nel titolo intero — label, task, nota e fork compresi — perché due
180
+ * conversazioni della stessa task con note diverse hanno già titoli distinti e
181
+ * non sono gemelle. Lo stesso `title` alimenta `-T` di Ptyxis e `--name`.
182
+ *
176
183
  * Ordine degli argomenti di `claude`: nome, permission mode e modello prima dei
177
184
  * flag di continuità, il prompt sempre ultimo. Il modello sta accanto al
178
185
  * permission mode perché sono la stessa cosa — la scelta di chi apre la
@@ -186,7 +193,7 @@ export function posixQuote(text) {
186
193
  */
187
194
  export function composeTab(req, ctx) {
188
195
  const warnings = [];
189
- const title = tabTitle(req, projectLabel(ctx.config));
196
+ const title = twinTitle(tabTitle(req, projectLabel(ctx.config)), ctx.liveRequestedNames ?? []);
190
197
  const mode = permissionMode(ctx, warnings);
191
198
  const model = modelOf(req, ctx, warnings);
192
199
  const key = stateKey(ctx, warnings);
@@ -21,6 +21,7 @@ import { isUsageError, parseTabArgs } from './tab-args.js';
21
21
  import { composeTab, ptyxisArgv } from './tab-compose.js';
22
22
  import { loadPromptCatalog, PROMPT_CATALOG } from './prompt-catalog.js';
23
23
  import { configFilePath } from './config.js';
24
+ import { liveRequestedNames, liveSessionsRoot } from './live-sessions.js';
24
25
  import { resolveGitBash } from './git-bash.js';
25
26
  import { semicolonArg, TAB_ARGV_ENV, windowsTabEnv, WT_CMD, wtNewTabArgs, wtProfile, } from './windows-terminal.js';
26
27
  /** Il wrapper che gira dentro una tab di Windows Terminal, nel build: lo esegue
@@ -96,6 +97,9 @@ export async function runDeckRun(argv, io) {
96
97
  catalog: loadPromptCatalog(catalogPath),
97
98
  catalogPath,
98
99
  platform: io.platform,
100
+ // Su Windows niente `/proc`, quindi nessuna liveness (T177): il titolo
101
+ // resta quello base, cioè il comportamento di prima del gemello.
102
+ liveRequestedNames: io.platform === 'win32' ? [] : liveRequestedNames(liveSessionsRoot(io.env)),
99
103
  });
100
104
  for (const w of plan.warnings)
101
105
  io.stderr(`${w}\n`);
@@ -0,0 +1,63 @@
1
+ // T182 — il titolo della conversazione GEMELLA: un carattere in coda al
2
+ // `--name` quando una conversazione viva ha già chiesto lo stesso nome.
3
+ //
4
+ // Chi porta davanti la tab di una conversazione la cerca per titolo (coda via
5
+ // AT-SPI, `sessionsByName` di compass), e il titolo della tab è il `--name`
6
+ // passato a `claude`. Due conversazioni vive con lo stesso nome — tipico: il
7
+ // preflight e il run della stessa task — hanno due tab con lo stesso titolo, e
8
+ // chi cerca non può scegliere. Il lanciatore rende unico il secondo titolo; il
9
+ // primo lancio resta senza carattere, quindi nel caso normale il titolo è quello
10
+ // di sempre.
11
+ //
12
+ // Modulo PURO: i nomi chiesti dalle conversazioni vive arrivano già letti
13
+ // (`liveRequestedNames` in `live-sessions.ts`).
14
+ /**
15
+ * I caratteri del gemello, nell'ordine in cui si assegnano: il primo gemello
16
+ * prende `#`.
17
+ *
18
+ * GEMELLA di `TWIN_MARKS` in `loom-compass/compass@lamemind/model.js`, il
19
+ * secondo lanciatore di conversazioni: nessun codice confronta le due copie, e
20
+ * cambiarne una sola fa scegliere caratteri diversi ai due lanciatori senza
21
+ * errore. Si toccano insieme.
22
+ *
23
+ * Ogni carattere sta fuori da cinque classi, e ognuna rompe un consumer in
24
+ * silenzio:
25
+ * - fuori dall'ASCII a una cella: un simbolo di larghezza ambigua (`§`, `†`)
26
+ * occupa una o due celle a seconda della locale e sposta il taglio del
27
+ * titolo nella tab;
28
+ * - dentro l'alfabeto di `saneNote`: un carattere ammesso lì può arrivare da
29
+ * una nota digitata, e il gemello non si distingue più dal testo;
30
+ * - dentro `[A-Za-z0-9_-]`: il matcher di compass `titleKeyRe` riconosce una
31
+ * finestra con `^<emoji> <name>(?![\w-])`;
32
+ * - l'apice singolo: il titolo sta dentro `claude --name '…'` nella riga che
33
+ * `bash -lc` analizza;
34
+ * - i metacaratteri di shell (`; & $ \` | < >`): inerti fra apici, ma il test
35
+ * di `saneNote` asserisce che nessun `[;&`$]` sopravviva in un titolo, e un
36
+ * gemello della stessa classe renderebbe cieco quel test. Fuori anche la
37
+ * punteggiatura che si legge come testo (`. , : /`) e le parentesi.
38
+ */
39
+ export const TWIN_MARKS = ['#', '+', '=', '~', '^', '%', '@', '*', '?', '!'];
40
+ /**
41
+ * Il titolo da usare: `base` se nessuna conversazione viva l'ha chiesto,
42
+ * altrimenti `base` + spazio + il primo carattere di `TWIN_MARKS` che nessuna
43
+ * conversazione viva porta già su quel nome.
44
+ *
45
+ * Lo spazio separa il carattere come ogni altro suffisso del titolo (` · T182`,
46
+ * ` <nota>`, ` · fork`): incollato all'ultima parola si leggerebbe come un
47
+ * refuso della nota. Il confronto è per uguaglianza intera, la stessa che fanno
48
+ * coda e `sessionsByName`.
49
+ *
50
+ * Insieme esaurito → `base`: il lancio parte comunque, e chi cerca la tab per
51
+ * titolo torna all'esito ambiguo che ha anche senza lanciatore.
52
+ */
53
+ export function twinTitle(base, liveRequestedNames) {
54
+ const taken = new Set(liveRequestedNames);
55
+ if (!taken.has(base))
56
+ return base;
57
+ for (const mark of TWIN_MARKS) {
58
+ const candidate = `${base} ${mark}`;
59
+ if (!taken.has(candidate))
60
+ return candidate;
61
+ }
62
+ return base;
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.78.0",
3
+ "version": "0.79.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": {