@volter/supercode-teams 0.3.197 → 0.3.199

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
@@ -61,8 +61,8 @@ Before the first SQLite start, `supercode teams mail migration-check --home /abs
61
61
  reads that guard locally without a daemon connection or migration. Admission is not proof that the import will finish.
62
62
  The [source boundary and pending qualification](../../docs/evidence/legacy-wrapper-migration-2026-10-08.md)
63
63
  name the trusted legacy ABI, loss of later legacy diagnostics and existing retirement recovery.
64
- Settled read bodies expire after 30 days, with explicit placeholders and retained thread headers; pending mail keeps
65
- its originals. Expired read headers are capped at 1,000,000 and carrier/terminal event log tables at 10,000 rows each. Read the
64
+ Mail bodies are kept like their headers. Read headers whose body is not kept show an explicit placeholder and are
65
+ capped at 1,000,000; carrier/terminal event log tables keep 10,000 rows each. Read the
66
66
  [dated qualification scope](../../docs/evidence/sqlite-mail-store-2026-10-08.md) before inferring platform support.
67
67
 
68
68
  Native mail-history reads process the opened file's initial extent in 64 KiB blocks,
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, randomUUID } from 'node:crypto';
87
+ import { randomBytes, createHash } 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
@@ -909,30 +909,16 @@ export class TeamsMachine extends MachineCore {
909
909
  const reading = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
910
910
  const native = reading?.activities?.find(row => row.pid === session.rootPid)?.activity;
911
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.
912
+ // A board wake is never typed into a Claude /btw panel, and the daemon sends
913
+ // no keys to close it: the wake waits unread and the board reports the
914
+ // session's `dialog open` turn to the manager.
915
915
  const boardWake = messageIds?.length && messageIds.every(id => {
916
916
  const stored = this.mailStore.findIn(address, id);
917
917
  const envelope = stored ? this.mailStore.body(stored) : null;
918
918
  return envelope?.kind === 'notice' && parseAddress(envelope.from)?.harness === 'board';
919
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
- }
920
+ if (boardWake && claudeSideQuestionPanel(await this.host.capture(session.id, { lines: 200, preserveEscapes: true }), native))
921
+ return { delivered: false, reason: 'a Claude /btw panel is open on the pane; the board wake waits unread and no keys were sent' };
936
922
  const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
937
923
  if (result.refused) throw new Error(result.refused.reason);
938
924
  return result.waits ?? result;
package/mail/carrier.mjs CHANGED
@@ -53,12 +53,6 @@ export class MailCarrier {
53
53
  start() {
54
54
  this.onWake = () => this.#schedule();
55
55
  this.store.on('wake', this.onWake);
56
- this.onLive = () => {
57
- if (this.stopped) return;
58
- try { this.store.wakeIdleSessions(this.live.sessions().filter((row) => row.status === 'idle').map((row) => row.address)); }
59
- catch (error) { this.log(`carrier: idle waits could not be resumed: ${error.message}`); }
60
- };
61
- this.live.on('changed', this.onLive);
62
56
  this.#sweep();
63
57
  this.sweeper = setInterval(() => this.#sweep(), LAST_RETRY_MS);
64
58
  this.sweeper.unref?.();
@@ -74,7 +68,6 @@ export class MailCarrier {
74
68
  stop() {
75
69
  this.stopped = true;
76
70
  this.store.off('wake', this.onWake);
77
- this.live.off('changed', this.onLive);
78
71
  clearInterval(this.sweeper); clearTimeout(this.timer);
79
72
  this.timer = null; this.timerAt = null;
80
73
  }
@@ -135,7 +128,7 @@ export class MailCarrier {
135
128
  let due = this.store.dueWakes(address, now);
136
129
  if (!due.length) return;
137
130
  // a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
138
- // hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
131
+ // hook and no relay (Grok), whose mail that found a draft in its composer waits here until it is free
139
132
  const session = this.live.sessions().find((row) => row.address === address);
140
133
  const pane = session ? this.paneOf(session) : null;
141
134
  const users = due.filter((id) => this.store.findIn(address, id)?.envelope.kind === 'user');
@@ -198,8 +191,7 @@ export class MailCarrier {
198
191
  new Promise((settle) => { bound = setTimeout(() => settle({ outcome: 'later', detail: `its hand-over was still running after ${HANDOVER_BOUND_MS / 1000} s` }), HANDOVER_BOUND_MS); bound.unref?.(); }),
199
192
  ]).finally(() => clearTimeout(bound));
200
193
  carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' }
201
- : answer?.outcome === 'waiting' ? { kind: 'waiting', reason: answer.detail }
202
- : answer?.outcome === 'pending' ? { kind: 'pending', reason: answer.detail } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
194
+ : answer?.outcome === 'pending' ? { kind: 'pending', reason: answer.detail } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
203
195
  } catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
204
196
  }
205
197
  if (this.stopped) return;
@@ -325,11 +317,6 @@ export class MailCarrier {
325
317
  this.store.setWakeState(address, id, { ...state, next_at_ms: Date.now() + FIRST_RETRY_MS, last_error: carried.reason });
326
318
  return;
327
319
  }
328
- if (carried.kind === 'waiting') {
329
- const state = this.store.wakeState(address, id);
330
- this.store.setWakeState(address, id, { ...state, waiting_for_idle: true, next_at_ms: Date.now() + LAST_RETRY_MS });
331
- return;
332
- }
333
320
  const now = Date.now();
334
321
  const state = this.store.wakeState(address, id);
335
322
  const envelope = this.store.findIn(address, id)?.envelope;
package/mail/send.mjs CHANGED
@@ -31,7 +31,7 @@ const CODEX_KNOWN_WAIT_MS = 20_000;
31
31
  const RELAY_GRACE_MS = 30_000;
32
32
  /** The kinds of delivery that hand nothing over (#deliverWoken's `how`): the copy waits filed. An operator's mailbox,
33
33
  * which its operator reads, and a session's hook, which shows it, are deliveries. */
34
- const LEFT_FILED = new Set(['queued', 'stored', 'pane_held', 'pane_waits', 'native_waits', 'not_running']);
34
+ const LEFT_FILED = new Set(['queued', 'stored', 'pane_held', 'pane_waits', 'not_running']);
35
35
 
36
36
  /** Replies in one unbroken chain between two sessions beyond which another is refused. */
37
37
  const REPLY_CHAIN_LIMIT = 32;
@@ -428,7 +428,6 @@ export class MailSend {
428
428
  steered: [0, 'steered into its running turn'],
429
429
  started: [0, 'it was idle, so the message started a turn'],
430
430
  native_busy: [0, 'it will read it at its next tool call'],
431
- native_waits: [EXIT_STORED, 'Stored: its relay could not deliver it and its session is busy, so its pane fallback waits unread until it is idle. Do not resend'],
432
431
  native_idle: [0, 'it was idle, so the message starts its next turn'],
433
432
  hooked: [0, 'queued in its mailbox; terminal delivery waits for native transcript confirmation'],
434
433
  hook_woken: [0, 'it was idle, so its pane was told to read its mailbox; its hook shows it the message in that turn'],
@@ -499,7 +498,8 @@ export class MailSend {
499
498
  const busy = door.session.status !== 'idle' || door.session.panes_current === false;
500
499
  if (!wake && !busy) throw new Refused('queue');
501
500
  // a busy session is handed it at once, as an idle one is: Claude Code's own cross-session message surfaces at the
502
- // receiver's next tool call (D391). Only the pane fallback below waits for idle, since a busy pane is never typed into
501
+ // receiver's next tool call (D391). Its pane fallback below types it at once too: a prompt submitted mid-turn is queued
502
+ // and absorbed into the running turn
503
503
  // its reply line names the door this session really has: its tools when it runs supercode's MCP server
504
504
  const handed = sent.reply_via?.mode === 'command' && door.session.tools ? { ...sent, reply_via: { mode: 'tool' } } : sent;
505
505
  const text = render(handed);
@@ -549,15 +549,7 @@ export class MailSend {
549
549
  const received = this.store.claimOf(to, handed.id)?.recipient === true;
550
550
  // the relay produced no receipt and cannot send it late (known not made, or not in the receiver's transcript after
551
551
  // its bound, its queue gone): a session with a daemon pane has the same copy typed there instead
552
- if (!relayed.delivered && !received && wake && door.pane && (!relayed.unknown || relayed.landed === false) && busy) {
553
- // its pane is typed into only once its turn is idle: the copy waits unread, its wake resumed when it goes idle
554
- // (carrier.mjs), which hands it to the relay again first
555
- this.log(`relay could not deliver ${shortId(handed.id)} to busy ${to} (${relayed.detail}); its pane fallback waits for idle`);
556
- try { this.store.requestWake(to, handed.id, { keep: true }); } catch { /* the wake filed above stands */ }
557
- const state = this.store.wakeState(to, handed.id);
558
- this.store.setWakeState(to, handed.id, { ...state, waiting_for_idle: true, next_at_ms: Date.now() + 300_000 });
559
- delivered = 'native_waits';
560
- } else if (!relayed.delivered && !received && wake && door.pane && (!relayed.unknown || relayed.landed === false)) {
552
+ if (!relayed.delivered && !received && wake && door.pane && (!relayed.unknown || relayed.landed === false)) {
561
553
  this.log(`relay could not deliver ${shortId(handed.id)} to ${to} (${relayed.detail}); typing it into its pane ${door.pane}`);
562
554
  this.relaying.add(key);
563
555
  try { delivered = await this.#typeIntoPane(handed, to, door.pane, wake, attempt); }
@@ -610,7 +602,7 @@ export class MailSend {
610
602
  }
611
603
  // held by the daemon's queue for the pane, typed once its composer frees: not typed yet, so kept unread
612
604
  if (typed?.queued) { this.store.deliver(to, sent); if (wake) this.store.requestWake(to, sent.id, { keep: true }); return 'pane_held'; }
613
- // not typed (its composer held a draft or a turn ran): it waits unread with a wake, so the carrier tries it again once
605
+ // not typed (its composer held a draft, or showed none): it waits unread with a wake, so the carrier tries it again once
614
606
  // the composer is empty (mail/carrier.mjs), never left for a read a session with no hook never makes
615
607
  this.store.deliver(to, sent);
616
608
  if (!wake) return 'queued';
@@ -747,7 +739,7 @@ export class MailSend {
747
739
  if (error instanceof Refused) throw new Error(`${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
748
740
  throw error;
749
741
  }
