@volter/teams 0.1.45 → 0.1.47

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.
@@ -52,6 +52,13 @@ export type BrowserTurnAnswer =
52
52
  */
53
53
  export function sendBrowserTurn(channel: BrowserChannel, text: string, options?: { turnKey?: string }): Promise<BrowserTurnAnswer>;
54
54
 
55
+ export interface MailHistoryEntry {
56
+ id: string; from: string; fromName: string; to: string; text: string; at: number;
57
+ direction: 'in' | 'out'; inReplyTo: string | null; delivery: string;
58
+ }
59
+ /** Read mailbox history without marking messages read. Requires observe on the bound session. */
60
+ export function readBrowserMail(channel: BrowserChannel): Promise<MailHistoryEntry[]>;
61
+
55
62
  /** A pane's terminal stream. Events: `data` (text), `hello`, `notice`, `error-message`, `close`. */
56
63
  export interface BrowserPane extends ChannelEvents {
57
64
  write(text: string): void;
@@ -167,6 +167,21 @@ export async function sendBrowserTurn(channel, text, { turnKey } = {}) {
167
167
  });
168
168
  }
169
169
 
170
+ /** Read one bound session's mailbox without consuming any messages. */
171
+ export async function readBrowserMail(channel) {
172
+ const stream = await channel.open('mail', { request: { op: 'history' } });
173
+ return new Promise((resolve, reject) => {
174
+ let answer = '';
175
+ const timer = setTimeout(() => { stream.close(); reject(new Error('Mailbox read timed out')); }, 25_000);
176
+ stream.on('data', chunk => { answer += chunk; if (answer.length > 8_000_000) { stream.close(); reject(new Error('Mailbox response too large')); } });
177
+ stream.on('close', () => {
178
+ clearTimeout(timer);
179
+ try { const body = JSON.parse(answer); if (body.code !== 0) throw new Error(body.text || 'Mailbox unavailable'); resolve(body.rows); }
180
+ catch (error) { reject(error); }
181
+ });
182
+ });
183
+ }
184
+
170
185
  /** A pane's terminal stream: 'data' (text), 'hello', 'notice', 'close'; write(text), resize(cols, rows), close(). */
171
186
  export async function attachBrowserPane(channel, { pane, mode = 'observe', cols = 120, rows = 40 }) {
172
187
  const stream = await channel.open('terminal', { pane, mode, cols, rows });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "type": "module",
5
5
  "description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
6
6
  "license": "MIT",
package/server/core.mjs CHANGED
@@ -84,6 +84,14 @@ export async function maintain(api){const now=api.clock.nowMs();
84
84
  {sql:'UPDATE messages SET revision=(SELECT MIN(k.revision) FROM session_versions k WHERE k.team_id=messages.team_id AND k.session_id=messages.session_id AND k.revision>messages.revision AND k.revision<COALESCE(messages.until_revision,messages.revision+1)) WHERE team_id=? AND session_id=? AND revision=? AND EXISTS (SELECT 1 FROM session_versions k WHERE k.team_id=messages.team_id AND k.session_id=messages.session_id AND k.revision>messages.revision AND k.revision<COALESCE(messages.until_revision,messages.revision+1))',params:[team_id,session_id,revision]},
85
85
  ...['messages','session_artifacts','session_versions'].map(table=>({sql:`DELETE FROM ${table} WHERE team_id=? AND session_id=? AND revision=?`,params:[team_id,session_id,revision]})),
86
86
  ]);
