@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 +4 -1
- package/app-door.mjs +23 -3
- package/package.json +1 -1
- package/session-index-owner.mjs +20 -8
- package/session-index-watches.mjs +28 -10
- package/terminal-mail.mjs +17 -0
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
|
|
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
|
-
|
|
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.
|
|
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",
|
package/session-index-owner.mjs
CHANGED
|
@@ -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
|
-
&& !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|