750
- return { how, text: { steered: 'steered into its running turn', started: 'started a turn', native_busy: 'read at its next tool call', native_waits: 'waits unread until its native turn is idle', native_idle: 'starts its next turn', hooked: 'in its mailbox, its hook shows it', queued: 'waits in its mailbox', operator: 'filed', already: 'it already had it', stored: 'stored; it has no delivery door', typed: 'typed into its pane', pane_held: 'held for its pane until its composer is free', pane_waits: 'waits in its mailbox (its composer was not empty)' }[how] };
742
+ return { how, text: { steered: 'steered into its running turn', started: 'started a turn', native_busy: 'read at its next tool call', native_idle: 'starts its next turn', hooked: 'in its mailbox, its hook shows it', queued: 'waits in its mailbox', operator: 'filed', already: 'it already had it', stored: 'stored; it has no delivery door', typed: 'typed into its pane', pane_held: 'held for its pane until its composer is free', pane_waits: 'waits in its mailbox (its composer was not empty)' }[how] };
751
743
  }
752
744
 
753
745
  // ---- a filing --------------------------------------------------------------------------------------------------
@@ -848,9 +840,9 @@ export class MailSend {
848
840
  return refused('delivery_failed', error.message);
849
841
  }
850
842
  return {
851
- delivered_to_bus: door.kind !== 'stored' && !['pane_waits', 'pane_held', 'native_waits'].includes(how), message_id: sent.id, reply_to: sender,
843
+ delivered_to_bus: door.kind !== 'stored' && !['pane_waits', 'pane_held'].includes(how), message_id: sent.id, reply_to: sender,
852
844
  target: { harness, session_id: sessionId, name: door.kind === 'native' ? door.session.name.split('@')[0] : null },
853
- delivery: { door: door.kind, how: { steered: 'steered', started: 'started', native_busy: 'next_tool_call', native_waits: 'waiting_for_idle', native_idle: 'started', hooked: 'hook', queued: 'queued', stored: 'stored', operator: 'filed', already: 'already', typed: 'typed', pane_held: 'held', pane_waits: 'stored' }[how] },
845
+ delivery: { door: door.kind, how: { steered: 'steered', started: 'started', native_busy: 'next_tool_call', native_idle: 'started', hooked: 'hook', queued: 'queued', stored: 'stored', operator: 'filed', already: 'already', typed: 'typed', pane_held: 'held', pane_waits: 'stored' }[how] },
854
846
  };
