@volter/supercode-teams 0.3.87 → 0.3.88

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/live-sessions.mjs CHANGED
@@ -453,7 +453,13 @@ export class LiveSessions extends EventEmitter {
453
453
  }
454
454
 
455
455
  /** The session a client runs in, from its claim: its parent's current session, else its environment's id through the map. */
456
- #sessionOfClaim({ pid, ppid, session }) {
456
+ #sessionOfClaim({ pid, ppid, session, runtime }) {
457
+ // a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
458
+ // the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
459
+ if (runtime === true) {
460
+ const own = this.#runtimeOfPid(Number(pid));
461
+ if (own) return own;
462
+ }
457
463
  const parent = this.claude.get(Number(ppid));
458
464
  if (parent && alive(parent.pid)) return this.#address('claude-code', parent.sessionId);
459
465
  if (typeof session === 'string' && session) {
@@ -465,14 +471,20 @@ export class LiveSessions extends EventEmitter {
465
471
  // only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
466
472
  // records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
467
473
  for (const candidate of [Number(pid), Number(ppid)]) {
468
- if (!Number.isInteger(candidate) || candidate <= 0) continue;
469
- const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === candidate && alive(runtime.pid));
470
- if (held.length === 1) return this.#address(held[0].harness, held[0].sessionId);
471
- if (held.length > 1) throw new CallerRefused(`process ${candidate} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
474
+ const found = this.#runtimeOfPid(candidate);
475
+ if (found) return found;
472
476
  }
473
477
  return null;
474
478
  }
475
479
 
480
+ /** The runtime whose live receipt alone names process `pid`, or null; several receipts naming it are no one runtime, refused. */
481
+ #runtimeOfPid(pid) {
482
+ if (!Number.isInteger(pid) || pid <= 0) return null;
483
+ const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === pid && alive(runtime.pid));
484
+ if (held.length > 1) throw new CallerRefused(`process ${pid} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
485
+ return held.length === 1 ? this.#address(held[0].harness, held[0].sessionId) : null;
486
+ }
487
+
476
488
  /**
477
489
  * Who a local client is, from its claim `{pid, ppid, session, named}` (docs/architecture/overview.md, "Who is
478
490
  * calling"): a session's address, a board's address, or null for a person. Throws CallerRefused when the claim names
package/machine.mjs CHANGED
@@ -649,7 +649,9 @@ export class TeamsMachine extends MachineCore {
649
649
  deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request) });
650
650
  // an agent's verbs (3b-2): its threads, a delegation, its record
651
651
  this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
652
- paneOf: (session) => this.mail.paneOf(session), mailRoot });
652
+ paneOf: (session) => this.mail.paneOf(session), mailRoot,
653
+ // a delegation's pane that never became its holder is closed, as `panes.close` closes one
654
+ closePane: async (pane) => { const session = await this.#paneByContext(pane); if (session) { await this.host.closeSession(session.id); this.#panesChanged(); } } });
653
655
  // the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
654
656
  // runs under another, kept from the map's changes (at most once a second)
655
657
  let naming = null;
package/mail/agents.mjs CHANGED
@@ -12,6 +12,8 @@ import { cameThroughLink, parseAddress, threadOf } from './compose.mjs';
12
12
 
13
13
  /** The pseudo-harness of an agent address. */
14
14
  export const AGENT_HARNESS = 'agent';
15
+ /** How recent a folder time is not trusted to say its folder is unchanged (a coarse clock's tick, a 1 s filesystem). */
16
+ const RACY_MS = 2000;
15
17
  /** How long a send waits for a resumed session to be reachable again. */
16
18
  export const RESUME_WAIT_MS = 45_000;
17
19
 
@@ -63,34 +65,47 @@ export class Agents {
63
65
  thread(id) { return validName(id) ? readJson(join(this.threadsDir, `${id}.json`)) : null; }
64
66
 
65
67
  /** Every thread, oldest first. */
66
- threads() {
67
- // Read once and kept while the folder is unchanged: every writer (this daemon, and mail_agent.rs's in the native
68
- // watch until step 4) renames a whole record into it, which moves the folder's time. One stat a call, not a read of
69
- // every record: a send's plan asks this on every send, and an agent's threads grow with its history.
68
+ threads() { return this.#records().list.map((thread) => structuredClone(thread)); }
69
+
70
+ /**
71
+ * The thread records, read once and kept while their folder is unchanged, with an index of them by holder and by
72
+ * channel marker. Every writer (this daemon, and mail_agent.rs's in the native watch until step 4) renames a whole
73
+ * record into the folder, which moves its time: one stat a call, never a read of every record, though a send's plan
74
+ * asks on every send and an agent's threads grow with its history. A folder time within RACY_MS of now is not trusted
75
+ * (a write in the same tick of a coarse clock leaves it unmoved): read again, and not kept, until it is older.
76
+ */
77
+ #records() {
70
78
  let changed;
71
- try { changed = statSync(this.threadsDir).mtimeMs; } catch { return []; }
72
- if (this.threadsCache?.changed === changed) return this.threadsCache.list.map((thread) => structuredClone(thread));
79
+ try { changed = statSync(this.threadsDir).mtimeMs; } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
80
+ if (this.threadsCache?.changed === changed && Date.now() - changed > RACY_MS) return this.threadsCache;
73
81
  let names = [];
74
- try { names = readdirSync(this.threadsDir); } catch { return []; }
82
+ try { names = readdirSync(this.threadsDir); } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
75
83
  const list = names.filter((name) => name.endsWith('.json')).map((name) => readJson(join(this.threadsDir, name))).filter((thread) => thread?.id)
76
84
  .sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
77
- this.threadsCache = { changed, list };
78
- return list.map((thread) => structuredClone(thread));
85
+ const byHolder = new Map(), byMarker = new Map();
86
+ for (const thread of list) {
87
+ byHolder.set(thread.holder, [...(byHolder.get(thread.holder) ?? []), thread]);
88
+ for (const [marker, id] of Object.entries(thread.markers ?? {})) if (!byMarker.has(marker)) byMarker.set(marker, { thread, id });
89
+ }
90
+ const records = { changed, list, byHolder, byMarker };
91
+ if (Date.now() - changed > RACY_MS) this.threadsCache = records;
92
+ return records;
79
93
  }
80
94
 
81
95
  /** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
82
96
  heldBy(address) {
83
- return this.threads().reverse().find((thread) => {
84
- if (thread.holder !== address || thread.parent != null) return false;
97
+ const held = [...(this.#records().byHolder.get(address) ?? [])].reverse().find((thread) => {
98
+ if (thread.parent != null) return false;
85
99
  const agent = this.load(thread.agent);
86
100
  return agent != null && agent.main_session !== address;
87
- }) ?? null;
101
+ });
102
+ return held ? structuredClone(held) : null;
88
103
  }
89
104
 
90
105
  /** The thread whose channel message has the platform id `marker`, with that message's id. */
91
106
  ofMarker(marker) {
92
- for (const thread of this.threads()) if (thread.markers?.[marker]) return { thread, id: thread.markers[marker] };
93
- return null;
107
+ const found = this.#records().byMarker.get(marker);
108
+ return found ? { thread: structuredClone(found.thread), id: found.id } : null;
94
109
  }
95
110
 
96
111
  /** The agent `address` is a session of: its main, or a thread's holder. */
@@ -163,7 +178,7 @@ export class Agents {
163
178
  }
164
179
 
165
180
  /** The threads of the agent `name`, oldest first. */
166
- threadsOf(name) { return this.threads().filter((thread) => thread.agent === name); }
181
+ threadsOf(name) { return this.#records().list.filter((thread) => thread.agent === name).map((thread) => structuredClone(thread)); }
167
182
 
168
183
  /**
169
184
  * A line that reached an agent's main session through its own input (its terminal, its DM) is a root of the agent:
package/mail/store.mjs CHANGED
@@ -124,7 +124,9 @@ export class MailStore extends EventEmitter {
124
124
  if (!envelope || typeof envelope.id !== 'string') { box.skipped.add(`${part}/${file}`); return; }
125
125
  // its body's first line is kept beside the header (never in it): a listing of threads names each root by it with no
126
126
  // body read
127
- const firstLine = typeof envelope.body === 'string' ? envelope.body.slice(0, 400).split('\n')[0] : '';
127
+ // the line whole, then capped by characters (a surrogate pair is never split), never a slice of the body first
128
+ const end = typeof envelope.body === 'string' ? envelope.body.indexOf('\n') : -1;
129
+ const firstLine = typeof envelope.body === 'string' ? Array.from(end < 0 ? envelope.body : envelope.body.slice(0, end)).slice(0, 400).join('') : '';
128
130
  box.files.set(`${part}/${file}`, { header: headerOf(envelope), state: STATES[part], path, part, firstLine });
129
131
  }
130
132
 
package/mail/verbs.mjs CHANGED
@@ -19,8 +19,8 @@ export class AgentVerbs {
19
19
  /** `machine` this machine's name; `store` the mail store; `agents` an agent's records; `live` the live-session map;
20
20
  * `supercodeBin` the native binary (`supercode open` starts a session in a pane); `paneOf(session)` the daemon pane a
21
21
  * live session runs in; `mailRoot` the mail folder. */
22
- constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot }) {
23
- Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot });
22
+ constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot, closePane = async () => {} }) {
23
+ Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot, closePane });
24
24
  }