87
+ // Retired versions release their native bundles. Keep every bundle still named by a version,
88
+ // continuation (including completed receipts), or in-flight capture, and leave a full day for uploads.
89
+ const orphaned=await api.store.query(`SELECT u.team_id,u.id FROM uploads u WHERE u.committed=1 AND u.created_at+86400000<=?
90
+ AND NOT EXISTS (SELECT 1 FROM session_artifacts a WHERE a.team_id=u.team_id AND a.upload_id=u.id)
91
+ AND NOT EXISTS (SELECT 1 FROM continuations c WHERE c.team_id=u.team_id AND instr(c.value_json,u.id)>0)
92
+ AND NOT EXISTS (SELECT 1 FROM captures c WHERE c.team_id=u.team_id AND c.state='staging' AND instr(c.begin_json,u.id)>0)
93
+ LIMIT 10`,[now]);
94
+ for(const upload of orphaned)await api.store.transaction([{sql:'DELETE FROM uploads WHERE team_id=? AND id=?',params:[upload.team_id,upload.id]}]);
87
95
  await api.store.transaction([
88
96
  {sql:'DELETE FROM cursors WHERE expires_at<=?',params:[now]},
89
97
  {sql:'DELETE FROM global_cursors WHERE expires_at<=?',params:[now]},
@@ -96,8 +104,8 @@ export async function maintain(api){const now=api.clock.nowMs();
96
104
  {sql:'INSERT OR IGNORE INTO capture_expiries(team_id,source_id,id,expired_at) SELECT team_id,source_id,id,? FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now,now]},
97
105
  {sql:'DELETE FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now]},
98
106
  ]).catch(()=>{});
99
- const result={pruned_versions:stale.length,pruned_parts:parts.length,pruned_idempotency:expired.length,backlog:expired.length>=1000||stale.length>=VERSION_PRUNE_BATCH||parts.length>=PART_PRUNE_BATCH};
100
- if(result.pruned_versions||result.pruned_parts||result.pruned_idempotency)console.log(`maintain: pruned ${result.pruned_idempotency} expired replay records, ${result.pruned_parts} parts, ${result.pruned_versions} versions${result.backlog?' (backlog)':''}`);
107
+ const result={pruned_versions:stale.length,pruned_parts:parts.length,pruned_idempotency:expired.length,pruned_uploads:orphaned.length,backlog:orphaned.length>=10||expired.length>=1000||stale.length>=VERSION_PRUNE_BATCH||parts.length>=PART_PRUNE_BATCH};
108
+ if(result.pruned_versions||result.pruned_parts||result.pruned_idempotency||result.pruned_uploads)console.log(`maintain: pruned ${result.pruned_uploads} unreachable bundles, ${result.pruned_idempotency} expired replay records, ${result.pruned_parts} parts, ${result.pruned_versions} versions${result.backlog?' (backlog)':''}`);
101
109
  return result;}
102
110
  /** Session versions `maintain` removes per run: small enough for one Durable Object alarm; a backlog runs again in seconds. */
103
111
  const VERSION_PRUNE_BATCH=20;
@@ -212,7 +212,7 @@ async function delegatedChannel(api, teamId, machineId, token) {
212
212
 
213
213
  /**
214
214
  * A session-view token's channel (RH2 ADR 0008): the member's own browser types a turn into one session its owner
215
- * bound to the token's Room and Conversation. `control` scope only; the grant minted is `interact` on exactly that
215
+ * bound to the token's Room and Conversation. Both scopes can observe its mail; only `control` can interact with that
216
216
  * session (`session:<harness>/<native id>`) on the machine it runs on, for the token's life, and the claim carries
217
217
  * the member's name from the token. Anything else is refused as a session that does not exist.
218
218
  */
@@ -220,7 +220,7 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
220
220
  const unknown = { status: 404, code: 'not_found', message: 'session not found' };
221
221
  const access = await delegatedSessionOf(api, token);
222
222
  if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
223
- if (access.teamId !== teamId || access.scope !== 'control' || access.sessionRef !== sessionRef) return unknown;
223
+ if (access.teamId !== teamId || !['view', 'control'].includes(access.scope) || access.sessionRef !== sessionRef) return unknown;
224
224
  const target = await boundSessionTarget(api, teamId, access);
225
225
  if (!target) return unknown;
226
226
  const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access);
@@ -229,7 +229,7 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
229
229
  { sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', params: [teamId, target.machineId], code: 'not_found', status: 404, message: 'session not found' },
230
230
  ], [
231
231
  { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
232
- { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [teamId, id, target.machineId, principal, JSON.stringify(['interact']), JSON.stringify([`session:${target.harness}/${target.nativeId}`]), issuer, now, access.expiresAt] },
232
+ { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [teamId, id, target.machineId, principal, JSON.stringify(access.scope === 'control' ? ['observe', 'interact'] : ['observe']), JSON.stringify([`session:${target.harness}/${target.nativeId}`]), issuer, now, access.expiresAt] },
233
233
  ]).catch((error) => { if (error.code !== 'not_found') throw error; return null; });
234
234
  return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name } };
235
235
  }
