@volter/teams 0.1.34 → 0.1.36

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.
@@ -0,0 +1,59 @@
1
+ /** A tiny event emitter: the channel, its streams and a pane's terminal stream all speak it. */
2
+ export interface ChannelEvents {
3
+ on(event: string, listener: (...args: any[]) => void): this;
4
+ once(event: string, listener: (...args: any[]) => void): this;
5
+ off(event: string, listener: (...args: any[]) => void): this;
6
+ }
7
+
8
+ /** One named stream of a channel (a door on the machine: `mail`, `terminal`, `node`, …). */
9
+ export interface BrowserStream extends ChannelEvents {
10
+ readonly id: number;
11
+ readonly name: string;
12
+ closed: boolean;
13
+ /** Events: `data` (a frame's text), `close` (the reason, or null). */
14
+ write(data: string): void;
15
+ close(reason?: string | null): void;
16
+ }
17
+
18
+ /** A browser's channel to one machine, through its Teams server. Events: `hello` (the machine's greeting), `close`. */
19
+ export declare class BrowserChannel implements ChannelEvents {
20
+ constructor(socket: WebSocket);
21
+ hello: unknown;
22
+ on(event: string, listener: (...args: any[]) => void): this;
23
+ once(event: string, listener: (...args: any[]) => void): this;
24
+ off(event: string, listener: (...args: any[]) => void): this;
25
+ /** Open a named stream; resolves once the machine accepts it, rejects with its refusal. */
26
+ open(name: string, params?: Record<string, unknown>): Promise<BrowserStream>;
27
+ close(): void;
28
+ }
29
+
30
+ export interface ChannelOptions {
31
+ /** Defaults to the browser's WebSocket. */
32
+ WebSocketImpl?: typeof WebSocket;
33
+ }
34
+
35
+ /** Machine `machineId` of team `teamId` on the Teams server at `origin`, reached with the caller's own `token`. */
36
+ export function openBrowserMachine(target: { origin: string; teamId: string; machineId: string; token: string }, options?: ChannelOptions): Promise<BrowserChannel>;
37
+
38
+ /**
39
+ * The machine running session `sessionId` of team `teamId`, reached with a workplace's session token (scope `control`)
40
+ * for a session its owner bound to the token's Room and Conversation: the server finds the machine.
41
+ */
42
+ export function openBrowserSession(target: { origin: string; teamId: string; sessionId: string; token: string }, options?: ChannelOptions): Promise<BrowserChannel>;
43
+
44
+ /** The machine's answer to a typed turn. `waiting`: filed, typed when the session's composer is free. */
45
+ export type BrowserTurnAnswer =
46
+ | { code: 0; turn_id?: string; door?: 'runtime' | 'pane' | 'waiting'; text?: string }
47
+ | { code: number; text?: string };
48
+
49
+ /** One line typed into the channel's session as its user's own turn; rejects when the machine refuses the stream. */
50
+ export function sendBrowserTurn(channel: BrowserChannel, text: string): Promise<BrowserTurnAnswer>;
51
+
52
+ /** A pane's terminal stream. Events: `data` (text), `hello`, `notice`, `error-message`, `close`. */
53
+ export interface BrowserPane extends ChannelEvents {
54
+ write(text: string): void;
55
+ resize(cols: number, rows: number): void;
56
+ close(): void;
57
+ }
58
+
59
+ export function attachBrowserPane(channel: BrowserChannel, options: { pane: string; mode?: 'observe' | 'control'; cols?: number; rows?: number }): Promise<BrowserPane>;
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
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",
7
7
  "exports": {
8
8
  ".": "./index.mjs",
9
9
  "./channel": "./channel.mjs",
10
- "./browser-channel": "./browser-channel.mjs",
10
+ "./browser-channel": {
11
+ "types": "./browser-channel.d.ts",
12
+ "default": "./browser-channel.mjs"
13
+ },
14
+ "./session-follow": {
15
+ "types": "./session-follow.d.ts",
16
+ "default": "./session-follow.mjs"
17
+ },
11
18
  "./admission": "./admission.mjs",
12
19
  "./identity": "./identity.mjs",
13
20
  "./products": "./products.mjs",
@@ -31,6 +38,8 @@
31
38
  "files": [
32
39
  "LICENSE-MIT",
33
40
  "*.mjs",
41
+ "browser-channel.d.ts",
42
+ "session-follow.d.ts",
34
43
  "server",
35
44
  "viewer",
36
45
  "README.md"
package/server/core.mjs CHANGED
@@ -64,8 +64,8 @@ export async function maintain(api){const now=api.clock.nowMs();
64
64
  // they go (a backlog of them filled the database).
65
65
  const parts=await api.store.query(`SELECT p.team_id,p.capture_id,p.part_index FROM capture_parts p JOIN captures c ON c.team_id=p.team_id AND c.id=p.capture_id WHERE c.state='committed' LIMIT ${PART_PRUNE_BATCH}`,[]);
66
66
  for(let at=0;at<parts.length;at+=50)await api.store.transaction(parts.slice(at,at+50).map(({team_id,capture_id,part_index})=>({sql:'DELETE FROM capture_parts WHERE team_id=? AND capture_id=? AND part_index=?',params:[team_id,capture_id,part_index]})));
67
- // Every capture writes its session a new version with a full copy of its messages, so versions pile up (hundreds
68
- // per session) until listing them outgrew a Worker's memory. Kept: each session's current version, the newest
67
+ // Every capture writes its session a new version (its messages are carried, not copied: schema 9), so versions pile
68
+ // up (hundreds per session) until listing them outgrew a Worker's memory. Kept: each session's current version, the newest
69
69
  // version from each of its sources (the next capture and a correlation read that one), and every version an
70
70
  // unexpired list, search or history cursor names. The rest go, a bounded batch per run.
71
71
  const stale=await api.store.query(`SELECT v.team_id,v.session_id,v.revision FROM session_versions v JOIN sessions s ON s.team_id=v.team_id AND s.id=v.session_id
@@ -74,7 +74,12 @@ export async function maintain(api){const now=api.clock.nowMs();
74
74
  AND NOT EXISTS (SELECT 1 FROM cursors c, json_each(c.snapshot_json) j WHERE c.team_id=v.team_id AND c.kind IN ('list','search') AND c.expires_at>? AND json_extract(j.value,'$[0]')=v.session_id AND json_extract(j.value,'$[1]')=v.revision)
75
75
  AND NOT EXISTS (SELECT 1 FROM cursors c WHERE c.team_id=v.team_id AND c.kind='history' AND c.expires_at>? AND json_extract(c.snapshot_json,'$.session_id')=v.session_id AND json_extract(c.snapshot_json,'$.revision')=v.revision)
76
76
  LIMIT ${VERSION_PRUNE_BATCH}`,[now,now]);
77
- for(const {team_id,session_id,revision} of stale)await api.store.transaction(['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]})));
77
+ // A message row lives from the version that wrote it until one that changed it (schema 9), so a row a later version
78
+ // still holds moves to the next version that holds it before its own goes; the rest go with the version.
79
+ for(const {team_id,session_id,revision} of stale)await api.store.transaction([
80
+ {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]},
81
+ ...['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]})),
82
+ ]);
78
83
  await api.store.transaction([
79
84
  {sql:'DELETE FROM cursors WHERE expires_at<=?',params:[now]},
80
85
  {sql:'DELETE FROM global_cursors WHERE expires_at<=?',params:[now]},
@@ -0,0 +1,8 @@
1
+ // Schema migration 009 (SQLite DDL), applied in order by store-core migrate().
2
+ export default `-- Schema 9: a message row is written once and read by every version it belongs to. A row is part of each version
3
+ -- of its session from \`revision\` up to, not including, \`until_revision\`: open (OPEN_REVISION) while the session's
4
+ -- current version still holds it, closed at the revision of the version that changed or dropped it. A row without one
5
+ -- (every row written before schema 9) belongs to its own revision only. A capture writes only the messages that
6
+ -- changed; a version that changes no message writes none.
7
+ ALTER TABLE messages ADD COLUMN until_revision INTEGER;
8
+ `;
@@ -7,4 +7,9 @@ import m005 from './005.mjs';
7
7
  import m006 from './006.mjs';
8
8
  import m007 from './007.mjs';
9
9
  import m008 from './008.mjs';
10
- export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008];
10
+ import m009 from './009.mjs';
11
+ export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
12
+ /** A message row's `until_revision` while its session's current version still holds it (schema 9). */
13
+ export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
14
+ /** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
15
+ export const MESSAGE_AT = (m = 'm') => `${m}.revision<=? AND ?<COALESCE(${m}.until_revision,${m}.revision+1)`;
package/server/viewer.mjs CHANGED
@@ -10,6 +10,8 @@ const FILES = {
10
10
  // The same module under its own name, as a workplace console imports it (openBrowserSession, sendBrowserTurn).
11
11
  '/view/browser-channel.mjs': ['browser-channel.mjs', 'text/javascript; charset=utf-8'],
12
12
  '/view/stream-rpc.mjs': ['stream-rpc.mjs', 'text/javascript; charset=utf-8'],
13
+ // A team session followed live (the viewer's, and a workplace's own drawing of it: @volter/teams/session-follow).
14
+ '/view/session-follow.mjs': ['session-follow.mjs', 'text/javascript; charset=utf-8'],
13
15
  // A team session's surface: supercode-ui's transcript and composer, built by scripts/build-session-view.mjs.
14
16
  '/view/session-view.mjs': ['viewer/session-view.mjs', 'text/javascript; charset=utf-8'],
15
17
  '/view/session-view.css': ['viewer/session-view.css', 'text/css; charset=utf-8'],
@@ -0,0 +1,50 @@
1
+ /** One read of a session: its descriptor, its messages (oldest first), the names of its members and machines. */
2
+ export interface TeamSessionSnapshot {
3
+ descriptor: { id: string; harness: string; title?: string | null; user?: { principal_id?: string | null; reported_user?: string | null }; machine_ids?: string[]; [field: string]: unknown };
4
+ messages: Array<{ id: string; source_id?: string; native_key?: string; recorded_at?: number | null; value: { role: string; content?: unknown; metadata?: Record<string, unknown>; [field: string]: unknown } }>;
5
+ names?: { members?: Array<{ principal?: { id: string; display_name?: string } }>; machines?: Array<{ id: string; name?: string }> };
6
+ working?: unknown;
7
+ event_cursor?: string | number;
8
+ revision?: number;
9
+ [field: string]: unknown;
10
+ }
11
+
12
+ export interface FollowTeamSessionOptions {
13
+ origin: string;
14
+ teamId: string;
15
+ /** The session's id, or its harness's own (`claude-code:<id>`, or the id alone). */
16
+ sessionId: string;
17
+ /** The bearer to use now: a member's credential, or a workplace's delegated token refreshed as it runs out. */
18
+ token(): string | Promise<string>;
19
+ /** How many of the newest messages each read returns (1–500). */
20
+ tail?: number;
21
+ onSnapshot(snapshot: TeamSessionSnapshot): void;
22
+ onLive?(live: boolean): void;
23
+ /** A read or the follow failed; it retries by itself. `snapshot`: whether one was drawn before. */
24
+ onError?(failure: { stage: 'read' | 'follow'; status: number | null; message: string; snapshot?: boolean }): void;
25
+ onDeleted?(): void;
26
+ fetchImpl?: typeof fetch;
27
+ retryMs?: number;
28
+ }
29
+
30
+ /** Follow one session: its snapshot read whole, then its follow stream from that snapshot's cursor, reopened as it drops. */
31
+ export function followTeamSession(options: FollowTeamSessionOptions): { stop(): void; read(): Promise<TeamSessionSnapshot> };
32
+
33
+ /** A message another session sent this one, from its envelope: its sender and body, or null for an ordinary turn. */
34
+ export function peerMessage(text: string): { name: string; address: string; body: string } | null;
35
+
36
+ export function personImage(seed?: string): string;
37
+ export function agentImage(seed: string): string;
38
+
39
+ /**
40
+ * Who wrote each entry of a projected transcript (supercode-ui's `TranscriptEntryModel`s), from the messages' own
41
+ * annotations. Each actor carries `id` (a principal id, a session address or seed) for a host that draws its own faces.
42
+ */
43
+ export function withPeople<Entry extends { role: string; text: string; ts: number | null; id: string }>(
44
+ transcript: Entry[],
45
+ messages: Array<{ role?: string; metadata?: Record<string, unknown> }>,
46
+ options?: { agentName?: string | null; sessionId?: string; personImage?(seed: string): string; agentImage?(seed: string): string; locale?: string | string[] },
47
+ ): Entry[];
48
+
49
+ /** A session's message values as the harness SDK's normalized session, for supercode-ui's projection. */
50
+ export function normalizedSession(descriptor: { id: string; harness: string }, messages: unknown[]): Record<string, unknown>;
@@ -0,0 +1,152 @@
1
+ // A team session, followed live from a browser (or any fetch host), and its messages as a transcript's authors: what
2
+ // the hosted viewer uses, exported so a workplace that draws the session itself (RH2's Conversation, supercode-ui in
3
+ // its own tree) reads and labels it the same way. No imports: it runs as the viewer's own module and in a host's bundle.
4
+
5
+ /**
6
+ * Follow one session: its snapshot read whole (`/messages?tail=`, a busy session's is over the follow stream's 1 MB
7
+ * buffer), then its follow stream from that snapshot's cursor, each change meaning "read again". The stream ends when
8
+ * the token does or the connection drops, and is reopened from where it left off. `token()` returns the bearer to use
9
+ * now (a workplace's delegated token, refreshed as it runs out). Returns `stop()`.
10
+ *
11
+ * onSnapshot(body) each read: { descriptor, messages, names, working, event_cursor, … }
12
+ * onLive(live) the follow stream opened (true) or dropped (false)
13
+ * onError({ stage, status, message }) a read or the follow failed (`stage`: read | follow); it retries by itself
14
+ * onDeleted() the session was deleted; following stops
15
+ */
16
+ export function followTeamSession({ origin, teamId, sessionId, token, tail = 300, onSnapshot, onLive = () => {}, onError = () => {}, onDeleted = () => {}, fetchImpl = globalThis.fetch, retryMs = 2000 }) {
17
+ const base = `${origin}/api/v1/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(sessionId)}`;
18
+ const decoder = new TextDecoder();
19
+ const controller = new AbortController();
20
+ const headers = async () => ({ Authorization: `Bearer ${await token()}` });
21
+ const failure = (stage, status, message) => Object.assign(new Error(message), { stage, status });
22
+ let reading = null;
23
+ const read = () => (reading ??= (async () => {
24
+ try {
25
+ const response = await fetchImpl(`${base}/messages?tail=${tail}`, { headers: await headers(), signal: controller.signal });
26
+ if (!response.ok) throw failure('read', response.status, `reading the session failed (${response.status})`);
27
+ const body = await response.json();
28
+ onSnapshot(body.data ?? body);
29
+ return body.data ?? body;
30
+ } finally { reading = null; }
31
+ })());
32
+ (async () => {
33
+ let cursor = null;
34
+ while (!controller.signal.aborted) {
35
+ try {
36
+ if (!cursor) cursor = String((await read()).event_cursor ?? '') || null;
37
+ const response = await fetchImpl(`${base}/events?${cursor ? `after=${encodeURIComponent(cursor)}` : 'tail=1'}`, { headers: await headers(), signal: controller.signal });
38
+ if (!response.ok) throw failure('follow', response.status, `following the session failed (${response.status})`);
39
+ onLive(true);
40
+ const reader = response.body.getReader();
41
+ let buffer = '';
42
+ for (;;) {
43
+ const { value, done } = await reader.read();
44
+ if (done) break;
45
+ buffer += decoder.decode(value, { stream: true });
46
+ let end;
47
+ while ((end = buffer.indexOf('\n\n')) >= 0) {
48
+ const block = buffer.slice(0, end);
49
+ buffer = buffer.slice(end + 2);
50
+ const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
51
+ if (!data) continue;
52
+ const event = JSON.parse(data);
53
+ if (event.cursor) cursor = event.cursor;
54
+ // The server ends a stream it can no longer resume (a slow reader, a retired cursor): start from a snapshot.
55
+ if (event.terminal && event.type === 'resync_required') cursor = null;
56
+ if (event.type === 'snapshot') onSnapshot(event.data);
57
+ else if (event.type === 'session_changed') read().catch((error) => { if (!controller.signal.aborted) onError({ stage: error.stage ?? 'read', status: error.status ?? null, message: error.message }); });
58
+ else if (event.type === 'session_deleted') { onLive(false); onDeleted(); return; }
59
+ }
60
+ }
61
+ } catch (error) {
62
+ if (controller.signal.aborted) return;
63
+ onError({ stage: error.stage ?? 'follow', status: error.status ?? null, message: error.message, snapshot: Boolean(cursor) });
64
+ }
65
+ onLive(false);
66
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
67
+ }
68
+ })();
69
+ return { stop: () => controller.abort(), read };
70
+ }
71
+
72
+ /**
73
+ * A message another session sent this one, as its harness delivers it: an envelope naming the sender (a
74
+ * `cross-session-message` tag, the innermost one when the harness wraps it in its own) around the body. Returns
75
+ * { name, address, body }, or null for an ordinary turn.
76
+ */
77
+ export function peerMessage(text) {
78
+ const close = text.search(/<\\?\/cross-session-message>/);
79
+ if (close < 0) return null;
80
+ const opens = [...text.slice(0, close).matchAll(/<\\?cross-session-message\b([^>]*)>/g)];
81
+ if (!opens.length) return null;
82
+ const inner = opens[opens.length - 1];
83
+ const attributes = (tag) => Object.fromEntries([...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
84
+ const own = attributes(inner[1]), outer = attributes(opens[0][1]);
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 };
87
+ }
88
+
89
+ /** A person's picture: the brand's person placeholder, seeded; an empty seed is its one standard anonymous face. */
90
+ export const personImage = (seed = '') => `https://brand.volter.ai/runner/svg?seed=${encodeURIComponent(seed)}&size=36&radius=18`;
91
+ /** An agent's picture (this session's own, a peer session's): the brand's agent mark, a rounded square, never a face. */
92
+ export const agentImage = (seed) => `https://brand.volter.ai/agent/svg?seed=${encodeURIComponent(seed)}&size=36&radius=7`;
93
+
94
+ /**
95
+ * Who wrote each entry of a projected transcript (supercode-ui's entries), from the messages' own annotations:
96
+ * this session's turns are its agent's (`agentName`); a peer session's message is that session's, its envelope kept
97
+ * as context; a Room member's turn is theirs (source_name / source_principal_id, recorded when they typed it), anyone
98
+ * unnamed the standard anonymous face. Each actor carries `id` (a principal id, a session address or seed), so a host
99
+ * may draw its own faces; `personImage`/`agentImage` choose the default pictures. One author's run within five minutes
100
+ * is `continued`, and each entry has a short local `timeLabel`.
101
+ */
102
+ export function withPeople(transcript, messages, { agentName = null, sessionId = '', personImage: person = personImage, agentImage: agent = agentImage, locale = [] } = {}) {
103
+ const byTime = new Map();
104
+ for (const message of messages) {
105
+ if (message?.role !== 'user') continue;
106
+ const at = Date.parse(message.metadata?.timestamp ?? '');
107
+ if (Number.isFinite(at)) byTime.set(at, message.metadata);
108
+ }
109
+ const people = transcript.map((entry) => {
110
+ if (entry.role === 'assistant') return { ...entry, actor: { id: sessionId, ...(agentName ? { name: agentName } : {}), image: agent(sessionId), kind: 'agent' } };
111
+ if (entry.role !== 'user') return entry;
112
+ // A harness's delivery notice about a message is a compact system line, not a person's turn.
113
+ if (/^\s*\[Cross-session [a-z ]*notice\]/i.test(entry.text)) return { ...entry, role: 'notice', text: entry.text.trim().split('\n')[0] };
114
+ const peer = peerMessage(entry.text);
115
+ if (peer) {
116
+ 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 }] };
118
+ }
119
+ const metadata = byTime.get(entry.ts) ?? {};
120
+ const name = metadata.source_name || null;
121
+ const seed = name ? (metadata.source_principal_id || name) : '';
122
+ return { ...entry, actor: { ...(seed ? { id: seed } : {}), name: name ?? 'Someone in the session', image: person(seed), kind: 'person' } };
123
+ });
124
+ let last = null;
125
+ return people.map((entry) => {
126
+ if (!entry.actor) return entry;
127
+ const author = `${entry.actor.kind ?? ''}:${entry.actor.name ?? entry.role}`;
128
+ const continued = last !== null && last.author === author && Number.isFinite(entry.ts) && Number.isFinite(last.ts) && entry.ts - last.ts < 5 * 60_000;
129
+ last = { author, ts: entry.ts };
130
+ const timeLabel = Number.isFinite(entry.ts) ? new Date(entry.ts).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }) : undefined;
131
+ return { ...entry, actor: { ...entry.actor, ...(continued ? { continued: true } : {}), ...(timeLabel ? { timeLabel } : {}) } };
132
+ });
133
+ }
134
+
135
+ const SOURCES = { 'claude-code': 'claude_code', codex: 'codex', gemini: 'gemini', goose: 'goose', grok: 'grok', opencode: 'opencode', pi: 'pi' };
136
+
137
+ /** A team session's captured messages (their values, oldest first) as the harness SDK's normalized session, for supercode-ui's projection. */
138
+ export function normalizedSession(descriptor, messages) {
139
+ return {
140
+ source: SOURCES[descriptor.harness] ?? 'native',
141
+ session_id: descriptor.id,
142
+ model: null,
143
+ cwd: null,
144
+ system_prompt: null,
145
+ agent_id: null,
146
+ parent_tool_use_id: null,
147
+ lineage: {},
148
+ trigger: 'human',
149
+ surface: null,
150
+ messages,
151
+ };
152
+ }
@@ -8,82 +8,7 @@ import { Composer } from '@volter/supercode-ui/preact/composer';
8
8
  import { EMPTY_UI_STATE, normalizeUiState } from '@volter/supercode-ui';
9
9
  import { projectSubagentTranscript } from '@volter/supercode-ui/controller';
10
10
 
11
- // A person's picture: the brand's person placeholder. An empty seed is its one standard anonymous face.
12
- const personImage = (seed = '') => `https://brand.volter.ai/runner/svg?seed=${encodeURIComponent(seed)}&size=36&radius=18`;
13
- // An agent's picture (this session's own, a peer session's): the brand's agent mark, a rounded square, never a face.
14
- const agentImage = (seed) => `https://brand.volter.ai/agent/svg?seed=${encodeURIComponent(seed)}&size=36&radius=7`;
15
-
16
- /**
17
- * A message another session sent this one, as its harness delivers it: an envelope naming the sender (a
18
- * `cross-session-message` tag, the innermost one when the harness wraps it in its own) around the body. Returns the
19
- * sender and the body, or null for an ordinary turn.
20
- */
21
- function peerMessage(text) {
22
- const close = text.search(/<\\?\/cross-session-message>/);
23
- if (close < 0) return null;
24
- const opens = [...text.slice(0, close).matchAll(/<\\?cross-session-message\b([^>]*)>/g)];
25
- if (!opens.length) return null;
26
- const inner = opens[opens.length - 1];
27
- const attributes = (tag) => Object.fromEntries([...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
28
- const own = attributes(inner[1]), outer = attributes(opens[0][1]);
29
- const body = text.slice(inner.index + inner[0].length, close).trim();
30
- return { name: own['from-name'] || outer['from-name'] || own.from || outer.from || 'Another session', address: own.from || outer.from || '', body };
31
- }
32
-
33
- /**
34
- * Who wrote each user turn, from the message's own annotations (source_name / source_principal_id, recorded when a
35
- * Room member typed it): their name and face, or, for anyone unnamed, the standard anonymous face. Never an agent mark.
36
- */
37
- function withPeople(transcript, messages, { agentName = null, sessionId = '' } = {}) {
38
- const byTime = new Map();
39
- for (const message of messages) {
40
- if (message?.role !== 'user') continue;
41
- const at = Date.parse(message.metadata?.timestamp ?? '');
42
- if (Number.isFinite(at)) byTime.set(at, message.metadata);
43
- }
44
- const people = transcript.map((entry) => {
45
- // This session's own turns: named as the Room knows its agent, with the agent mark.
46
- if (entry.role === 'assistant') return { ...entry, actor: { ...(agentName ? { name: agentName } : {}), image: agentImage(sessionId), kind: 'agent' } };
47
- if (entry.role !== 'user') return entry;
48
- // A harness's delivery notice about a message is a compact system line, not a person's turn.
49
- if (/^\s*\[Cross-session [a-z ]*notice\]/i.test(entry.text)) return { ...entry, role: 'notice', text: entry.text.trim().split('\n')[0] };
50
- const peer = peerMessage(entry.text);
51
- if (peer) return { ...entry, text: peer.body, actor: { name: peer.name, image: agentImage(peer.address.startsWith('sc:') ? peer.address : peer.name), kind: 'agent' }, context: [{ id: `${entry.id}:envelope`, kind: 'envelope', label: 'Delivered by supercode', detail: entry.text }] };
52
- const metadata = byTime.get(entry.ts) ?? {};
53
- const name = metadata.source_name || null;
54
- return { ...entry, actor: { name: name ?? 'Someone in the session', image: personImage(name ? (metadata.source_principal_id || name) : ''), kind: 'person' } };
55
- });
56
- // A row layout groups one author's run: a message by the same author within five minutes of their last is
57
- // `continued` (no second avatar and name); a tool step does not break the run. Each shows a short local time.
58
- let last = null;
59
- return people.map((entry) => {
60
- if (!entry.actor) return entry;
61
- const author = `${entry.actor.kind ?? ''}:${entry.actor.name ?? entry.role}`;
62
- const continued = last !== null && last.author === author && Number.isFinite(entry.ts) && Number.isFinite(last.ts) && entry.ts - last.ts < 5 * 60_000;
63
- last = { author, ts: entry.ts };
64
- const timeLabel = Number.isFinite(entry.ts) ? new Date(entry.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : undefined;
65
- return { ...entry, actor: { ...entry.actor, ...(continued ? { continued: true } : {}), ...(timeLabel ? { timeLabel } : {}) } };
66
- });
67
- }
68
-
69
- const SOURCES = { 'claude-code': 'claude_code', codex: 'codex', gemini: 'gemini', goose: 'goose', grok: 'grok', opencode: 'opencode', pi: 'pi' };
70
-
71
- /** A team session's captured messages as the harness SDK's normalized session, for supercode-ui's projection. */
72
- function normalizedSession(descriptor, messages) {
73
- return {
74
- source: SOURCES[descriptor.harness] ?? 'native',
75
- session_id: descriptor.id,
76
- model: null,
77
- cwd: null,
78
- system_prompt: null,
79
- agent_id: null,
80
- parent_tool_use_id: null,
81
- lineage: {},
82
- trigger: 'human',
83
- surface: null,
84
- messages,
85
- };
86
- }
11
+ import { withPeople, normalizedSession } from '../session-follow.mjs';
87
12
 
88
13
  /**
89
14
  * Mount one session into `element`. `update({ descriptor, messages, members, machines, live, status, error, sending })`
@@ -350,6 +350,12 @@
350
350
  .scui-progress-step { height:2px; overflow:hidden; border-radius:2px; background:var(--scui-fill) }
351
351
  .scui-progress-step[data-progress="done"] { background:var(--scui-accent) }
352
352
  .scui-progress-step[data-progress="current"]::after { content:""; display:block; width:55%; height:100%; background:var(--scui-accent); animation:scui-progress 1s ease-in-out infinite alternate }
353
+ .scui-suggestions { list-style:none; margin:0 0 calc(6px * var(--scui-scaling)); padding:calc(3px * var(--scui-scaling)); max-height:calc(240px * var(--scui-scaling)); overflow-y:auto; border:1px solid var(--scui-border); border-radius:var(--scui-radius-md); background:var(--scui-bg-raised) }
354
+ .scui-suggestion { display:flex; align-items:center; gap:calc(8px * var(--scui-scaling)); padding:calc(5px * var(--scui-scaling)) calc(8px * var(--scui-scaling)); border-radius:var(--scui-radius-xs); color:var(--scui-fg); font-size:var(--scui-text-sm); cursor:pointer }
355
+ .scui-suggestion[data-active="true"] { background:var(--scui-fill) }
356
+ .scui-suggestion-image { width:20px; height:20px; border-radius:var(--scui-radius-xs); object-fit:cover; flex:0 0 auto }
357
+ .scui-suggestion-label { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
358
+ .scui-suggestion-detail { margin-left:auto; color:var(--scui-muted); font-size:var(--scui-text-xs); white-space:nowrap }
353
359
  .scui-context-candidates { display:flex; gap:calc(5px * var(--scui-scaling)); margin-bottom:calc(6px * var(--scui-scaling)); overflow-x:auto; padding:1px; scrollbar-width:thin }
354
360
  .scui-context-candidate { display:grid; grid-template-columns:auto minmax(0,1fr) auto; flex:0 0 auto; width:min(calc(180px * var(--scui-scaling)),55vw); min-height:calc(34px * var(--scui-scaling)); align-items:center; gap:calc(6px * var(--scui-scaling)); padding:calc(4px * var(--scui-scaling)) calc(6px * var(--scui-scaling)); border:1px solid var(--scui-border); border-radius:var(--scui-radius-md); background:var(--scui-bg); color:var(--scui-fg); text-align:left; cursor:pointer }
355
361
  .scui-context-candidate:hover:not(:disabled) { border-color:var(--scui-border-strong); background:var(--scui-fill) }