855
847
  }
856
848
 
@@ -1038,7 +1030,7 @@ export class MailSend {
1038
1030
  try {
1039
1031
  const how = await this.route(sent, address, door, true, false);
1040
1032
  // handed to the session (or already its): moved to cur/ (one only queued for a runtime not woken would stay unread)
1041
- if (LEFT_FILED.has(how)) return { outcome: how === 'native_waits' ? 'waiting' : how === 'pane_held' ? 'pending' : 'later', detail: how === 'pane_waits' ? this.store.wakeState(address, id).last_error : how };
1033
+ if (LEFT_FILED.has(how)) return { outcome: how === 'pane_held' ? 'pending' : 'later', detail: how === 'pane_waits' ? this.store.wakeState(address, id).last_error : how };
1042
1034
  const now = this.store.findIn(address, id);
1043
1035
  if (how !== 'queued' && now?.state === 'unread') this.store.markRead(address, now.path);
1044
1036
  return { outcome: 'delivered', detail: null };
@@ -472,7 +472,7 @@ export class SqliteMailStore extends EventEmitter {
472
472
  }
473
473
  setWakeState(address, id, state) {
474
474
  const record = { attempts: state.attempts ?? 0, next_at_ms: state.next_at_ms ?? 0, last_error: state.last_error ?? null,
475
- handover_at_ms: state.handover_at_ms ?? null, failing_since_ms: state.failing_since_ms ?? null, waiting_for_idle: state.waiting_for_idle === true };
475
+ handover_at_ms: state.handover_at_ms ?? null, failing_since_ms: state.failing_since_ms ?? null };
476
476
  this.transaction(() => {
477
477
  if (this.statement('UPDATE deliveries SET record = ?, due_at_ms = ?, attempts = ? WHERE mailbox = ? AND message = ?')
478
478
  .run(JSON.stringify(record), record.next_at_ms, record.attempts, address, id).changes) {
@@ -519,17 +519,6 @@ export class SqliteMailStore extends EventEmitter {
519
519
  });
520
520
  }
521
521
 
522
- /** A live idle transition wakes only the native waits for those addresses. */
523
- wakeIdleSessions(addresses) {
524
- this.transaction(() => {
525
- const changed = this.statement(`UPDATE deliveries SET due_at_ms = 0, record = json_set(record, '$.next_at_ms', 0)
526
- WHERE mailbox IN (SELECT value FROM json_each(?)) AND json_extract(record, '$.waiting_for_idle') = 1
527
- RETURNING mailbox`).all(JSON.stringify(addresses));
528
- for (const address of new Set(changed.map((row) => row.mailbox))) this.refreshDeliveryTarget('mailbox', address);
529
- if (changed.length) this.database.changed('wake', {});
530
- });
531
- }
532
-
533
522
  nextDeliveryTarget(excluded = []) {
534
523
  return this.statement(`SELECT t.kind, t.target, t.due_at_ms FROM delivery_targets t
535
524
  WHERE NOT EXISTS (SELECT 1 FROM json_each(?) e WHERE json_extract(e.value, '$.kind') = t.kind AND json_extract(e.value, '$.target') = t.target)
package/mail/sqlite.mjs CHANGED
@@ -10,12 +10,12 @@ import { RECORD_SCHEMA } from '../record-state.mjs';
10
10
 
11
11
  export const MAIL_SCHEMA_VERSION = 3;
12
12
  export const MAIL_RETENTION = Object.freeze({
13
- bodyDays: 30,
13
+ relayTextDays: 30,
14
14
  maxReadHeaders: 1_000_000,
15
15
  maxCarrierRows: 10_000,
16
16
  maxTerminalLogRows: 10_000,
17
17
  });
18
- export const RETAINED_BODY = `[this message's body was let go after ${MAIL_RETENTION.bodyDays} days (mail retention); its header and thread are kept]`;
18
+ export const RETAINED_BODY = `[this message's body was not kept; its header and thread are kept]`;
19
19
 
20
20
  const SCHEMA = `
21
21
  CREATE TABLE IF NOT EXISTS mail_metadata (
@@ -331,51 +331,24 @@ export class MailDatabase extends EventEmitter {
331
331
  });
332
332
  }
333
333
 
334
- /** Only settled read mail is eligible. Pending deliveries, subscriptions and
335
- * claim holders protect their originals; an unresolved message never loses its body. */
334
+ /** Mail bodies are kept. Retention removes old relay scratch copies (their
335
+ * originals stay in the mailbox) and caps headers whose body is already gone. */
336
336
  retain(now = Date.now()) {
337
- const cutoff = now - this.retention.bodyDays * 86_400_000;
337
+ const cutoff = now - this.retention.relayTextDays * 86_400_000;
338
338
  return this.transaction(() => {
339
- const expired = this.statement(`UPDATE messages SET body_digest = coalesce(body_digest, supercode_sha256(body)), body = NULL, first_line = '', body_expired_at_ms = ?
340
- WHERE state = 'cur' AND created_at_ms < ? AND body IS NOT NULL
341
- AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = messages.mailbox AND d.message = messages.id)
342
- AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.mailbox = messages.mailbox AND s.message = messages.id)`)
343
- .run(now, cutoff).changes;
344
- // Completed native questions duplicate an envelope and its answer input.
345
- // Keep the claim/binding/header so a repeat cannot submit another answer,
346
- // but let go of those text copies after the mailbox copies have expired.
347
- const questions = this.statement(`UPDATE mail_records AS r SET record = json_set(
348
- json_remove(record, '$.native', '$.answers'), '$.envelope.body', ?, '$.body_expired_at_ms', ?)
349
- WHERE collection = 'native_questions' AND json_type(record, '$.envelope') = 'object'
350
- AND json_type(record, '$.body_expired_at_ms') IS NULL
351
- AND json_extract(record, '$.envelope.created_at_ms') < ?
352
- AND EXISTS (SELECT 1 FROM mail_records d WHERE d.collection = 'native_questions'
353
- AND d.key = substr(r.key, 1, instr(r.key, '.') - 1) || '.delivered' AND CAST(d.record AS INTEGER) < ?)
354
- AND NOT EXISTS (SELECT 1 FROM mail_records w WHERE w.collection = 'native_questions'
355
- AND w.key = substr(r.key, 1, instr(r.key, '.') - 1) || '.waiting')
356
- AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.id = json_extract(r.record, '$.envelope.id') AND m.body IS NOT NULL)`)
357
- .run(RETAINED_BODY, now, cutoff, cutoff).changes;
358
339
  const relayRecords = this.statement(`DELETE FROM mail_records AS r WHERE collection = 'relays'
359
340
  AND (key LIKE '%/turn.txt' OR key LIKE '%/receipt.json') AND updated_at_ms < ?
360
341
  AND NOT EXISTS (SELECT 1 FROM mail_records q WHERE q.collection = 'relays'
361
342
  AND q.key = substr(r.key, 1, instr(r.key, '/')) || 'queue.json')`).run(cutoff).changes;
362
- const launchInputs = this.statement(`UPDATE mail_records SET record = json_set(
363
- json_remove(record, '$.initialInput', '$.claudeInput.text'), '$.input_body_expired_at_ms', ?)
364
- WHERE collection = 'native_launches' AND json_type(record, '$.input_body_expired_at_ms') IS NULL
365
- AND json_type(record, '$.input_settled_at_ms') = 'integer'
366
- AND json_extract(record, '$.input_settled_at_ms') < ?
367
- AND (json_type(record, '$.initialInput') = 'text' OR json_type(record, '$.claudeInput.text') = 'text')`)
368
- .run(now, cutoff).changes;
369
343
  const removed = this.statement(`DELETE FROM messages WHERE (mailbox, id) IN (
370
344
  SELECT mailbox, id FROM messages m WHERE state = 'cur' AND body_expired_at_ms IS NOT NULL
371
345
  AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = m.mailbox AND d.message = m.id)
372
346
  AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.mailbox = m.mailbox AND s.message = m.id)
373
347
  ORDER BY created_at_ms DESC, id DESC LIMIT -1 OFFSET ?)`)
374
348
  .run(this.retention.maxReadHeaders).changes;
375
- const result = { at_ms: now, expired_bodies: expired, expired_question_records: questions,
376
- removed_relay_text_records: relayRecords, expired_launch_inputs: launchInputs, removed_headers: removed, policy: this.retention };
349
+ const result = { at_ms: now, removed_relay_text_records: relayRecords, removed_headers: removed, policy: this.retention };
377
350
  this.setMetadata('retention', result);
378
- if (expired || questions || relayRecords || launchInputs || removed) this.changed('retention', result);
351
+ if (relayRecords || removed) this.changed('retention', result);
379
352
  return result;
380
353
  });
381
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.197",
3
+ "version": "0.3.199",
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/terminal-mail.mjs CHANGED
@@ -1,6 +1,8 @@
1
- // Per-recipient terminal mailbox. A retained paste is never repeated: retries
2
- // press Enter and look for the exact message in the native transcript (a user row, or the queued_command
3
- // attachment a running Claude Code turn absorbs it as).
1
+ // Per-recipient terminal mailbox. Mail is typed and submitted at once, into an idle or a busy session alike: a prompt
2
+ // submitted mid-turn is queued by Claude Code and absorbed into the running turn, and held by Codex and written as a
3
+ // user message in that same turn. Only a composer holding a person's unsent text is waited for. A retained paste is
4
+ // never repeated: retries press Enter and look for the exact message in the native transcript (a user row, or the
5
+ // queued_command attachment a running Claude Code turn absorbs it as).
4
6
  import { statSync, openSync, readSync, closeSync } from 'node:fs';
5
7
  import { createHash, randomUUID } from 'node:crypto';
6
8
  import { spawnSync } from 'node:child_process';
@@ -70,6 +72,10 @@ export function confirmed(batch) {
70
72
  const decoder = new StringDecoder('utf8');
71
73
  const bytes = Buffer.alloc(64 * 1024);
72
74
  let matched = 0, fragments = 0;
75
+ // Claude Code holds a prompt submitted mid-turn in its queue (queue-operation enqueue, with its content) until the
76
+ // turn absorbs it or the next turn takes it (remove/dequeue). While held it is not yet delivered, but it will land:
77
+ // the batch is kept, never refused at its deadline, until the transcript shows it or the queue lets it go.
78
+ if (batch.kind === 'claude-code') batch.claudeQueued = false;
73
79
  for (;;) {
74
80
  const count = readSync(fd, bytes, 0, bytes.length, offset);
75
81
  if (!count) return false;
@@ -78,6 +84,11 @@ export function confirmed(batch) {
78
84
  while ((end = buffered.indexOf('\n')) >= 0) {
79
85
  const line = buffered.slice(0, end); buffered = buffered.slice(end + 1);
80
86
  let row; try { row = JSON.parse(line); } catch { continue; }
87
+ if (batch.kind === 'claude-code' && row.type === 'queue-operation' && (!batch.sessionId || row.sessionId === batch.sessionId)) {
88
+ if (row.operation === 'enqueue' && typeof row.content === 'string' && claudeSame(row.content, batch.text)) batch.claudeQueued = true;
89
+ else if (row.operation !== 'enqueue') batch.claudeQueued = false;
90
+ continue;
91
+ }
81
92
  // A prompt submitted while a turn runs is queued, and reaches the model as a `queued_command` attachment when
82
93
  // the turn absorbs it (queue-operation remove, reason absorbed_mid_turn); no `user` row is written for it.
83
94
  // That attachment is its delivery. An enqueue alone is not: a queued prompt can still be withdrawn.
@@ -174,7 +185,7 @@ export class TerminalMailbox {
174
185
  const own = claudeHolds(batch, shown, true);
175
186
  // One final read/Enter at the deadline, then a bounded native receipt
176
187
  // grace. A badge that painted late never goes straight to refusal.
177
- if (own && !batch.confirmationError && !batch.finalEnter && !/\besc to (?:interrupt|cancel)\b/iu.test(screen)) {
188
+ if (own && !batch.confirmationError && !batch.finalEnter) {
178
189
  batch.finalEnter = true; batch.phase = 'pasted'; batch.expiresAt = Date.now() + 5000;
179
190
  batch.enters++; batch.last_enter = Date.now(); this.save();
180
191
  await this.host.sendInput(this.session.id, '\r', { submitMode: 'single', inputOwner: batch.inputOwner });
@@ -295,6 +306,11 @@ export class TerminalMailbox {
295
306
  if (batch.kind === 'claude-code' && batch.phase === 'prepared' && batch.expiresAt && Date.now() >= batch.expiresAt) {
296
307
  await this.recordRefusal(batch, 'delivery deadline elapsed; no input pasted'); return;
297
308
  }
309
+ if (batch.kind === 'claude-code' && batch.phase !== 'refusing' && batch.claudeQueued) {
310
+ // submitted and held in Claude Code's own queue: it lands when the running turn absorbs it, so it is never
311
+ // refused, pressed again or cleaned up meanwhile
312
+ batch.error = 'queued in its running turn; native transcript confirmation pending'; this.save(); return;
313
+ }
298
314
  if (batch.kind === 'claude-code' && (batch.phase === 'refusing' || (batch.expiresAt && Date.now() >= batch.expiresAt))) {
299
315
  await this.refuseClaude(batch); return;
300
316
  }
@@ -311,12 +327,13 @@ export class TerminalMailbox {
311
327
  } catch (error) {
312
328
  await this.recordRefusal(batch, `input ownership unavailable (${error.code ?? error.message}); no input pasted`); return;
313
329
  }
330
+ // A running turn is no reason to hold: only a person's unsent text (or a screen with no composer) is.
314
331
  for (let check = 0; check < 2; check++) {
315
332
  const screen = await this.host.capture(this.session.id, { lines: 200, preserveEscapes: true });
316
333
  const held = composerText(batch.kind, screen);
317
- if (held !== '' || /\besc to (?:interrupt|cancel)\b/iu.test(screen)) {
334
+ if (held !== '') {
318
335
  await this.host.releaseInput(this.session.id, batch.inputOwner);
319
- batch.error = held == null ? 'no-composer' : held ? 'draft' : 'working'; this.save(); return;
336
+ batch.error = held == null ? 'no-composer' : 'draft'; this.save(); return;
320
337
  }
321
338
  if (check === 0) await pause(200);
322
339
  }
@@ -357,8 +374,9 @@ export class TerminalMailbox {
357
374
  const screen = await this.host.capture(this.session.id, { lines: 200, preserveEscapes: true });
358
375
  // Re-read until the deadline: a Windows paste badge can appear well
359
376
  // after one second. Ownership is held by the pane host, across daemon
360
- // restart, from before the empty reads through receipt/refusal.
361
- if (!claudeHolds(batch, composerText(batch.kind, screen), true) || /\besc to (?:interrupt|cancel)\b/iu.test(screen)) {
377
+ // restart, from before the empty reads through receipt/refusal. A
378
+ // running turn takes the Enter too: it queues the prompt.
379
+ if (!claudeHolds(batch, composerText(batch.kind, screen), true)) {
362
380
  batch.error = 'composer changed or unreadable; native transcript confirmation pending'; this.save(); return;
363
381
  }
364
382
  }