@@ -33,6 +33,9 @@ export function followTeamSession(options: FollowTeamSessionOptions): { stop():
33
33
  /** A message another session sent this one, from its envelope: its sender and body, or null for an ordinary turn. */
34
34
  export function peerMessage(text: string): { name: string; address: string; body: string } | null;
35
35
 
36
+ /** Overlay actual envelopes, deduplicating copies already delivered into the native transcript. */
37
+ export function withMail<Entry extends { id: string; ts: number | null; mail?: { id: string } }>(transcript: Entry[], rows: import('./browser-channel.js').MailHistoryEntry[], options?: { agentName?: string }): Array<Entry | { id: string; role: 'notice'; text: string; ts: number; truncated: boolean; mail: { id: string; from: string; to: string; direction: 'in' | 'out'; inReplyTo?: string | null } }>;
38
+
36
39
  export function personImage(seed?: string): string;
37
40
  export function agentImage(seed: string): string;
38
41
 
@@ -83,7 +83,7 @@ export function peerMessage(text) {
83
83
  const attributes = (tag) => Object.fromEntries([...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
84
84
  const own = attributes(inner[1]), outer = attributes(opens[0][1]);
85
85
  const body = text.slice(inner.index + inner[0].length, close).trim();
86
- return { name: own['from-name'] || outer['from-name'] || own.from || outer.from || 'Another session', address: own.from || outer.from || '', body };
86
+ return { id: own.id || outer.id || null, name: own['from-name'] || outer['from-name'] || own.from || outer.from || 'Another session', address: own.from || outer.from || '', body };
87
87
  }
88
88
 
89
89
  /** A person's picture: the brand's person placeholder, seeded; an empty seed is its one standard anonymous face. */
@@ -109,12 +109,17 @@ export function withPeople(transcript, messages, { agentName = null, sessionId =
109
109
  const people = transcript.map((entry) => {
110
110
  if (entry.role === 'assistant') return { ...entry, actor: { id: sessionId, ...(agentName ? { name: agentName } : {}), image: agent(sessionId), kind: 'agent' } };
111
111
  if (entry.role !== 'user') return entry;
112
+ const automated = /^\s*<(hook_prompt|supercode-notice)\b[^>]*>([\s\S]*)<\/\1>\s*$/.exec(entry.text);
113
+ // This exact legacy wake string is emitted by mail_route::CODEX_WAKE. Do
114
+ // not infer automation from general wording in a person's own message.
115
+ const legacyWake = entry.text.trim() === 'You have unread supercode messages. Read them with: supercode message inbox';
116
+ if (automated || legacyWake) return { ...entry, role: 'notice', code: 'automation', text: `${automated?.[1] === 'hook_prompt' ? 'Hook' : 'Mailbox'} notification: ${automated ? automated[2].trim() : entry.text.trim()}` };
112
117
  // A harness's delivery notice about a message is a compact system line, not a person's turn.
113
118
  if (/^\s*\[Cross-session [a-z ]*notice\]/i.test(entry.text)) return { ...entry, role: 'notice', text: entry.text.trim().split('\n')[0] };
114
119
  const peer = peerMessage(entry.text);
115
120
  if (peer) {
116
121
  const seed = peer.address.startsWith('sc:') ? peer.address : peer.name;
117
- return { ...entry, text: peer.body, actor: { id: seed, name: peer.name, image: agent(seed), kind: 'agent' }, context: [{ id: `${entry.id}:envelope`, kind: 'envelope', label: 'Delivered by supercode', detail: entry.text }] };
122
+ return { ...entry, text: peer.body, mail: { id: peer.id || entry.id, from: peer.name, to: agentName || 'This session', direction: 'in' }, actor: { id: seed, name: peer.name, image: agent(seed), kind: 'agent' }, context: [{ id: `${entry.id}:envelope`, kind: 'envelope', label: 'Delivered by supercode', detail: entry.text }] };
118
123
  }
119
124
  const metadata = byTime.get(entry.ts) ?? {};
120
125
  const name = metadata.source_name || null;
@@ -132,6 +137,22 @@ export function withPeople(transcript, messages, { agentName = null, sessionId =
132
137
  });
133
138
  }
134
139
 
140
+ /** Overlay real mail on the native transcript without rewriting native messages.
141
+ * Stable envelope IDs deduplicate harness-delivered copies. Neither rendering
142
+ * nor expanding an envelope changes its mailbox state. */
143
+ export function withMail(transcript, rows, { agentName = 'This session' } = {}) {
144
+ const mail = rows.map(row => ({ id: `mail:${row.id}`, role: 'notice', text: row.text, ts: row.at, truncated: false,
145
+ mail: { id: row.id, from: row.fromName || row.from, to: row.direction === 'in' ? agentName : row.to,
146
+ direction: row.direction, inReplyTo: row.inReplyTo } }));
147
+ const result = transcript.filter(entry => !entry.mail || !rows.some(row => row.id === entry.mail.id || row.id.startsWith(entry.mail.id)));
148
+ // Keep native tool/result ordering intact (some native entries have no time).
149
+ for (const entry of mail.sort((a, b) => a.ts - b.ts)) {
150
+ const index = result.findIndex(item => Number.isFinite(item.ts) && item.ts > entry.ts);
151
+ result.splice(index < 0 ? result.length : index, 0, entry);
152
+ }
153
+ return result;
154
+ }
155
+
135
156
  const SOURCES = { 'claude-code': 'claude_code', codex: 'codex', gemini: 'gemini', goose: 'goose', grok: 'grok', opencode: 'opencode', pi: 'pi' };
136
157
 
137
158
  /** A team session's captured messages (their values, oldest first) as the harness SDK's normalized session, for supercode-ui's projection. */