@volter/supercode-teams 0.3.183 → 0.3.185

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
@@ -225,7 +225,10 @@ required for archived Codex metadata.
225
225
 
226
226
  Sources are keyed by harness, physical root and profile/registry selection.
227
227
  Watches precede listings, include root parents and absent-root ancestors, and
228
- share physical directories. Hourly reconciliation repairs missed events.
228
+ share physical coverage. On macOS/Windows recursive streams cover session trees
229
+ and symlink-target parents; Linux retains directory watches over inotify.
230
+ Hourly reconciliation repairs missed events. [The scale decision](../../docs/adr/0022-session-tree-watches.md)
231
+ names the native-watch failure paths and measured platform scope.
229
232
  Metadata changes and verified file offsets commit together with cursor-addressed
230
233
  row changes; unchanged warm files contribute no transcript reads. A refused
231
234
  source retains its prior rows and reports the path, error and retry deadline.
package/app-door.mjs CHANGED
@@ -214,12 +214,32 @@ export function serveAppDoor({ home, open, log = () => {} }) {
214
214
  start();
215
215
  };
216
216
 
217
- let watcher = null;
218
- try { watcher = watch(home, (_, name) => { if (name === 'app-door.json') reload(); }); } catch { /* a home that cannot be watched is read at start */ }
217
+ let watcher = null, watchRetry = null, closed = false;
218
+ const watchLost = (error) => {
219
+ if (closed) return;
220
+ watcher?.close(); watcher = null;
221
+ log({ outcome: 'failed', origin: null, detail: `app door settings watch: ${error.message}` });
222
+ clearTimeout(watchRetry);
223
+ watchRetry = setTimeout(watchSettings, LISTEN_RETRY_MS);
224
+ watchRetry.unref?.();
225
+ };
226
+ const watchSettings = () => {
227
+ if (closed) return;
228
+ watchRetry = null;
229
+ // Re-read even when watch installation fails, so revocation and turning
230
+ // the door off still take effect while native watching is unavailable.
231
+ reload();
232
+ try {
233
+ watcher = watch(home, (_, name) => { if (name == null || name === 'app-door.json') reload(); });
234
+ watcher.on('error', watchLost);
235
+ reload(); // cover a settings write between the first read and watch
236
+ } catch (error) { watchLost(error); }
237
+ };
238
+ watchSettings();
219
239
  start();
220
240
  if (!setting.enabled) report();
221
241
  return {
222
242
  state: () => ({ enabled: setting.enabled, port: setting.port, origins: setting.origins, listening, failure }),
223
- close() { watcher?.close(); stop(); wss.close(); },
243
+ close() { closed = true; clearTimeout(watchRetry); watcher?.close(); stop(); wss.close(); },
224
244
  };
225
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.183",
3
+ "version": "0.3.185",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
@@ -270,8 +270,13 @@ export class SessionIndexOwner extends EventEmitter {
270
270
  if (!source) return;
271
271
  const path = name == null ? null : join(directory, name);
272
272
  if (path?.endsWith('-shm')) return;
273
+ // Lifecycle parents cover replacement of the selected root, not every
274
+ // sibling below that parent. Only selected physical directory bindings
275
+ // extend file-source admission beyond its logical aliases.
276
+ const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname)]);
277
+ const selectedTargets = source.kind === 'stores' ? targets : targets.filter((target) => !parents.has(target));
273
278
  if (path != null && !pathUnder(source.root, path) && !pathUnder(path, source.root)
274
- && !(source.kind === 'stores' ? targets : [...source.aliases]).some((target) => pathUnder(path, target) || pathUnder(target, path))
279
+ && ![...selectedTargets, ...source.aliases].some((target) => pathUnder(path, target) || pathUnder(target, path))
275
280
  && !(source.registryPath && (pathUnder(source.registryPath, path) || pathUnder(path, source.registryPath)))
276
281
  && ![source.namesPath, source.historyPath].includes(path)) return;
