@volter/supercode-teams 0.3.187 → 0.3.188
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/machine.mjs +78 -9
- package/mail/send.mjs +2 -2
- package/package.json +1 -1
- package/session-index-owner.mjs +8 -1
package/machine.mjs
CHANGED
|
@@ -84,7 +84,7 @@ import { WorkspaceContexts, fromContext } from '@volter/teams/context';
|
|
|
84
84
|
import { TeamsClient } from '@volter/teams/http-client';
|
|
85
85
|
import { supercodeHome } from '@volter/teams/home';
|
|
86
86
|
import { readMailHistory, machineName } from './mail-history.mjs';
|
|
87
|
-
import { randomBytes, createHash } from 'node:crypto';
|
|
87
|
+
import { randomBytes, createHash, randomUUID } from 'node:crypto';
|
|
88
88
|
import { WebSocket } from 'ws';
|
|
89
89
|
import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
|
|
90
90
|
// The ConPTY host is read from the namespace, where a terminal package too old to have it gives undefined rather
|
|
@@ -137,6 +137,18 @@ export function claudeUsageLimitWait(screen) {
|
|
|
137
137
|
return /usage limit to reset/i.test(status) && /rate-limit-options/i.test(status) && !/esc to interrupt/i.test(status);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** Claude's /btw panel, from its native dialog receipt and live panel footer.
|
|
141
|
+
* Permission prompts, settings panels and an unknown layout do not qualify. */
|
|
142
|
+
export function claudeSideQuestionPanel(screen, activity) {
|
|
143
|
+
if (activity?.harness !== 'claude-code' || activity.evidence?.source !== 'claude_registry'
|
|
144
|
+
|| activity.evidence?.waiting_for !== 'dialog open') return false;
|
|
145
|
+
const rows = String(screen).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').split('\n').map(line => line.trim()).filter(Boolean);
|
|
146
|
+
const footer = rows.slice(-3).join('\n');
|
|
147
|
+
return /^\/btw(?:\s|$)/mu.test(rows.slice(-40).join('\n'))
|
|
148
|
+
&& /\b(?:esc|escape)(?:\s+to)?\s+close\b/iu.test(footer)
|
|
149
|
+
&& composerHoldsDraft('claude-code', screen) === null;
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
/** The settle floor for a pane whose kind has no composer pattern: the screen
|
|
141
153
|
* must have stopped changing for this long before the pane counts as ready. */
|
|
142
154
|
const INITIAL_INPUT_SETTLE_MS = 1_000;
|
|
@@ -551,6 +563,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
551
563
|
// Set by a service-run connector: how `machine.restart` ends this daemon so its service manager starts it again.
|
|
552
564
|
this.restartHandler = null;
|
|
553
565
|
this.launches = new Launches(this.p.launches);
|
|
566
|
+
this.boardOwnersPath = join(this.home, 'board-owners.json');
|
|
554
567
|
// The release this daemon runs, in its machine record (`supercode teams machine status`): its Teams package and its
|
|
555
568
|
// native. A daemon an upgrade did not restart runs the release it started with, whatever is installed, and a
|
|
556
569
|
// client a release behind or ahead of it is refused, so status says which it is (2026-10-06: version skew between
|
|
@@ -717,6 +730,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
717
730
|
this.mailStore = new SqliteMailStore({ mailRoot: join(supercodeHome(this.env), 'mail'), machine: machineName(this.env), committed: this.env.SUPERCODE_MAIL_COMMITTED === '1', startupRecovery: this.env.SUPERCODE_MAIL_STARTUP_RECOVERY === '1', log: (line) => this.emit('records-log', line) });
|
|
718
731
|
this.mailStore.acquire();
|
|
719
732
|
await this.mailStore.start();
|
|
733
|
+
await this.#restoreBoardOwners();
|
|
720
734
|
this.sessionIndex = new SessionIndexOwner({ database: this.mailStore.database, env: this.env, log: (line) => this.emit('records-log', line) });
|
|
721
735
|
this.indexViews = new SessionIndexViews({ owner: this.sessionIndex,
|
|
722
736
|
enrichLive: (query, rows) => projectLiveDescriptors(query, rows, { owner: this.sessionIndex, live: this.live, mail: this.mail }),
|
|
@@ -895,6 +909,30 @@ export class TeamsMachine extends MachineCore {
|
|
|
895
909
|
const reading = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
|
|
896
910
|
const native = reading?.activities?.find(row => row.pid === session.rootPid)?.activity;
|
|
897
911
|
if (native?.harness !== expected.harness || native.session_id !== expected.session) return { delivered: false, reason: 'the pane no longer runs the addressed native session; no input submitted' };
|
|
912
|
+
// A lifecycle wake belongs to the board's owned session, not the /btw
|
|
913
|
+
// composer. Dismiss only that identified panel; never answer a question,
|
|
914
|
+
// approve a permission, clear a draft or interrupt the underlying turn.
|
|
915
|
+
const boardWake = messageIds?.length && messageIds.every(id => {
|
|
916
|
+
const stored = this.mailStore.findIn(address, id);
|
|
917
|
+
const envelope = stored ? this.mailStore.body(stored) : null;
|
|
918
|
+
return envelope?.kind === 'notice' && parseAddress(envelope.from)?.harness === 'board';
|
|
919
|
+
});
|
|
920
|
+
if (boardWake && claudeSideQuestionPanel(await this.host.capture(session.id, { lines: 200, preserveEscapes: true }), native)) {
|
|
921
|
+
const owner = randomUUID();
|
|
922
|
+
if (await this.host.acquireInput?.(session.id, owner)) {
|
|
923
|
+
try {
|
|
924
|
+
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
925
|
+
const latest = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
|
|
926
|
+
const current = latest?.activities?.find(row => row.pid === session.rootPid)?.activity;
|
|
927
|
+
const screen = await this.host.capture(session.id, { lines: 200, preserveEscapes: true });
|
|
928
|
+
if (!this.stopping && current?.session_id === expected.session && claudeSideQuestionPanel(screen, current)) {
|
|
929
|
+
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', inputOwner: owner, deadlineMs: Date.now() + 5_000 });
|
|
930
|
+
this.log.append({ principal: null, cap: 'control', target: `pane:${pane}`, outcome: 'board-side-question-dismissed', detail: JSON.stringify({ address, message_ids: messageIds }) });
|
|
931
|
+
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
932
|
+
}
|
|
933
|
+
} finally { await this.host.releaseInput?.(session.id, owner); }
|
|
934
|
+
}
|
|
935
|
+
}
|
|
898
936
|
const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
|
|
899
937
|
if (result.refused) throw new Error(result.refused.reason);
|
|
900
938
|
return result.waits ?? result;
|
|
@@ -1028,6 +1066,15 @@ export class TeamsMachine extends MachineCore {
|
|
|
1028
1066
|
try { activities = (await this.#serveRequest('harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }, 30_000))?.activities ?? []; }
|
|
1029
1067
|
catch (error) { sessions_unread = String(error?.message ?? error).slice(0, 200); }
|
|
1030
1068
|
}
|
|
1069
|
+
const launches = new Map(this.launches.all().map(launch => [launch.pane, launch]));
|
|
1070
|
+
for (const pane of live) {
|
|
1071
|
+
const launch = launches.get(pane.pane), activity = activities.find(row => row.pid === pane.pid)?.activity;
|
|
1072
|
+
// The daemon resolved the starter at launch; the native receipt and
|
|
1073
|
+
// current process must name the same child. A pane title or argv is
|
|
1074
|
+
// never a delegation receipt.
|
|
1075
|
+
if (launch?.status === 'ready' && launch.native_session && launch.native_session === activity?.session_id && launch.kind === activity.harness)
|
|
1076
|
+
pane.starter_session = launch.by ?? null;
|
|
1077
|
+
}
|
|
1031
1078
|
this.live?.panes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid,
|
|
1032
1079
|
activity: sessions_unread ? null : activities.find(row => row.pid === pane.pid)?.activity ?? null })), { current: sessions_unread == null });
|
|
1033
1080
|
// the sessions under the panes unread: the panes are read again on the section's backoff until they are (MR4)
|
|
@@ -2075,7 +2122,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
2075
2122
|
/** The home and board a board name (an address's last part) names among the homes registered with this daemon, by the
|
|
2076
2123
|
* one rule the board names itself with (@volter/teams/board-name). */
|
|
2077
2124
|
#boardKept(name) {
|
|
2078
|
-
for (const root of Object.keys(this.boardEvents?.state?.boards ?? {})) {
|
|
2125
|
+
for (const root of new Set([...Object.keys(this.boardEvents?.state?.boards ?? {}), ...(this.boardOwners?.keys() ?? [])])) {
|
|
2079
2126
|
if (boardName(boardLabel(root, 'default')) === name) return { root, board: 'default' };
|
|
2080
2127
|
let named = [];
|
|
2081
2128
|
try { named = readdirSync(join(root, 'kanban', 'boards')); } catch { /* no named boards */ }
|
|
@@ -2577,6 +2624,23 @@ export class TeamsMachine extends MachineCore {
|
|
|
2577
2624
|
return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
|
|
2578
2625
|
}
|
|
2579
2626
|
|
|
2627
|
+
/** A restart retains the homes a verified owner registered. A saved PID is
|
|
2628
|
+
* only a candidate: the kernel must still show it holding that home's lock.
|
|
2629
|
+
* Caller admission separately proves the live connection's process. */
|
|
2630
|
+
async #restoreBoardOwners() {
|
|
2631
|
+
this.boardOwners = new Map();
|
|
2632
|
+
const saved = readJson(this.boardOwnersPath, { owners: {} });
|
|
2633
|
+
for (const [root, pid] of Object.entries(saved?.owners ?? {})) {
|
|
2634
|
+
if (isAbsolute(root) && Number.isSafeInteger(pid) && pid > 1 && await this.#holdsBoardLock(root, pid))
|
|
2635
|
+
this.boardOwners.set(root, pid);
|
|
2636
|
+
}
|
|
2637
|
+
this.#saveBoardOwners();
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
#saveBoardOwners() {
|
|
2641
|
+
writeJson(this.boardOwnersPath, { version: 1, owners: Object.fromEntries(this.boardOwners ?? []) });
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2580
2644
|
/** A filing's sender as this machine knows it, with no process and no request (mailbox.rs `sender_identity`'s rule): this filing door is local, so a sender from this machine's address is a session here
|
|
2581
2645
|
* (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
|
|
2582
2646
|
* reaches this one only through its link, so a return address is never an identity. */
|
|
@@ -2590,7 +2654,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
2590
2654
|
async #runFiling(request) {
|
|
2591
2655
|
if (!this.mailSend) return { code: 1, text: 'this machine\'s daemon has not started its mail yet' };
|
|
2592
2656
|
return this.mailSend.file({ address: request.from, name: request.from_name ?? '' }, request.to, request.body ?? '', {
|
|
2593
|
-
subject: request.subject ?? null, key: request.id ?? null, wake: request.wake !== false,
|
|
2657
|
+
subject: request.subject ?? null, key: request.id ?? null, wake: request.wake !== false, notice: request.notice === true,
|
|
2594
2658
|
sender_identity: request.sender_identity && typeof request.sender_identity === 'object' ? request.sender_identity : null, typed: request.typed === true,
|
|
2595
2659
|
});
|
|
2596
2660
|
}
|
|
@@ -4501,7 +4565,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
4501
4565
|
for (const key of ['from', 'to', 'body']) if (typeof params[key] !== 'string' || !params[key]) throw Object.assign(new Error(`a filing names its ${key}`), { code: 400 });
|
|
4502
4566
|
const sender_identity = await this.#observeSender(params.from, params.from_name ?? null, typeof params.context === 'string' && params.context ? params.context : null);
|
|
4503
4567
|
const answer = await this.#runFiling({ from: params.from, from_name: params.from_name ?? '', to: params.to, body: params.body, id: params.id ?? null,
|
|
4504
|
-
wake: params.wake !== false, subject: params.subject ?? null, sender_identity });
|
|
4568
|
+
wake: params.wake !== false, subject: params.subject ?? null, notice: params.notice === true, sender_identity });
|
|
4505
4569
|
act('mail.file', params.to, answer?.code === 0 ? 'ok' : 'failed');
|
|
4506
4570
|
return answer;
|
|
4507
4571
|
}
|
|
@@ -4814,21 +4878,26 @@ export class TeamsMachine extends MachineCore {
|
|
|
4814
4878
|
case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
|
|
4815
4879
|
// A board home this machine keeps, published to its team by its connector (board-events.mjs).
|
|
4816
4880
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
4817
|
-
if (
|
|
4818
|
-
if (method === 'harness.v1.boards.list') return this.boardEvents.list();
|
|
4881
|
+
if (method === 'harness.v1.boards.list') return this.boardEvents?.list() ?? [];
|
|
4819
4882
|
if (method === 'harness.v1.boards.unregister') {
|
|
4820
4883
|
if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
|
|
4821
|
-
|
|
4884
|
+
this.boardOwners?.delete(resolve(params.root));
|
|
4885
|
+
try { this.boardOwners?.delete(realpathSync(params.root)); } catch { /* already removed */ }
|
|
4886
|
+
this.#saveBoardOwners();
|
|
4887
|
+
return this.boardEvents?.unregister(params.root) ?? { root: resolve(params.root), published: false };
|
|
4822
4888
|
}
|
|
4823
4889
|
if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
|
|
4824
|
-
const registered = this.boardEvents
|
|
4890
|
+
const registered = this.boardEvents?.register(params.root) ?? { root: resolve(params.root), published: false };
|
|
4825
4891
|
// The home's board owner acts as its boards: the caller it names for a board of that home is taken from it
|
|
4826
4892
|
// (#callerOf). The owner is the process holding the home's board lock (board.lock, through its lock holder), as
|
|
4827
4893
|
// the kernel says: the registering client names its pid (its claim), and only a pid that holds the lock is
|
|
4828
4894
|
// taken. A registration from any other process publishes the home and grants nothing, and never replaces the
|
|
4829
4895
|
// owner's.
|
|
4830
4896
|
const pid = entry.principal === 'local' && !entry.appDoor ? Number(params.claim?.pid) : NaN;
|
|
4831
|
-
if (Number.isInteger(pid) && await
|
|
4897
|
+
if (Number.isInteger(pid) && (!entry.socket || await peerIs(entry.socket, pid)) && await this.#holdsBoardLock(registered.root, pid)) {
|
|
4898
|
+
(this.boardOwners ??= new Map()).set(registered.root, pid);
|
|
4899
|
+
this.#saveBoardOwners();
|
|
4900
|
+
}
|
|
4832
4901
|
act('boards.register', registered.root);
|
|
4833
4902
|
return registered;
|
|
4834
4903
|
}
|
package/mail/send.mjs
CHANGED
|
@@ -732,13 +732,13 @@ export class MailSend {
|
|
|
732
732
|
* `sender_identity` is the filer's observation (this daemon's), never the request's own. Answers `{ code, message_id,
|
|
733
733
|
* new, to }`, or `{ code, text }`.
|
|
734
734
|
*/
|
|
735
|
-
file(from, to, body, { subject = null, key = null, wake = true, sender_identity = null, typed = false } = {}) {
|
|
735
|
+
file(from, to, body, { subject = null, key = null, wake = true, sender_identity = null, typed = false, notice = false } = {}) {
|
|
736
736
|
const sender = parseAddress(from.address), receiver = parseAddress(to);
|
|
737
737
|
if (!sender) return { code: EXIT_REFUSED, text: `\`${from.address}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>` };
|
|
738
738
|
if (!receiver) return { code: EXIT_REFUSED, text: `\`${to}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>` };
|
|
739
739
|
let id = messageIdFor(sender.address, key);
|
|
740
740
|
if (typed) id = `i-${id.replace(/^m-/, '')}`;
|
|
741
|
-
const filed = makeEnvelope({ id, from: sender.address, from_name: from.name ?? '', sender_identity, kind: typed ? 'user' : 'peer', reply_via: { mode: typed ? 'none' : 'command' }, subject: subjectLine(subject), body });
|
|
741
|
+
const filed = makeEnvelope({ id, from: sender.address, from_name: from.name ?? '', sender_identity, kind: typed ? 'user' : notice ? 'notice' : 'peer', reply_via: { mode: typed || notice ? 'none' : 'command' }, subject: subjectLine(subject), body });
|
|
742
742
|
try {
|
|
743
743
|
if (this.store.findIn(receiver.address, id)) return { code: 0, message_id: id, new: false, to: receiver.address };
|
|
744
744
|
// a typed line's record is read already: it reached the session as its composer turn, never as mail
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.188",
|
|
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
|
@@ -273,7 +273,8 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
273
273
|
// Lifecycle parents cover replacement of the selected root, not every
|
|
274
274
|
// sibling below that parent. Only selected physical directory bindings
|
|
275
275
|
// extend file-source admission beyond its logical aliases.
|
|
276
|
-
const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname)
|
|
276
|
+
const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname),
|
|
277
|
+
...(source.registryPath ? [dirname(source.registryPath)] : [])]);
|
|
277
278
|
const selectedTargets = source.kind === 'stores' ? targets : targets.filter((target) => !parents.has(target));
|
|
278
279
|
if (path != null && !pathUnder(source.root, path) && !pathUnder(path, source.root)
|
|
279
280
|
&& ![...selectedTargets, ...source.aliases].some((target) => pathUnder(path, target) || pathUnder(target, path))
|
|
@@ -349,6 +350,12 @@ export class SessionIndexOwner extends EventEmitter {
|
|
|
349
350
|
});
|
|
350
351
|
if (this.closed) return;
|
|
351
352
|
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);
|
|
353
|
+
// A previous watcher may have admitted a sibling of an aliased registry.
|
|
354
|
+
// Only a completed full listing proves which transcript paths belong here;
|
|
355
|
+
// malformed files still selected retain their failure and retry evidence.
|
|
356
|
+
if (folder === source.root) for (const failure of this.store.failures([source.id])) {
|
|
357
|
+
if (failure.kind === 'file' && !listing.parts.has(failure.path)) this.store.clearFailure(source.id, failure.path, failure.kind);
|
|
358
|
+
}
|
|
352
359
|
for (const path of listing.parts) { if (this.closed) return; await this.#file(source, path); }
|
|
353
360
|
if (this.closed) return;
|
|
354
361
|
this.store.clearFailure(source.id, folder, 'folder');
|