25
25
 
26
26
  #whole(stored) { const envelope = this.store.body(stored); return envelope ? { ...stored, envelope } : null; }
@@ -126,17 +126,24 @@ export class AgentVerbs {
126
126
  child.on('close', (code) => resolve({ code, out, err }));
127
127
  });
128
128
  if (run.code !== 0) throw new Error(`the session did not start: ${run.err.trim()}`);
129
- if (known) return known;
130
129
  let pane = null;
131
130
  try { pane = JSON.parse(run.out).pane; } catch { /* not reported */ }
131
+ if (known) return known;
132
132
  if (typeof pane !== 'string') throw new Error('the new pane was not reported');
133
- const started = Date.now();
134
- while (Date.now() - started < 60_000) {
135
- const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
136
- if (session) { await ready(session.address); return session.address; }
137
- await sleep(500);
133
+ // a launch that fails once its pane is open closes that pane: nothing it started is left running, holding what was
134
+ // copied to it, after its thread goes back
135
+ try {
136
+ const started = Date.now();
137
+ while (Date.now() - started < 60_000) {
138
+ const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
139
+ if (session) { await ready(session.address); return session.address; }
140
+ await sleep(500);
141
+ }
142
+ throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute; its pane is closed`);
143
+ } catch (error) {
144
+ try { await this.closePane(pane); } catch (closing) { error.message += ` (its pane ${pane} could not be closed: ${closing.message})`; }
145
+ throw error;
138
146
  }
139
- throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute`);
140
147
  }
141
148
 
142
149
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.87",
3
+ "version": "0.3.88",
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",