277
282
  if (path == null || pathUnder(path, source.root) || path === source.registryPath
@@ -306,8 +311,10 @@ export class SessionIndexOwner extends EventEmitter {
306
311
  finally { this.running = null; if (!this.closed && this.pending.size) this.#schedule(this.pending.keys().next().value, null); }
307
312
  }
308
313
 
309
- async #watch(source, path) {
310
- const directory = await this.watches.want(source.id, path);
314
+ async #watch(source, path, tree = false) {
315
+ const directory = await this.watches.want(source.id, path, {
316
+ tree, physicalParent: tree && !pathUnder(source.root, path),
317
+ });
311
318
  source.wanted.add(path);
312
319
  if (!this.closed) {
313
320
  this.store.clearFailure(source.id, path, 'watch');
@@ -320,7 +327,7 @@ export class SessionIndexOwner extends EventEmitter {
320
327
  this.metrics.reconciliations = (this.metrics.reconciliations ?? 0) + 1;
321
328
  for (const root of new Set([source.root, ...source.aliases])) {
322
329
  await this.#watch(source, dirname(root));
323
- await this.#watch(source, root);
330
+ await this.#watch(source, root, source.kind === 'files');
324
331
  }
325
332
  for (const failure of this.store.failures([source.id])) if (failure.kind === 'registry-selection') {
326
333
  try {
@@ -338,14 +345,14 @@ export class SessionIndexOwner extends EventEmitter {
338
345
  }
339
346
  if (source.harness === 'codex') await this.#codexLedgers(source);
340
347
  const listing = await listSessionParts(source.harness, source.root, {
341
- folder, metrics: this.metrics, beforeDirectory: (path) => this.#watch(source, path),
348
+ folder, metrics: this.metrics, beforeDirectory: (path) => this.#watch(source, path, true),
342
349
  });
343
350
  if (this.closed) return;
344
- for (const part of source.parts.values()) if (part.kind === 'file' && pathUnder(folder, part.path) && !listing.parts.has(part.path)) this.#remove(source, part.path);
351
+ for (const part of source.parts.values()) if (part.kind === 'file' && (folder === source.root || pathUnder(folder, part.path)) && !listing.parts.has(part.path)) this.#remove(source, part.path);
345
352
  for (const path of listing.parts) { if (this.closed) return; await this.#file(source, path); }
346
353
  if (this.closed) return;
347
354
  this.store.clearFailure(source.id, folder, 'folder');
348
- for (const directory of source.directories) if (pathUnder(folder, directory)) source.directories.delete(directory);
355
+ for (const directory of source.directories) if (folder === source.root || pathUnder(folder, directory)) source.directories.delete(directory);
349
356
  for (const directory of listing.directories) source.directories.add(directory);
350
357
  const wanted = new Set([...source.aliases, ...[...source.aliases].map(dirname), source.root, dirname(source.root), ...source.directories,
351
358
  ...(source.registryPath ? [source.registryPath, dirname(source.registryPath)] : []),
@@ -539,7 +546,12 @@ export class SessionIndexOwner extends EventEmitter {
539
546
  const part = await sessionPartFor(source.harness, source.root, path);
540
547
  if (part != null) return this.#file(source, part);
541
548
  if (path === source.root || !pathUnder(source.root, path)) return this.#reconcile(source);
542
- if ((await sourceStat(path))?.isDirectory() || source.directories.has(path)) return this.#reconcile(source, path);
549
+ // Directory topology belongs to the whole selection: canonical transcript
550
+ // paths can sit outside a logical symlink's subtree. A removed alias no
551
+ // longer has stat metadata and is absent from the physical directory set,
552
+ // but must still retire those rows and their watch bindings.
553
+ const info = await sourceStat(path);
554
+ if (!info || info.isDirectory() || source.directories.has(path)) return this.#reconcile(source);
543
555
  }
544
556
 
545
557
  #retryTimer() {
@@ -1,8 +1,11 @@
1
1
  import { watch } from 'node:fs';
2
- import { dirname } from 'node:path';
3
- import { normalizedSourcePath, sourceStat } from '@volter/supercode-interchange';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { normalizedSourcePath, pathUnder, sourceStat } from '@volter/supercode-interchange';
4
4
 
5
- /** One directory watch per physical directory, shared by all selected homes.
5
+ const NATIVE_RECURSIVE = ['darwin', 'win32'].includes(process.platform);
6
+
7
+ /** Native recursive trees on macOS/Windows, shared by physical coverage.
8
+ * Linux retains directory watches over its shared inotify descriptor.
6
9
  * Missing paths keep an ancestor watch. Reconciliation installs closer
7
10
  * watches before listing newly created directories. */
8
11
  export class SessionIndexWatches {
@@ -11,7 +14,7 @@ export class SessionIndexWatches {
11
14
  this.directories = new Map(); this.closed = false;
12
15
  }
13
16
 
14
- async want(source, path) {
17
+ async want(source, path, { tree = false, physicalParent = false } = {}) {
15
18
  if (this.closed) return;
16
19
  let at = path;
17
20
  for (;;) {
@@ -21,27 +24,42 @@ export class SessionIndexWatches {
21
24
  if (parent === at) throw new Error(`no existing directory can watch ${path}`);
22
25
  at = parent;
23
26
  }
24
- const directory = await normalizedSourcePath(at);
27
+ const target = await normalizedSourcePath(at);
28
+ let recursive = NATIVE_RECURSIVE && tree && at === path;
29
+ let directory = recursive && physicalParent ? dirname(target) : target;
30
+ // Descendants (including a symlink's physical tree) share an existing
31
+ // native stream. Shallow lifecycle/registry watches need no new stream
32
+ // when a selected tree already covers them.
33
+ for (const [root, value] of this.directories) if (value.recursive && pathUnder(root, target)) {
34
+ directory = root; recursive = true; break;
35
+ }
25
36
  const info = await sourceStat(directory);
26
37
  if (this.closed) return;
27
38
  if (!info?.isDirectory()) throw new Error(`directory changed while binding a watch for ${path}`);
28
39
  let held = this.directories.get(directory);
29
- const renew = !held || held.dev !== info.dev || held.ino !== info.ino;
40
+ const renew = !held || held.dev !== info.dev || held.ino !== info.ino || recursive && !held.recursive;
30
41
  if (renew) {
31
42
  // A pathname can now name another filesystem object. Preserve every
32
43
  // source binding while replacing the watch attached to the old object.
33
44
  const old = held;
34
- const value = { sources: old?.sources ?? new Map(), watcher: null, dev: info.dev, ino: info.ino };
45
+ const value = { sources: old?.sources ?? new Map(), watcher: null, dev: info.dev, ino: info.ino, recursive: recursive || old?.recursive || false };
35
46
  // Register before returning to the caller's first listing. A nameless
36
47
  // event and a watcher error both request a reconciliation, never removals.
37
- value.watcher = watch(directory, { persistent: false, encoding: 'buffer' }, (event, filename) => {
48
+ value.watcher = watch(directory, { persistent: false, encoding: 'buffer', recursive: value.recursive }, (event, filename) => {
38
49
  if (this.closed || this.directories.get(directory) !== value) return;
39
50
  let name = null;
40
51
  if (filename != null) {
41
52
  try { name = new TextDecoder('utf-8', { fatal: true }).decode(filename); }
42
53
  catch { /* an undecodable path requires a whole directory read */ }
43
54
  }
44
- for (const [id, targets] of value.sources) this.changed(id, { directory, name, event, targets: [...targets] });
55
+ const changed = name == null ? null : resolve(directory, name);
56
+ // Recursive filenames name a path below the tree. Keep the owner's
57
+ // directory/basename contract, and reconcile an undecodable event.
58
+ const known = changed != null && pathUnder(directory, changed);
59
+ for (const [id, targets] of value.sources) this.changed(id, {
60
+ directory: known ? dirname(changed) : directory,
61
+ name: known ? basename(changed) : null, event, targets: [...targets],
62
+ });
45
63
  });
46
64
  value.watcher.on('error', (error) => {
47
65
  this.#refuse(directory, value, error);
@@ -74,7 +92,7 @@ export class SessionIndexWatches {
74
92
  // Parent first closes the replacement/creation gap around root's stat.
75
93
  const parent = dirname(root);
76
94
  if (parent !== root) await this.want(source, parent);
77
- await this.want(source, root);
95
+ await this.want(source, root, { tree: true });
78
96
  }
79
97
 
80
98
  /** Successful reconciliation supplies the complete wanted directory set.
package/terminal-mail.mjs CHANGED
@@ -264,6 +264,23 @@ export class TerminalMailbox {
264
264
  batch.error = 'codex-update-prompt: delivery retained; restart Codex with check_for_update_on_startup=false';
265
265
  this.save(); this.log({ delivered: false, queued: true, reason: batch.error, message_ids: batch.message_ids }); return;
266
266
  }
267
+ if (batch.kind === 'codex' && batch.phase === 'pasted' && batch.enters >= 3 && Date.now() - batch.last_enter >= 5000) {
268
+ // Every Enter is spent and the transcript, read above from the paste's own offset, holds no such message. A
269
+ // composer that reads empty with no turn running holds neither the paste nor a turn that could still take it
270
+ // (Codex writes a message entered during a turn when that turn takes it or ends): this paste will never be
271
+ // confirmed, and left active it holds every later message behind it for good. It is refused, never pasted
272
+ // again, and the queue moves on. Read twice, then the transcript once more; a draft, a running turn or an
273
+ // unreadable screen leaves the batch waiting on its transcript.
274
+ let lost = true;
275
+ for (let check = 0; lost && check < 2; check++) {
276
+ const screen = await this.host.capture(this.session.id, { lines: 200, preserveEscapes: true });
277
+ lost = composerText(batch.kind, screen) === '' && !/\besc to (?:interrupt|cancel)\b/iu.test(screen);
278
+ if (lost && check === 0) await pause(200);
279
+ }
280
+ if (lost && !confirmed(batch)) {
281
+ await this.recordRefusal(batch, 'native transcript unconfirmed after Enter retries; composer empty'); return;
282
+ }
283
+ }
267
284
  if (batch.kind === 'claude-code' && batch.phase === 'prepared' && batch.expiresAt && Date.now() >= batch.expiresAt) {
268
285
  await this.recordRefusal(batch, 'delivery deadline elapsed; no input pasted'); return;
269
286
  }