@volter/supercode-teams 0.2.151 → 0.2.153

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/bin/teams.mjs CHANGED
@@ -47,7 +47,7 @@ import { NODE_PROTOCOL } from '@volter/teams/channel';
47
47
 
48
48
  const MACHINE_VERBS = new Set(['machine', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail']);
49
49
  // Every flag a machine verb reads; any other is refused rather than silently ignored.
50
- const KNOWN_FLAGS = new Set(['home', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft']);
50
+ const KNOWN_FLAGS = new Set(['home', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft', 'message-ids']);
51
51
 
52
52
  const argv = process.argv.slice(2);
53
53
  const flags = {};
@@ -237,8 +237,8 @@ async function main() {
237
237
  if (flags.key === true || rest.length) die('input <pane> --key NAMES: keys by tmux name, space- or comma-separated (Escape, Enter, Tab, BTab, BSpace, Space, Up, Down, Left, Right, Home, End, PPage, NPage, DC, C-a … C-z)');
238
238
  return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true, caller: caller() })));
239
239
  }
240
- if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' '), caller: caller() })));
241
- return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'], caller: caller() })));
240
+ if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' '), message_ids: flags['message-ids'] ? String(flags['message-ids']).split(',') : undefined, caller: caller() })));
241
+ return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'], message_ids: flags['message-ids'] ? String(flags['message-ids']).split(',') : undefined, caller: caller() })));
242
242
  case 'port': {
243
243
  // `port <pane>/<n> [--listen host:port]`: a machine port forwarded here. Stays up until ctrl-c.
244
244
  const spec = String(sub ?? '');
package/machine.mjs CHANGED
@@ -50,6 +50,7 @@ import { request as httpRequest } from 'node:http';
50
50
  import { DEFAULT_RANK, normalizeRank, judge } from './attention.mjs';
51
51
  import { popupTerminal, openPopup } from './popup-terminals.mjs';
52
52
  import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from './execution.mjs';
53
+ import { TerminalMailbox, retainedTerminalMail } from './terminal-mail.mjs';
53
54
  import { confirmNativeInitialInput } from './native-initial-input.mjs';
54
55
  import { canonicalCodexFolderTrust, settleCodexHookReview } from './codex-startup.mjs';
55
56
 
@@ -197,6 +198,13 @@ export class TeamsMachine extends MachineCore {
197
198
  // longer than the caller's own RPC timeout — so the wait lives here, where
198
199
  // `stop()` can abandon it rather than leave a timer running past the daemon.
199
200
  this.voiceProcesses = new Map(); // session address -> optional voice pack child
201
+ this.terminalMailboxes = new Map();
202
+ this.terminalMailRoot = join(this.env.SUPERCODE_HOME || join(this.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode'), 'mail');
203
+ this.terminalMailTimer = setInterval(() => {
204
+ if (this.terminalMailWork) return;
205
+ this.terminalMailWork = this.#drainTerminalMail().catch(error => this.emit('mail-watch-stderr', `terminal mailbox: ${error.message}`)).finally(() => { this.terminalMailWork = null; });
206
+ }, 1000);
207
+ this.terminalMailTimer.unref?.();
200
208
  this.inputInFlight = new Set(); // one input transaction per pane
201
209
  this.inputRepairsPath = join(this.home, 'input-repairs.json');
202
210
  this.inputRepairs = readJson(this.inputRepairsPath, {});
@@ -229,6 +237,7 @@ export class TeamsMachine extends MachineCore {
229
237
  }
230
238
 
231
239
  async beforeClose() {
240
+ clearInterval(this.terminalMailTimer); await this.terminalMailWork;
232
241
  clearInterval(this.inputRepairTimer);this.inputRepairTimer=null;
233
242
  await this.inputRepairWork;
234
243
  clearInterval(this.endWatch); this.endWatch = null;
@@ -894,13 +903,46 @@ export class TeamsMachine extends MachineCore {
894
903
  this.pendingInitialInputs.set(key, { abort, task });
895
904
  }
896
905
 
906
+ async #drainTerminalMail() {
907
+ for (const saved of retainedTerminalMail(this.terminalMailRoot)) {
908
+ if (this.terminalMailboxes.has(saved.recipient) || !saved.binding) continue;
909
+ const session = await this.#paneByContext(saved.binding.context);
910
+ if (!session || session.id !== saved.binding.terminal || session.rootPid !== saved.binding.pid) continue;
911
+ const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.log.append({ principal: null, cap: 'control',
912
+ target: `pane:${saved.binding.context}`, outcome: receipt.delivered ? 'input-delivered' : 'input-pending', detail: JSON.stringify(receipt) }), (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
913
+ queue.identity = saved.binding.identity; queue.host = this.host; queue.session = session;
914
+ this.terminalMailboxes.set(saved.recipient, queue);
915
+ }
916
+ await Promise.all([...this.terminalMailboxes.values()].filter(queue => !this.inputRepairs[queue.state.binding?.context] && !this.inputInFlight.has(queue.session?.id)).map(queue => queue.kick()));
917
+ }
918
+
919
+ async #terminalMail(session, contextKey, kind, text, options) {
920
+ const activity = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] });
921
+ const native = activity?.activities?.find(row => row.pid === session.rootPid)?.activity;
922
+ if (!native?.session_id) return { delivered: false, reason: 'native-session-unresolved' };
923
+ const found = await this.#serveRequest('harness.v1.sessions.discover', { query: native.session_id, harnesses: [kind], limit: 20 });
924
+ const row = found?.sessions?.find(row => row.locator?.session_id === native.session_id);
925
+ const path = row?.locator?.storage?.path;
926
+ if (!row?.locator || (kind === 'codex' && !path)) return { delivered: false, reason: 'native-transcript-unresolved' };
927
+ const key = `${kind}:${native.session_id}`;
928
+ let queue = this.terminalMailboxes.get(key);
929
+ if (!queue) {
930
+ queue = new TerminalMailbox(this.terminalMailRoot, key, receipt => this.log.append({ principal: null, cap: 'control', target: `pane:${contextKey}`,
931
+ outcome: receipt.delivered ? 'input-delivered' : 'input-pending', detail: JSON.stringify(receipt) }), (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
932
+ this.terminalMailboxes.set(key, queue);
933
+ }
934
+ const ids = queue.enqueue(text, options.messageIds, { kind, path, locator: row.locator }, this.host, session);
935
+ return queue.receipt(ids);
936
+ }
937
+
897
938
  async #deliverPaneText(session, contextKey, text, options = {}) {
939
+ const kind = this.#paneKind(contextKey, session);
940
+ if (['codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) return this.#terminalMail(session, contextKey, kind, submission(text, false), options);
898
941
  if(this.inputRepairs[contextKey])return {delivered:false,reason:'input-cleanup-pending',cleanup_required:true};
899
942
  if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
900
943
  this.inputInFlight.add(session.id);
901
944
  try {
902
- const kind = this.#paneKind(contextKey, session);
903
- if (kind === 'claude-code' || kind === 'codex') {
945
+ if (kind === 'claude-code') {
904
946
  if (typeof terminal.deliverComposerInput !== 'function') return { delivered: false, reason: 'terminal-package-too-old' };
905
947
  const result=await terminal.deliverComposerInput(this.host, session.id, kind, submission(text, false), options);
906
948
  if(result.cleanup_required){
@@ -1542,15 +1584,16 @@ export class TeamsMachine extends MachineCore {
1542
1584
  if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
1543
1585
  let result;
1544
1586
  if (params.keys || params.submit === false) {
1587
+ if (!params.keys && [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length))) return { delivered: false, reason: 'terminal mailbox has an unconfirmed paste' };
1545
1588
  this.inputInFlight.add(session.id);
1546
1589
  try {
1547
1590
  const delivery = await this.host.sendInput(session.id, params.keys ? String(params.text ?? '') : submission(params.text, false), { submitMode: 'single', deadlineMs });
1548
1591
  result = { delivered: true, delivery };
1549
1592
  } finally { this.inputInFlight.delete(session.id); }
1550
1593
  } else {
1551
- result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs });
1594
+ result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs, messageIds: params.message_ids });
1552
1595
  }
1553
- act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', result.reason);
1596
+ act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', JSON.stringify(result));
1554
1597
  return result;
1555
1598
  }
1556
1599
  case 'panes.clearDraft': {
@@ -1620,15 +1663,15 @@ export class TeamsMachine extends MachineCore {
1620
1663
  if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
1621
1664
  if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
1622
1665
  const kind = this.#paneKind(params.pane, session);
1623
- const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
1624
- const first = await draftOf();
1625
- await new Promise((resolve) => setTimeout(resolve, COMPOSER_RECHECK_MS));
1626
- const second = await draftOf();
1627
- if (first !== false || second !== false) {
1628
- return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
1666
+ if (kind === 'claude-code') {
1667
+ const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
1668
+ const first = await draftOf();
1669
+ await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
1670
+ const second = await draftOf();
1671
+ if (first !== false || second !== false) return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
1629
1672
  }
1630
- const result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs: Date.now() + 10_000 });
1631
- act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', result.reason);
1673
+ const result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs: Date.now() + 10_000, messageIds: params.message_ids });
1674
+ act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', JSON.stringify(result));
1632
1675
  return result;
1633
1676
  }
1634
1677
  case 'panes.capture': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.151",
3
+ "version": "0.2.153",
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",
@@ -0,0 +1,130 @@
1
+ // Per-recipient terminal mailbox. A retained paste is never repeated: retries
2
+ // press Enter and look for the exact user message in the native transcript.
3
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+
8
+ const hash = value => createHash('sha256').update(value).digest('hex');
9
+ const textOf = content => typeof content === 'string' ? content : Array.isArray(content) && content.every(p => ['text', 'input_text'].includes(p.type)) ? content.map(p => p.text).join('') : null;
10
+
11
+ function confirmed(batch) {
12
+ const fd = openSync(batch.path, 'r');
13
+ try {
14
+ let offset = batch.offset, buffered = '';
15
+ const decoder = new StringDecoder('utf8');
16
+ const bytes = Buffer.alloc(64 * 1024);
17
+ for (;;) {
18
+ const count = readSync(fd, bytes, 0, bytes.length, offset);
19
+ if (!count) return false;
20
+ offset += count; buffered += decoder.write(bytes.subarray(0, count));
21
+ let end;
22
+ while ((end = buffered.indexOf('\n')) >= 0) {
23
+ const line = buffered.slice(0, end); buffered = buffered.slice(end + 1);
24
+ let row; try { row = JSON.parse(line); } catch { continue; }
25
+ const content = batch.kind === 'codex' && row.type === 'response_item' && row.payload?.type === 'message' && row.payload.role === 'user'
26
+ ? row.payload.content : batch.kind === 'grok' && row.type === 'user' ? row.content : null;
27
+ if (textOf(content) === batch.text) return true;
28
+ }
29
+ }
30
+ } finally { closeSync(fd); }
31
+ }
32
+
33
+ export class TerminalMailbox {
34
+ constructor(root, recipient, log, load) {
35
+ this.dir = join(root, 'terminal-delivery', hash(recipient));
36
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
37
+ this.file = join(this.dir, 'queue.json'); this.log = log; this.load = load;
38
+ try { this.state = JSON.parse(readFileSync(this.file, 'utf8')); }
39
+ catch (error) { if (error.code !== 'ENOENT') throw error; this.state = { recipient, pending: [], active: null, receipts: [], accepted: {} }; }
40
+ this.work = null;
41
+ }
42
+ save() {
43
+ const encoded = JSON.stringify(this.state) + '\n';
44
+ if (encoded === this.saved) return;
45
+ const temporary = `${this.file}.${process.pid}.tmp`;
46
+ writeFileSync(temporary, encoded, { mode: 0o600 });
47
+ renameSync(temporary, this.file); this.saved = encoded;
48
+ }
49
+ receipt(ids) {
50
+ const delivered = ids.every(id => this.state.receipts.some(r => r.message_ids.includes(id)));
51
+ const batch = this.state.active;
52
+ return { delivered, queued: !delivered, message_ids: ids,
53
+ ...(delivered ? { receipt: 'native-user-message' } : { reason: batch?.error ?? 'queued', batch: batch?.id ?? null }) };
54
+ }
55
+ enqueue(text, ids, identity, host, session) {
56
+ const messageIds = ids?.length ? ids : [`input-${randomUUID()}`];
57
+ if (messageIds.some(id => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(id))) throw new Error('invalid terminal message id');
58
+ const digest = hash(text);
59
+ const accepted = this.state.accepted = Object.assign(Object.create(null), this.state.accepted ?? {});
60
+ const previous = messageIds.filter(id => accepted[id]);
61
+ if (previous.some(id => accepted[id] !== digest) || (previous.length && previous.length !== messageIds.length)) throw new Error('terminal message ids must retry the same complete envelope');
62
+ if (!previous.length) {
63
+ for (const id of messageIds) accepted[id] = digest;
64
+ // New mail joins the next paste while the current one awaits its receipt.
65
+ this.state.pending.push({ ids: messageIds, text }); this.save();
66
+ }
67
+ this.identity = identity; this.host = host; this.session = session;
68
+ this.state.binding = { context: session.contextKey, terminal: session.id, pid: session.rootPid, identity }; this.save();
69
+ return messageIds;
70
+ }
71
+ kick() {
72
+ if (this.work) return this.work;
73
+ this.work = this.drain().catch(error => {
74
+ if (this.state.active) { this.state.active.error = error.message; this.save(); }
75
+ this.log({ delivered: false, reason: error.message, message_ids: this.state.active?.message_ids ?? [] });
76
+ }).finally(() => { this.work = null; });
77
+ return this.work;
78
+ }
79
+ async drain() {
80
+ let batch = this.state.active;
81
+ if (!batch && this.state.pending.length) {
82
+ const items = []; let size = 0;
83
+ for (const message of this.state.pending) {
84
+ if (items.length && size + Buffer.byteLength(message.text) > 32768) break;
85
+ items.push(message); size += Buffer.byteLength(message.text);
86
+ }
87
+ const text = items.map(m => m.text).join('\n\n');
88
+ const incremental = this.identity.kind === 'codex';
89
+ const baseline = incremental ? null : await this.load(this.identity.locator, { message_tail: 1 });
90
+ batch = { id: randomUUID(), message_ids: items.flatMap(m => m.ids), text, kind: this.identity.kind,
91
+ path: this.identity.path, offset: incremental ? statSync(this.identity.path).size : null,
92
+ locator: this.identity.locator, messageOffset: baseline?.window?.total_messages ?? null, phase: 'prepared', enters: 0, last_enter: 0 };
93
+ this.state.active = batch; this.state.pending = this.state.pending.filter(message => !items.includes(message)); this.save();
94
+ }
95
+ if (!batch) return;
96
+ const seen = batch.offset !== null ? confirmed(batch) : (await this.load(batch.locator, { message_offset: batch.messageOffset, message_limit: 1000 })).session?.messages?.some(message => message.role === 'user' && textOf(message.content) === batch.text);
97
+ if (seen) {
98
+ const receipt = { batch: batch.id, message_ids: batch.message_ids, delivered: true, receipt: 'native-user-message', at: Date.now() };
99
+ appendFileSync(join(this.dir, 'receipts.jsonl'), JSON.stringify(receipt) + '\n', { mode: 0o600 });
100
+ this.state.receipts.push(receipt);
101
+ this.state.active = null; this.save(); this.log(receipt); return;
102
+ }
103
+ if (batch.phase === 'prepared') {
104
+ // Persist uncertainty before I/O. A daemon restart checks the transcript;
105
+ // it never guesses that an unacknowledged paste did not happen.
106
+ batch.phase = 'paste_started'; this.save();
107
+ await this.host.sendInput(this.session.id, batch.text, { submitMode: 'none' });
108
+ batch.phase = 'pasted'; this.save();
109
+ }
110
+ if (batch.phase === 'paste_started') {
111
+ batch.error = 'paste outcome uncertain; native transcript confirmation pending'; this.save(); return;
112
+ }
113
+ if (batch.enters < 3 && Date.now() - batch.last_enter >= 5000) {
114
+ batch.enters += 1; batch.last_enter = Date.now(); this.save();
115
+ await this.host.sendInput(this.session.id, '\r', { submitMode: 'single' });
116
+ const attempt = { delivered: false, queued: true, batch: batch.id, message_ids: batch.message_ids, attempt: batch.enters, at: Date.now() };
117
+ appendFileSync(join(this.dir, 'attempts.jsonl'), JSON.stringify(attempt) + '\n', { mode: 0o600 });
118
+ this.log(attempt);
119
+ }
120
+ batch.error = batch.enters >= 3 ? 'native transcript unconfirmed after Enter retries' : 'native transcript confirmation pending';
121
+ this.save();
122
+ }
123
+ }
124
+
125
+ export function retainedTerminalMail(root) {
126
+ const directory = join(root, 'terminal-delivery');
127
+ let names; try { names = readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
128
+ return names.map(name => JSON.parse(readFileSync(join(directory, name, 'queue.json'), 'utf8')))
129
+ .filter(state => state.active || state.pending.length);
130
+ }