@platys/relay 0.0.0-stage → 0.8.0-alpha.12

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.
Files changed (51) hide show
  1. package/README.md +118 -2
  2. package/next/CLAUDE_CHANNEL.md +110 -0
  3. package/next/EASY_SETUP.md +122 -0
  4. package/next/REMOTE_MCP.md +194 -0
  5. package/next/TROUBLESHOOTING.md +72 -0
  6. package/out/core/budget.js +126 -0
  7. package/out/core/collaboration.js +170 -0
  8. package/out/core/companion.js +86 -0
  9. package/out/core/dashboard-page.js +69 -0
  10. package/out/core/dashboard.js +104 -0
  11. package/out/core/remote-connect.js +19 -0
  12. package/out/core/remote-hooks.js +21 -0
  13. package/out/core/remote-mcp.js +258 -0
  14. package/out/core/room.js +272 -0
  15. package/out/core/types.js +52 -0
  16. package/out/core/version.js +1 -0
  17. package/out/node/adapters.js +64 -0
  18. package/out/node/artifacts.js +151 -0
  19. package/out/node/budget-operator.js +212 -0
  20. package/out/node/claude-channel.js +234 -0
  21. package/out/node/cli.js +358 -0
  22. package/out/node/client.js +80 -0
  23. package/out/node/connector.js +171 -0
  24. package/out/node/context.js +59 -0
  25. package/out/node/diagnostics.js +45 -0
  26. package/out/node/git.js +70 -0
  27. package/out/node/hooks.js +113 -0
  28. package/out/node/hosted-connect.js +41 -0
  29. package/out/node/hub.js +101 -0
  30. package/out/node/install.js +64 -0
  31. package/out/node/integration.js +128 -0
  32. package/out/node/maintenance.js +82 -0
  33. package/out/node/network-cli.js +279 -0
  34. package/out/node/network-install.js +56 -0
  35. package/out/node/network-runtime.js +90 -0
  36. package/out/node/network-setup.js +121 -0
  37. package/out/node/network-state.js +61 -0
  38. package/out/node/remote-companion-install.js +44 -0
  39. package/out/node/remote-companion-mcp.js +38 -0
  40. package/out/node/remote-companion.js +192 -0
  41. package/out/node/remote-hooks-install.js +25 -0
  42. package/out/node/remote-setup.js +191 -0
  43. package/out/node/request-budget.js +50 -0
  44. package/out/node/setup-page.js +26 -0
  45. package/out/node/setup-web.js +129 -0
  46. package/out/node/sqlite.js +33 -0
  47. package/out/node/state.js +113 -0
  48. package/out/node/terminal.js +28 -0
  49. package/out/node/tools.js +204 -0
  50. package/out/node/workflow.js +60 -0
  51. package/package.json +65 -4
@@ -0,0 +1,126 @@
1
+ export const MONTHLY_REQUEST_LIMIT = 9_500_000;
2
+ export function billingPeriod(anchor, now) {
3
+ const origin = new Date(anchor);
4
+ if (!Number.isFinite(origin.getTime()))
5
+ throw Error('Invalid billing anchor');
6
+ const date = new Date(now);
7
+ const boundary = (year, month) => Date.UTC(year, month, Math.min(origin.getUTCDate(), new Date(Date.UTC(year, month + 1, 0)).getUTCDate()), origin.getUTCHours(), origin.getUTCMinutes(), origin.getUTCSeconds());
8
+ let year = date.getUTCFullYear(), month = date.getUTCMonth();
9
+ if (now < boundary(year, month)) {
10
+ month--;
11
+ if (month < 0) {
12
+ month = 11;
13
+ year--;
14
+ }
15
+ }
16
+ return { periodStart: new Date(boundary(year, month)).toISOString(), periodEnd: new Date(boundary(year, month + 1)).toISOString() };
17
+ }
18
+ export class SharedBudget {
19
+ sql;
20
+ baseLimit;
21
+ constructor(sql, baseLimit = MONTHLY_REQUEST_LIMIT) {
22
+ this.sql = sql;
23
+ this.baseLimit = baseLimit;
24
+ sql.exec('CREATE TABLE IF NOT EXISTS budget_state (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
25
+ sql.exec('CREATE TABLE IF NOT EXISTS budget_audit (id TEXT PRIMARY KEY, kind TEXT NOT NULL, data TEXT NOT NULL)');
26
+ }
27
+ load() { const r = this.sql.all('SELECT data FROM budget_state WHERE id=1')[0]; return r ? JSON.parse(r.data) : null; }
28
+ save(s) { this.sql.exec('INSERT INTO budget_state VALUES (1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', JSON.stringify(s)); }
29
+ roll(s, now) {
30
+ const p = billingPeriod(s.billingAnchor, now);
31
+ if (p.periodStart > s.periodStart) {
32
+ this.sql.exec('INSERT OR IGNORE INTO budget_audit VALUES (?,?,?)', 'period:' + s.periodStart, 'period', JSON.stringify(s));
33
+ Object.assign(s, p, { used: 0, limit: this.baseLimit, seededUsage: 0 });
34
+ // A threshold pause survives rollover. Only explicit approval clears it.
35
+ }
36
+ }
37
+ latch(s, now) {
38
+ if (!s.paused && s.used >= s.limit) {
39
+ s.paused = true;
40
+ s.pauseId = crypto.randomUUID();
41
+ s.message = `Relay paused at its shared monthly request budget (${s.used}/${s.limit}). Explicit user approval is required to resume.`;
42
+ this.sql.exec('INSERT INTO budget_audit VALUES (?,?,?)', s.pauseId, 'alert', JSON.stringify({ ...s, at: new Date(now).toISOString() }));
43
+ }
44
+ }
45
+ initialize(anchor, used, now = Date.now()) {
46
+ if (!Number.isSafeInteger(used) || used < 0)
47
+ throw Error('Initial usage must be a nonnegative integer');
48
+ return this.sql.transaction(() => {
49
+ if (this.load())
50
+ throw Error('Shared budget already configured; initialization cannot reset usage');
51
+ const s = { configured: true, billingAnchor: new Date(anchor).toISOString(), ...billingPeriod(anchor, now), used, limit: this.baseLimit, paused: false, pauseId: null, epoch: 0, message: 'Shared monthly budget active', seededUsage: used };
52
+ this.latch(s, now);
53
+ this.save(s);
54
+ return s;
55
+ });
56
+ }
57
+ status(now = Date.now()) {
58
+ return this.sql.transaction(() => { const s = this.load(); if (!s)
59
+ return null; this.roll(s, now); this.save(s); return s; });
60
+ }
61
+ take(now = Date.now()) {
62
+ return this.sql.transaction(() => {
63
+ const s = this.load();
64
+ if (!s)
65
+ return { allowed: false, status: null, newlyPaused: false };
66
+ this.roll(s, now);
67
+ const allowed = !s.paused, previous = s.paused;
68
+ // Rejected incoming invocations still count at Cloudflare.
69
+ s.used++;
70
+ this.latch(s, now);
71
+ this.save(s);
72
+ return { allowed, status: s, newlyPaused: !previous && s.paused };
73
+ });
74
+ }
75
+ reconcile(observed, periodStart, now = Date.now()) {
76
+ if (!Number.isSafeInteger(observed) || observed < 0)
77
+ throw Error('Invalid observed usage');
78
+ return this.sql.transaction(() => {
79
+ const s = this.load();
80
+ if (!s)
81
+ throw Error('Configure the shared budget first');
82
+ this.roll(s, now);
83
+ if (s.periodStart !== periodStart)
84
+ throw Error('Usage sample belongs to a different billing period');
85
+ s.used = Math.max(s.used, observed);
86
+ this.latch(s, now);
87
+ this.save(s);
88
+ return s;
89
+ });
90
+ }
91
+ resume(input, now = Date.now()) {
92
+ if (input.approve !== true || !input.reason?.trim() || !input.approvalId)
93
+ throw Error('Explicit user approval and reason are required');
94
+ if (!Number.isSafeInteger(input.extraRequests) || input.extraRequests < 0)
95
+ throw Error('Invalid extra request allowance');
96
+ return this.sql.transaction(() => {
97
+ const prior = this.sql.all('SELECT data FROM budget_audit WHERE id=?', input.approvalId)[0];
98
+ if (prior) {
99
+ const record = JSON.parse(prior.data);
100
+ if (JSON.stringify(record.input) !== JSON.stringify(input))
101
+ throw Error('Approval ID reused with different input');
102
+ return this.load();
103
+ }
104
+ const s = this.load();
105
+ if (!s)
106
+ throw Error('Configure the shared budget first');
107
+ this.roll(s, now);
108
+ if (!s.paused || s.pauseId !== input.pauseId)
109
+ throw Error('Approval does not match the current pause');
110
+ if (s.used >= s.limit && input.extraRequests === 0)
111
+ throw Error('This billing period is exhausted; explicitly approve extra requests or wait for the next period');
112
+ if (s.limit + input.extraRequests > Number.MAX_SAFE_INTEGER)
113
+ throw Error('Allowance is too large');
114
+ s.limit += input.extraRequests;
115
+ if (s.used >= s.limit)
116
+ throw Error('Extra allowance must exceed current usage');
117
+ s.paused = false;
118
+ s.pauseId = null;
119
+ s.epoch++;
120
+ s.message = 'Resumed with explicit user approval';
121
+ this.sql.exec('INSERT INTO budget_audit VALUES (?,?,?)', input.approvalId, 'approval', JSON.stringify({ input, at: new Date(now).toISOString(), status: s }));
122
+ this.save(s);
123
+ return s;
124
+ });
125
+ }
126
+ }
@@ -0,0 +1,170 @@
1
+ import { assert, bounded, hash, id, overlap, paths, stable, redact } from './types.js';
2
+ export class Collaboration {
3
+ sql;
4
+ project;
5
+ constructor(sql, project) {
6
+ this.sql = sql;
7
+ this.project = project;
8
+ sql.exec('CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY,data TEXT NOT NULL)');
9
+ sql.exec('CREATE TABLE IF NOT EXISTS artifacts(id TEXT PRIMARY KEY,data TEXT NOT NULL)');
10
+ sql.exec('CREATE TABLE IF NOT EXISTS artifact_chunks(artifact_id TEXT NOT NULL,part INTEGER NOT NULL,data TEXT NOT NULL,PRIMARY KEY(artifact_id,part))');
11
+ }
12
+ accepted() { const row = this.sql.all("SELECT value FROM meta WHERE key='acceptedRevision'")[0]; return row ? JSON.parse(row.value) : null; }
13
+ integrate(member, input) {
14
+ const session = this.project.own(member, id(input.sessionId));
15
+ assert(session.state !== 'closed', 409, 'Resume this session before changing collaboration state');
16
+ const artifact = this.artifact(input.artifactId);
17
+ assert(artifact.sealed, 409, 'Artifact is not ready');
18
+ if (input.operation === 'reserve') {
19
+ assert(/^[a-f0-9]{40,64}$/.test(input.baseHead), 400, 'Invalid base revision');
20
+ const accepted = this.accepted();
21
+ if (accepted?.id === input.id) {
22
+ assert(accepted.sessionId === session.id && accepted.artifactId === artifact.id, 409, 'Integration identifier reused');
23
+ return accepted;
24
+ }
25
+ assert(!accepted || accepted.head === input.baseHead, 409, 'Accepted revision changed; refresh before integrating');
26
+ this.checkScopes(session.id, artifact.paths, input.baseHead);
27
+ const row = this.sql.all("SELECT value FROM meta WHERE key='integrationLease'")[0];
28
+ const current = row ? JSON.parse(row.value) : null;
29
+ assert(!current || current.expiresAt <= Date.now() || (current.id === input.id && current.sessionId === session.id), 409, 'Another integration is being accepted; retry after its reservation expires');
30
+ const lease = { id: id(input.id), sessionId: session.id, artifactId: artifact.id, baseHead: input.baseHead, expiresAt: Date.now() + 120000 };
31
+ this.sql.exec("INSERT INTO meta VALUES ('integrationLease',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", JSON.stringify(lease));
32
+ return lease;
33
+ }
34
+ assert(/^[a-f0-9]{40,64}$/.test(input.head) && /^[a-f0-9]{40,64}$/.test(input.baseHead), 400, 'Invalid revision');
35
+ assert(session.git.head === input.head && !session.git.dirty, 409, 'Register the accepted clean revision first');
36
+ assert(input.validation?.exitCode === 0 && Array.isArray(input.validation.command) && input.validation.command.length > 0 && /^[a-f0-9]{64}$/.test(input.validation.outputHash), 400, 'Successful local validation evidence required');
37
+ const previous = this.accepted();
38
+ if (previous?.id === input.id) {
39
+ assert(previous.head === input.head && previous.artifactId === input.artifactId, 409, 'Integration identifier reused');
40
+ return previous;
41
+ }
42
+ const leaseRow = this.sql.all("SELECT value FROM meta WHERE key='integrationLease'")[0], lease = leaseRow ? JSON.parse(leaseRow.value) : null;
43
+ assert(lease && lease.id === input.id && lease.sessionId === session.id && lease.artifactId === artifact.id && lease.baseHead === input.baseHead && lease.expiresAt > Date.now(), 409, 'Acquire an integration reservation before publishing acceptance');
44
+ assert(!previous || previous.head === input.baseHead, 409, 'Accepted revision changed; refresh before integrating');
45
+ const record = { id: id(input.id), head: input.head, baseHead: input.baseHead, artifactId: artifact.id, sessionId: session.id, at: new Date().toISOString(), verification: 'Reported by integrating member; server did not execute tests', validation: input.validation };
46
+ assert(JSON.stringify(record).length < 8000, 413, 'Validation evidence too large');
47
+ this.sql.exec("INSERT INTO meta VALUES ('acceptedRevision',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", JSON.stringify(record));
48
+ this.sql.exec("DELETE FROM meta WHERE key='integrationLease'");
49
+ return record;
50
+ }
51
+ archiveTask(member, input) {
52
+ const task = this.task(input.id);
53
+ assert(member.role === 'owner' || this.project.own(member, task.sessionId), 403, 'Only the owner can archive this task');
54
+ assert(['complete', 'released'].includes(task.status) && stable(task) === stable(input.archivedTask), 409, 'Archive the exact completed task version first');
55
+ assert(!this.tasks().some(t => t.id !== task.id && t.dependencies.includes(task.id)), 409, 'Another task references this dependency');
56
+ this.sql.exec('DELETE FROM tasks WHERE id=?', task.id);
57
+ return { archived: true };
58
+ }
59
+ tasks() { return this.sql.all('SELECT data FROM tasks ORDER BY rowid DESC LIMIT 500').map(r => JSON.parse(r.data)); }
60
+ task(taskId) { const row = this.sql.all('SELECT data FROM tasks WHERE id=?', id(taskId))[0]; assert(row, 404, 'Task not found'); return JSON.parse(row.data); }
61
+ checkScopes(sessionId, scope, baseHead) {
62
+ const accepted = this.accepted();
63
+ assert(!accepted || accepted.head === baseHead, 409, 'Starting revision is stale; integrate the accepted revision before reserving or exporting changes');
64
+ const collision = this.tasks().find(t => t.sessionId !== sessionId && ['active', 'blocked'].includes(t.status) && t.expiresAt > Date.now() && scope.some(p => t.paths.some(q => overlap(p, q))));
65
+ assert(!collision, 409, `Scope reserved by task ${collision?.id}; request a handoff or choose other files`);
66
+ }
67
+ mutateTask(member, input) {
68
+ const session = this.project.own(member, id(input.sessionId));
69
+ assert(session.state !== 'closed', 409, 'Resume this session before changing collaboration state');
70
+ return this.sql.transaction(() => {
71
+ const now = Date.now(), existing = input.id ? this.task(input.id) : undefined;
72
+ if (existing) {
73
+ assert(input.version === existing.version, 409, 'Task changed; refresh its version');
74
+ assert(existing.sessionId === session.id || (existing.expiresAt <= now && (input.operation ?? 'claim') === 'claim'), 403, 'Task belongs to another session; ask for a handoff');
75
+ }
76
+ const operation = input.operation ?? 'claim';
77
+ assert(['claim', 'renew', 'handoff', 'complete', 'release', 'block'].includes(operation), 400, 'Invalid task operation');
78
+ assert(existing || operation === 'claim', 400, 'Create a task before updating it');
79
+ const scope = input.paths === undefined ? existing?.paths ?? [] : paths(input.paths);
80
+ assert(scope.length > 0, 400, 'Reserve explicit paths');
81
+ const dependencies = input.dependencies ?? existing?.dependencies ?? [];
82
+ assert(Array.isArray(dependencies) && dependencies.length <= 20, 400, 'At most 20 dependencies');
83
+ for (const dependency of dependencies) {
84
+ assert(dependency !== existing?.id, 400, 'A task cannot depend on itself');
85
+ assert(this.task(id(dependency)).status === 'complete', 409, 'Dependency is not complete');
86
+ }
87
+ let owner = session.id;
88
+ if (operation === 'handoff') {
89
+ assert(existing?.sessionId === session.id, 403, 'Only the current owner can hand off');
90
+ owner = this.project.session(id(input.targetSessionId)).id;
91
+ assert(this.project.session(owner).state !== 'closed', 409, 'Target session is closed');
92
+ }
93
+ const baseHead = input.baseHead ?? existing?.baseHead ?? session.git.head;
94
+ assert(baseHead === null || /^[a-f0-9]{40,64}$/.test(baseHead), 400, 'Invalid base revision');
95
+ if (['claim', 'renew', 'handoff'].includes(operation))
96
+ this.checkScopes(session.id, scope, baseHead);
97
+ const ttl = input.ttlSeconds ?? 900;
98
+ assert(Number.isInteger(ttl) && ttl >= 60 && ttl <= 3600, 400, 'Lease must be 60..3600 seconds');
99
+ if (!existing)
100
+ assert(this.sql.all('SELECT count(*) n FROM tasks')[0].n < 500, 429, 'Task history limit reached; archive completed tasks');
101
+ const task = { id: existing?.id ?? crypto.randomUUID(), sessionId: owner, title: redact(bounded(input.title ?? existing?.title, 300)), paths: scope, dependencies, baseHead, status: operation === 'complete' ? 'complete' : operation === 'release' ? 'released' : operation === 'block' ? 'blocked' : 'active', version: (existing?.version ?? 0) + 1, expiresAt: now + ttl * 1000, updatedAt: new Date(now).toISOString() };
102
+ this.sql.exec('INSERT INTO tasks VALUES (?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', task.id, JSON.stringify(task));
103
+ return task;
104
+ });
105
+ }
106
+ artifact(artifactId) { const row = this.sql.all('SELECT data FROM artifacts WHERE id=?', id(artifactId))[0]; assert(row, 404, 'Artifact not found'); return JSON.parse(row.data); }
107
+ artifacts() { return this.sql.all('SELECT data FROM artifacts ORDER BY rowid DESC LIMIT 100').map(r => JSON.parse(r.data)); }
108
+ chunk(artifactId, part) { const artifact = this.artifact(artifactId); assert(artifact.sealed, 409, 'Artifact upload is incomplete'); assert(Number.isInteger(part) && part >= 0 && part < artifact.chunks, 400, 'Invalid chunk'); return this.sql.all('SELECT data FROM artifact_chunks WHERE artifact_id=? AND part=?', artifactId, part)[0]; }
109
+ async mutateArtifact(member, input) {
110
+ const session = this.project.own(member, id(input.sessionId));
111
+ assert(session.state !== 'closed', 409, 'Resume this session before changing collaboration state');
112
+ if (input.operation === 'begin') {
113
+ const artifactId = id(input.id), source = input.source;
114
+ assert(source?.repository === this.project.project().repository, 409, 'Artifact belongs to a different repository');
115
+ assert(JSON.stringify(source).length < 20000, 413, 'Source metadata too large');
116
+ const scope = paths(input.paths);
117
+ assert(scope.length > 0, 400, 'Select files to export');
118
+ this.checkScopes(session.id, scope, source.head);
119
+ assert(/^[a-f0-9]{64}$/.test(input.sha256), 400, 'Invalid artifact hash');
120
+ assert(Number.isInteger(input.size) && input.size > 0 && input.size <= 2_000_000, 413, 'Artifact limit is 2 MB encoded');
121
+ assert(input.chunks === Math.ceil(input.size / 24000), 400, 'Invalid chunk count');
122
+ const found = this.sql.all('SELECT data FROM artifacts WHERE id=?', artifactId)[0];
123
+ if (found) {
124
+ const previous = JSON.parse(found.data);
125
+ assert(previous.sessionId === session.id && previous.sha256 === input.sha256 && previous.size === input.size, 409, 'Artifact ID reused');
126
+ return previous;
127
+ }
128
+ const entries = this.artifacts();
129
+ assert(entries.length < 100 && entries.reduce((n, a) => n + a.size, 0) + input.size <= 20_000_000, 429, 'Artifact quota reached; archive and prune old artifacts');
130
+ const artifact = { id: artifactId, sessionId: session.id, sha256: input.sha256, size: input.size, chunks: input.chunks, paths: scope, source, summary: redact(bounded(input.summary ?? 'Selected source files', 500)), createdAt: new Date().toISOString(), expiresAt: Date.now() + 7 * 86400000, sealed: false };
131
+ this.sql.exec('INSERT INTO artifacts VALUES (?,?)', artifact.id, JSON.stringify(artifact));
132
+ return artifact;
133
+ }
134
+ const artifact = this.artifact(input.id);
135
+ assert(artifact.sessionId === session.id || member.role === 'owner', 403, 'Artifact belongs to another session');
136
+ if (input.operation === 'abort') {
137
+ assert(!artifact.sealed, 409, 'Sealed artifacts require verified archival before deletion');
138
+ this.sql.transaction(() => { this.sql.exec('DELETE FROM artifact_chunks WHERE artifact_id=?', artifact.id); this.sql.exec('DELETE FROM artifacts WHERE id=?', artifact.id); });
139
+ return { aborted: true };
140
+ }
141
+ if (input.operation === 'delete') {
142
+ const referenced = this.sql.all("SELECT id FROM events WHERE json_extract(data,'$.artifactId')=? AND (json_extract(data,'$.kind')!='artifact' OR (json_extract(data,'$.targetSessionId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM receipts WHERE event_id=events.id AND json_extract(receipts.data,'$.state') IN ('delivered','answered')))) LIMIT 1", artifact.id);
143
+ assert(!referenced.length && this.accepted()?.artifactId !== artifact.id, 409, 'Artifact is still referenced by evidence or unresolved delivery');
144
+ assert(input.archivedHash === artifact.sha256, 409, 'Verify an archived copy before pruning');
145
+ this.sql.transaction(() => { this.sql.exec('DELETE FROM artifact_chunks WHERE artifact_id=?', artifact.id); this.sql.exec('DELETE FROM artifacts WHERE id=?', artifact.id); });
146
+ return { deleted: true };
147
+ }
148
+ assert(artifact.sessionId === session.id, 403, 'Only the exporting session can upload');
149
+ if (input.operation === 'chunk') {
150
+ const part = input.part;
151
+ assert(Number.isInteger(part) && part >= 0 && part < artifact.chunks, 400, 'Invalid chunk');
152
+ const data = bounded(input.data, 24000);
153
+ const prior = this.sql.all('SELECT data FROM artifact_chunks WHERE artifact_id=? AND part=?', artifact.id, part)[0];
154
+ if (prior) {
155
+ assert(prior.data === data, 409, 'Chunk differs from prior upload');
156
+ return { stored: true };
157
+ }
158
+ assert(!artifact.sealed, 409, 'Sealed artifacts are immutable');
159
+ this.sql.exec('INSERT INTO artifact_chunks VALUES (?,?,?)', artifact.id, part, data);
160
+ return { stored: true };
161
+ }
162
+ assert(input.operation === 'seal', 400, 'Invalid artifact operation');
163
+ const chunks = this.sql.all('SELECT data FROM artifact_chunks WHERE artifact_id=? ORDER BY part', artifact.id);
164
+ const payload = chunks.map(c => c.data).join('');
165
+ assert(chunks.length === artifact.chunks && payload.length === artifact.size && await hash(payload) === artifact.sha256, 409, 'Incomplete or corrupt artifact');
166
+ artifact.sealed = true;
167
+ this.sql.exec('UPDATE artifacts SET data=? WHERE id=?', JSON.stringify(artifact), artifact.id);
168
+ return artifact;
169
+ }
170
+ }
@@ -0,0 +1,86 @@
1
+ import { assert, id, RelayError } from './types.js';
2
+ /** A delivery companion has membership access, never an agent's conversation key. */
3
+ export class Companion {
4
+ room;
5
+ sql;
6
+ constructor(room, sql) {
7
+ this.room = room;
8
+ this.sql = sql;
9
+ sql.exec('CREATE TABLE IF NOT EXISTS companion_attempts (event_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, attempt_id TEXT NOT NULL, state TEXT NOT NULL)');
10
+ }
11
+ call(member, input) {
12
+ assert(this.room.project().mode === 'network', 403, 'Companions require a Relay network');
13
+ if (input.operation === 'read') {
14
+ return { sessions: this.sql.all('SELECT session_id,source_id FROM mcp_hook_bindings WHERE member_id=?', member.id).flatMap(binding => {
15
+ const session = this.room.own(member, binding.session_id);
16
+ if (session.provider !== 'codex' || session.state === 'closed' || session.state === 'blocked')
17
+ return [];
18
+ const pending = this.sql.all("SELECT e.id FROM events e LEFT JOIN receipts r ON r.event_id=e.id AND r.session_id=? LEFT JOIN companion_attempts a ON a.event_id=e.id WHERE json_extract(e.data,'$.targetSessionId')=? AND COALESCE(json_extract(r.data,'$.state'),'queued') NOT IN ('delivered','answered') AND a.event_id IS NULL ORDER BY e.seq LIMIT 20", session.id, session.id);
19
+ return [{ sessionId: session.id, nativeSessionId: binding.source_id, eventIds: pending.map(e => e.id) }];
20
+ }) };
21
+ }
22
+ assert(input.operation === 'claim' || input.operation === 'finish', 400, 'Unknown companion operation');
23
+ const session = this.room.own(member, id(input.sessionId)), event = this.room.event(id(input.eventId)), attemptId = id(input.attemptId);
24
+ assert(session.provider === 'codex' && session.state !== 'closed' && session.state !== 'blocked', 409, 'Session cannot receive native messages');
25
+ assert(event.targetSessionId === session.id, 403, 'Message belongs to another conversation');
26
+ const binding = this.sql.all('SELECT source_id FROM mcp_hook_bindings WHERE session_id=? AND member_id=?', session.id, member.id)[0];
27
+ assert(binding, 409, 'Resume this session once to attach its Relay hooks');
28
+ if (input.operation === 'claim')
29
+ return this.sql.transaction(() => {
30
+ const row = this.sql.all('SELECT data FROM receipts WHERE event_id=? AND session_id=?', event.id, session.id)[0];
31
+ const receipt = row ? JSON.parse(row.data) : undefined;
32
+ if (receipt && ['delivered', 'answered'].includes(receipt.state))
33
+ return { claimed: false };
34
+ if (this.sql.all('SELECT event_id FROM companion_attempts WHERE event_id=?', event.id).length)
35
+ return { claimed: false };
36
+ // Persist before invoking the native client. An ambiguous crash must not retry blindly.
37
+ this.sql.exec('INSERT INTO companion_attempts VALUES (?,?,?,?)', event.id, session.id, attemptId, 'attempting');
38
+ this.room.receipt(member, { eventId: event.id, sessionId: session.id, state: 'uncertain', detail: 'Native handoff started; waiting for client confirmation. Hooks retain the message if the companion disconnects.', updatedAt: '' });
39
+ return { claimed: true, nativeSessionId: binding.source_id };
40
+ });
41
+ assert(input.state === 'accepted' || input.state === 'uncertain', 400, 'A companion cannot claim the agent read or answered');
42
+ const attempt = this.sql.all('SELECT attempt_id,state FROM companion_attempts WHERE event_id=? AND session_id=?', event.id, session.id)[0];
43
+ assert(attempt?.attempt_id === attemptId, 409, 'Delivery attempt does not match');
44
+ assert(attempt.state === 'attempting' || attempt.state === input.state, 409, 'Delivery outcome already recorded');
45
+ this.sql.exec('UPDATE companion_attempts SET state=? WHERE event_id=?', input.state, event.id);
46
+ this.room.receipt(member, { eventId: event.id, sessionId: session.id, state: input.state, detail: input.state === 'accepted' ? 'Codex accepted the follow-up; it runs now if idle or after its current turn' : 'Native delivery outcome unknown; automatic retry withheld. The message remains available through Relay hooks.', updatedAt: '' });
47
+ return { recorded: true };
48
+ }
49
+ }
50
+ export async function routeCompanion(companion, room, request) {
51
+ const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
52
+ try {
53
+ assert(!request.headers.has('Origin'), 403, 'Browser origins are not allowed');
54
+ assert(request.method === 'POST', 405, 'Use POST');
55
+ assert(request.headers.get('content-type')?.split(';')[0] === 'application/json', 415, 'Use application/json');
56
+ const member = await room.authenticate(request.headers.get('authorization')?.replace(/^Bearer /i, '') ?? '');
57
+ const reader = request.body?.getReader();
58
+ let size = 0, text = '';
59
+ const decoder = new TextDecoder();
60
+ if (reader)
61
+ try {
62
+ while (true) {
63
+ const chunk = await reader.read();
64
+ if (chunk.done)
65
+ break;
66
+ size += chunk.value.length;
67
+ if (size > 4000) {
68
+ await reader.cancel();
69
+ throw new RelayError(413, 'Request too large');
70
+ }
71
+ text += decoder.decode(chunk.value, { stream: true });
72
+ }
73
+ text += decoder.decode();
74
+ }
75
+ finally {
76
+ reader.releaseLock();
77
+ }
78
+ const input = JSON.parse(text);
79
+ assert(input && typeof input === 'object' && !Array.isArray(input), 400, 'Invalid request');
80
+ return Response.json(companion.call(member, input), { headers: { ...headers, ...(input.operation === 'finish' ? { 'X-Relay-Changed': '1' } : {}) } });
81
+ }
82
+ catch (error) {
83
+ const status = error instanceof RelayError ? error.status : error instanceof SyntaxError ? 400 : 500;
84
+ return Response.json({ error: status === 500 ? 'Companion request failed' : error.message }, { status, headers });
85
+ }
86
+ }
@@ -0,0 +1,69 @@
1
+ import { codexRemoteHooks } from './remote-hooks.js';
2
+ export function dashboardPage() {
3
+ const nonce = crypto.randomUUID();
4
+ return new Response(String.raw `<!doctype html>
5
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Relay — Shared room</title>
6
+ <style nonce="${nonce}">
7
+ :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#252b2a;background:#fff;font-size:14px;--line:#e7eae6;--muted:#77817a;--green:#285b45;--wash:#f5f7f3}*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.45;cursor:default}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:3px solid #b5d2c1;outline-offset:3px}button{border:1px solid var(--line);border-radius:9px;padding:9px 13px;background:#fff;color:inherit;font-weight:550}button:hover:not(:disabled){background:var(--wash)}.primary{background:var(--green);color:#fff;border-color:var(--green)}.primary:hover:not(:disabled){background:#194b36}.quiet{border:0;background:transparent;color:var(--muted)}.small{font-size:12px}.muted{color:var(--muted)}[hidden]{display:none!important}.brand{display:flex;align-items:center;gap:11px;font-size:21px;font-weight:650;letter-spacing:-.8px}.mark{width:30px;height:30px;background:var(--green);border-radius:9px;display:grid;place-items:center;color:#fff;font-size:22px;line-height:1}.shell{display:grid;grid-template-columns:220px minmax(0,1fr);min-height:100vh}.sidebar{background:#f7f8f5;border-right:1px solid var(--line);padding:30px 20px;display:flex;flex-direction:column;gap:28px;height:100vh;position:sticky;top:0}.eyebrow{font-size:10px;font-weight:700;letter-spacing:1.6px;color:#8a918c;text-transform:uppercase;margin:0 0 12px}.room-label{font-size:13px;font-weight:600;overflow-wrap:anywhere}.nav{display:grid;gap:6px}.nav button{border:0;background:transparent;text-align:left;padding:12px;display:flex;gap:10px;align-items:center;color:#626b64}.nav button.active{background:#e8eee5;color:#274e38}.nav-icon{font-size:17px}.sidebar-note{font-size:12px;line-height:1.75;color:#8b938c;margin-top:auto}.profile{border-top:1px solid var(--line);padding-top:18px;display:flex;align-items:center;gap:10px}.avatar{background:#e9e9e1;color:#6c7465;border-radius:50%;width:32px;height:32px;display:grid;place-items:center;font-size:12px;font-weight:600;flex-shrink:0}.profile-name{font-size:12px;font-weight:600}.profile button{margin-left:auto;padding:4px;font-size:17px}.workspace{min-width:0}.topbar{height:79px;border-bottom:1px solid var(--line);padding:0 34px;display:flex;justify-content:space-between;align-items:center;gap:15px}.breadcrumb{display:flex;gap:12px;color:#8b938c;font-size:12px}.breadcrumb strong{color:#444e46;font-weight:500}.top-actions{display:flex;align-items:center;gap:14px}.connection{display:flex;align-items:center;gap:7px;font-size:12px;color:#7b847d;white-space:nowrap}.dot{width:6px;height:6px;border-radius:50%;background:#b9bdb8;display:inline-block}.dot.live{background:#558b60;box-shadow:0 0 0 3px #edf5eb}.layout{display:grid;grid-template-columns:minmax(0,1fr) 276px;min-height:calc(100vh - 79px)}.main{padding:35px 34px;min-width:0;max-width:1300px;width:100%;margin:auto}.heading{display:flex;align-items:flex-end;justify-content:space-between;gap:15px;margin-bottom:29px}.heading h1{font-size:29px;letter-spacing:-1px;font-weight:580;margin:0 0 9px}.heading p{font-size:13px;line-height:1.6;margin:0;color:var(--muted)}.section-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:25px 0 13px}.section-head h2{margin:0;font-size:13px;font-weight:600}.count{border:1px solid var(--line);border-radius:5px;padding:1px 6px;margin-left:7px;color:#8a928b;font-size:11px}.head-controls{display:flex;align-items:center;gap:12px}.filter{border:1px solid var(--line);border-radius:8px;padding:9px 11px;width:190px;font-size:12px;color:#556057;min-width:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.agent{display:flex;flex-direction:column;align-items:stretch;text-align:left;border:1px solid var(--line);border-radius:12px;padding:17px;background:#fff;min-height:153px;font-weight:400;min-width:0}.agent.selected{border-color:#89a78c;background:#fafcf8;box-shadow:0 0 0 1px #89a78c}.agent-top{display:flex;align-items:center;gap:9px}.provider{width:28px;height:28px;border-radius:8px;background:#eff0ee;display:grid;place-items:center;font-size:16px;color:#4a534c;flex-shrink:0}.provider.claude{background:#f8eee8;color:#c17e58}.owner{font-size:12px;font-weight:650;overflow:hidden;text-overflow:ellipsis}.agent-status{margin-left:auto;font-size:10px;color:#899287;display:flex;align-items:center;gap:4px;white-space:nowrap}.task{font-size:13px;line-height:1.6;margin:14px 0 16px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.agent-bottom{margin-top:auto;display:flex;justify-content:space-between;gap:8px;font-size:10px;color:#949b95}.workspace-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65%}.feed-wrap{border:1px solid var(--line);border-radius:13px;overflow:hidden;background:white}.feed-head{padding:15px 19px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px}.feed-head strong{font-weight:600}.feed{height:360px;overflow:auto;padding:5px 21px;scroll-behavior:auto}.message{padding:20px 0;border-bottom:1px solid #f0f2ef}.message:last-child{border:0}.message-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:#8d948f}.message-name{font-weight:650;color:#47534a}.message time{margin-left:auto;font-size:10px}.message-text{font-size:13px;line-height:1.75;white-space:pre-wrap;overflow-wrap:anywhere;color:#455148;margin:10px 0}.tag{font-size:10px;color:#768276;border:1px solid #e2e8df;border-radius:5px;padding:3px 6px;background:#f8faf5}.tag.answered{color:#42744d;background:#edf5e9;border-color:#e1ebdc}.tag.queued{color:#9c875e;background:#fbf8ee;border-color:#eee8d4}.message-foot{display:flex;align-items:center;gap:8px}.reply-context{font-size:11px;color:#829080;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.empty{text-align:center;padding:39px 24px;color:#89928b;font-size:12px;line-height:1.8}.empty strong{display:block;font-size:14px;color:#566159;margin:9px 0}.empty-icon{font-size:25px;color:#a3b09d}.composer{border-top:1px solid var(--line);padding:14px 17px;background:#fafbf8}.compose-top{display:flex;align-items:center;gap:8px;font-size:11px;color:#85917f}.compose-top select{border:0;background:transparent;font-size:11px;color:#4e6350;max-width:85%;padding:4px}.composer textarea{display:block;width:100%;border:0;resize:vertical;min-height:65px;max-height:240px;padding:11px 0;background:transparent;font-size:13px;line-height:1.6;color:#455148;outline-offset:0}.compose-bottom{display:flex;align-items:center;justify-content:space-between;gap:14px}.compose-bottom span{font-size:10px;line-height:1.6;color:#92998f;max-width:75%}.composer button{padding:7px 15px;font-size:12px}.rail{border-left:1px solid var(--line);padding:35px 24px;min-width:0}.rail h3{font-size:12px;font-weight:600;margin:0 0 19px}.person{display:flex;gap:10px;align-items:center;margin:16px 0}.person .avatar{width:29px;height:29px;font-size:10px}.person strong{display:block;font-size:11px;font-weight:550}.person small{font-size:10px;color:#959c95}.rail-section{padding-bottom:25px;margin-bottom:25px;border-bottom:1px solid var(--line)}.detail-title{font-size:13px;line-height:1.6;margin:14px 0}.detail-label{font-size:10px;color:#9aa198;margin:16px 0 5px}.detail-value{font-size:11px;line-height:1.65;overflow-wrap:anywhere;color:#697564}.rail-note{font-size:11px;color:#969e94;line-height:1.9}.banner{margin:15px 34px 0;border:1px solid #ede2c8;border-radius:8px;background:#fffcf4;padding:12px 15px;font-size:12px;line-height:1.6;color:#8e7443}.login{max-width:440px;margin:14vh auto;padding:25px}.login h1{font-size:34px;font-weight:550;letter-spacing:-1px;margin-top:40px}.login p{color:var(--muted);line-height:1.8}.login label{font-size:12px;display:block;margin:25px 0 8px}.login textarea{width:100%;border:1px solid var(--line);border-radius:10px;padding:14px;font-size:12px;min-height:100px}.login button{width:100%;margin-top:14px}dialog{border:1px solid var(--line);border-radius:16px;padding:28px;max-width:500px;width:calc(100% - 32px);box-shadow:0 18px 90px #2235291a}dialog::backdrop{background:#26372b55;backdrop-filter:blur(3px)}.dialog-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px}.dialog-head h2{font-size:20px;letter-spacing:-.5px;margin:0;font-weight:550}dialog p{font-size:13px;line-height:1.8;color:var(--muted)}dialog textarea{width:100%;min-height:92px;border:1px solid var(--line);border-radius:8px;padding:12px;font-size:11px;resize:vertical}dialog pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;line-height:1.7;background:var(--wash);padding:13px;border-radius:8px}dialog label{display:block;font-size:11px;margin:16px 0 8px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}.loading{opacity:.55}.footnote{text-align:center;color:#adb2a8;font-size:10px;margin:20px 0 0}.history{border:0;border-bottom:1px solid var(--line);border-radius:0;width:100%;padding:8px;font-size:11px;color:#8b9788;background:#fcfdfb}.tiny-action{padding:2px 5px;font-size:10px;border:0;background:transparent;color:#6c8369}.closed-control{font-size:11px;color:#8b9488;display:flex;align-items:center;gap:5px}.agent.closed{opacity:.6}.mobile-brand,.mobile-nav{display:none}
8
+ @media(min-width:1550px){.main{padding:45px 48px}.cards{grid-template-columns:repeat(3,minmax(0,1fr))}.feed{height:430px}}
9
+ @media(max-width:1150px){.layout{grid-template-columns:minmax(0,1fr)}.rail{display:none}.main{max-width:950px}.shell{grid-template-columns:195px minmax(0,1fr)}}
10
+ @media(max-width:700px){.mobile-nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.mobile-nav select{padding:7px 0;border:0;background:transparent;color:#7a8678;font-size:12px}.mobile-nav button{font-size:11px}.shell{display:block}.sidebar{display:none}.topbar{height:65px;padding:0 18px}.breadcrumb{display:none}.mobile-brand{display:flex}.brand{font-size:19px}.mark{width:27px;height:27px}.top-actions{gap:9px}.top-actions button{padding:7px 9px;font-size:11px}.main{padding:26px 17px}.heading h1{font-size:25px}.heading p{font-size:12px}.heading{display:block;margin-bottom:20px}.heading button{margin-top:13px}.section-head{flex-wrap:wrap}.filter{width:140px}.cards{grid-template-columns:1fr}.agent{min-height:132px}.agent-bottom{font-size:11px}.task{margin:11px 0}.feed{height:360px;padding:0 14px}.feed-head{padding:13px}.message time{margin-left:0}.banner{margin:12px 17px}.connection{font-size:10px}.compose-bottom span{max-width:70%}.composer{padding:12px}.footnote{line-height:1.7}.login{margin:6vh auto}.login h1{font-size:30px}}
11
+ </style></head><body>
12
+ <section id="login" class="login" hidden><div class="brand"><span class="mark">↗</span>relay</div><h1>Work together,<br>wherever you work.</h1><p>Open your shared room to see your team's agents and keep the conversation moving.</p><form id="login-form"><label for="connection-link">Your private room link</label><textarea id="connection-link" required placeholder="Paste your Relay room link" autocomplete="off" spellcheck="false"></textarea><button class="primary">Open room</button></form><p id="login-error" role="alert"></p><p class="small">Joining a friend? Open their invitation first. Your room link is private and connects you as yourself.</p></section>
13
+ <div id="shell" class="shell" hidden><aside class="sidebar"><div class="brand"><span class="mark">↗</span>relay</div><div><p class="eyebrow">Shared room</p><div id="room-name" class="room-label">Loading your room…</div></div><nav class="nav" aria-label="Room views"><button id="nav-room" class="active"><span class="nav-icon">▦</span> Sessions</button><button id="nav-mine"><span class="nav-icon">↗</span> My conversations</button></nav><div class="sidebar-note">Different workspaces.<br>A shared conversation.<br><br>Bring your agents together without moving your work.</div><button id="sidebar-invite">+ Invite a friend</button><div class="profile"><div id="self-avatar" class="avatar">·</div><div><div id="self-name" class="profile-name">Connecting</div><span id="self-role" class="small muted"></span></div><button id="signout" class="quiet" aria-label="Sign out" title="Sign out">↪</button></div></aside>
14
+ <div class="workspace"><header class="topbar"><div class="breadcrumb">Your workspace <span>/</span><strong>Shared room</strong></div><div class="brand mobile-brand"><span class="mark">↗</span>relay</div><div class="top-actions"><span class="connection"><i id="connection-dot" class="dot"></i><span id="connection-state">Connecting</span></span><button id="reconnect" hidden>Reconnect</button><button id="connect-agent">Connect an agent</button></div></header><div id="banner" class="banner" role="status" hidden></div><div class="layout"><main class="main"><div class="mobile-nav"><select id="mobile-view" aria-label="Room view"><option value="all">All sessions</option><option value="mine">My conversations</option></select><button id="mobile-signout" class="quiet">Sign out</button></div><div class="heading"><div><p class="eyebrow">Your team, in sync</p><h1 id="title">A shared place to work.</h1><p>See what everyone's working on. Ask the agent that knows.</p></div><button id="invite" class="primary">+ Invite a friend</button></div>
15
+ <div class="section-head"><h2>Agent sessions <span id="agent-count" class="count">0</span></h2><div class="head-controls"><label class="closed-control"><input id="show-closed" type="checkbox"> Closed</label><input id="search" class="filter" aria-label="Find an agent" placeholder="Find an agent…"></div></div><div id="cards" class="cards"></div>
16
+ <div class="section-head"><h2 id="activity-title">Room conversation</h2><button id="clear-selection" class="tiny-action" hidden>Show all sessions ×</button></div><section class="feed-wrap" aria-label="Shared conversation"><div class="feed-head"><strong id="feed-title">Shared activity</strong><span class="muted small" id="feed-subtitle">Questions, answers & context</span></div><button id="older" class="history" hidden>Load earlier messages</button><div id="feed" class="feed" role="log" aria-label="Conversation messages" aria-live="polite" aria-relevant="additions"></div><form id="composer" class="composer"><div class="compose-top"><span id="compose-label">To</span><select id="target" aria-label="Message recipient"><option value="">Choose an agent</option></select><button id="cancel-reply" type="button" class="tiny-action" hidden>Cancel reply</button></div><textarea id="message" aria-label="Message to agent" placeholder="Ask a question or share useful context…" maxlength="8000" required></textarea><div class="compose-bottom"><span id="send-hint">The agent receives your message when it next checks Relay.</span><button id="send" class="primary" disabled>Send ↗</button></div></form></section><p class="footnote">Only context shared through Relay appears here. Agent activity reflects the last Relay check-in.</p></main>
17
+ <aside class="rail"><div class="rail-section"><h3>People in this room</h3><div id="people"></div></div><div id="details" class="rail-section"><h3>A little context goes a long way.</h3><p class="rail-note">Select an agent to see its task and workspace, then start a conversation.</p></div><p class="rail-note">A green dot means recent Relay activity. Messages stay queued until the recipient checks in.</p></aside></div></div></div>
18
+ <dialog id="modal" aria-labelledby="modal-title"><div class="dialog-head"><h2 id="modal-title"></h2><button id="modal-close" class="quiet" aria-label="Close dialog">×</button></div><div id="modal-body"></div></dialog>
19
+ <script nonce="${nonce}">
20
+ const $=id=>document.getElementById(id),make=(tag,cls,txt)=>{const n=document.createElement(tag);if(cls)n.className=cls;if(txt!==undefined)n.textContent=txt;return n;};
21
+ let auth=null,data=null,events=new Map(),receipts=new Map(),selected='',mine=false,cursor=0,oldest=0,older=false,socket=null,connecting=false,retry=0,timer=null,refreshTimer=null,heartbeat=null,stopped=false,loading=false,again=false,replyTo=null,pending=null,sentNoticeId=null;
22
+ const initials=name=>String(name||'?').split(/\s+/).slice(0,2).map(x=>x[0]).join('').toUpperCase();
23
+ const ago=value=>{const n=Math.max(0,Math.floor((Date.now()-Date.parse(value))/1000));return n<60?'just now':n<3600?Math.floor(n/60)+'m ago':n<86400?Math.floor(n/3600)+'h ago':new Date(value).toLocaleDateString();};
24
+ const recent=s=>s.state!=='closed'&&Date.now()-Date.parse(s.updatedAt)<90000;
25
+ const who=id=>{const s=data?.sessions.find(s=>s.id===id);return s?(data.members.find(m=>m.id===s.memberId)?.name||s.name)+(s.provider==='human'?'':' · '+(s.provider==='claude'?'Claude':'Codex')):'Earlier session';};
26
+ function notice(text){sentNoticeId=null;$('banner').textContent=text;$('banner').hidden=!text;}
27
+ function connection(text,live=false){$('connection-state').textContent=text;$('connection-dot').className='dot'+(live?' live':'');}
28
+ function stop(){stopped=true;clearTimeout(timer);clearTimeout(refreshTimer);clearInterval(heartbeat);if(socket){socket.onclose=null;socket.close();socket=null;}}
29
+ function parseLink(raw){const u=new URL(raw,location.origin);if(u.origin!==location.origin)throw Error('Use a room link from this Relay service.');const x=JSON.parse(decodeURIComponent(u.hash.slice(1)));if(!/^[a-zA-Z0-9_-]{1,100}$/.test(x.projectId)||!/^[a-f0-9]{64}$/.test(x.token))throw Error('The room link is incomplete.');return {projectId:x.projectId,token:x.token};}
30
+ async function api(body){const expected=auth;if(!expected)throw Error('Open your room to connect.');const response=await fetch('/projects/'+expected.projectId+'/dashboard',{method:'POST',headers:{Authorization:'Bearer '+expected.token,'Content-Type':'application/json'},body:JSON.stringify(body)});let result;try{result=await response.json();}catch{throw Error('Relay is temporarily unavailable.');}if(auth!==expected)throw Error('Room connection changed.');if(!response.ok){if(response.status===401){stop();connection('Access unavailable');$('send').disabled=true;}if(response.status===429){stop();connection('Relay paused');$('reconnect').hidden=false;}throw Error(result.error||'Could not complete the request.');}return result;}
31
+ async function sync(before){const expected=auth;if(loading){again=true;return;}loading=true;try{let after=before===undefined&&data?cursor:undefined;do{const page=await api({operation:'read',...(before!==undefined?{before}:after!==undefined?{after}:{})});if(auth!==expected)return;data=page;for(const e of page.events)events.set(e.id,e);for(const r of page.receipts)receipts.set(r.eventId,r);if(before!==undefined||after===undefined)older=page.more;cursor=Math.max(cursor,page.cursor);if(page.oldest)oldest=oldest?Math.min(oldest,page.oldest):page.oldest;render();if(after===undefined||!page.more)break;after=page.cursor;}while(true);}catch(error){if(auth===expected)notice(error.message);}finally{if(auth===expected){loading=false;if(again){again=false;refreshTimer=setTimeout(()=>sync(),500);}}}}
32
+ async function live(){if(stopped||connecting||socket||document.hidden)return;connecting=true;const expected=auth;try{const ticket=await api({operation:'ticket'});if(stopped||document.hidden||auth!==expected)return;socket=new WebSocket(location.origin.replace(/^http/,'ws')+'/projects/'+auth.projectId+'/live?ticket='+encodeURIComponent(ticket.ticket));socket.onopen=()=>{retry=0;connection('Live',true);$('reconnect').hidden=true;heartbeat=setInterval(()=>{if(socket?.readyState===1)socket.send('ping');},30000);};socket.onmessage=event=>{if(event.data==='pong')return;const msg=JSON.parse(event.data);if(msg.type==='budget_paused'){stop();connection('Relay paused');notice(msg.budget?.message||'The shared usage budget is paused.');$('reconnect').hidden=false;}else if(msg.type==='changed'){clearTimeout(refreshTimer);refreshTimer=setTimeout(()=>sync(),250);}};socket.onclose=event=>{clearInterval(heartbeat);socket=null;connection('Disconnected');if(event.code===4009||event.code===1008){stop();notice(event.code===1008?'Your room access has been revoked.':'The shared usage budget is paused.');}schedule();};socket.onerror=()=>connection('Reconnecting');}catch(error){if(auth===expected){notice(error.message);schedule();}}finally{if(auth===expected)connecting=false;}}
33
+ function schedule(){if(stopped||document.hidden)return;if(++retry>5){$('reconnect').hidden=false;connection('Offline');return;}timer=setTimeout(()=>live(),Math.min(30000,1000*2**retry));}
34
+ function empty(parent,title,body){const box=make('div','empty');box.append(make('div','empty-icon','↗'),make('strong','',title),make('div','',body));parent.append(box);}
35
+ function render(){if(!data)return;$('mobile-view').value=mine?'mine':'all';if(sentNoticeId){const state=receipts.get(sentNoticeId)?.state;$('banner').textContent=state==='answered'?'Your question was answered.':state==='delivered'?'The agent has acknowledged your message.':state==='accepted'?'Your message was returned to the agent client.':'Message queued. The agent will receive it on its next Relay check.';}$('room-name').textContent=data.project.name;$('title').textContent=mine?'Your conversations.':'A shared place to work.';$('self-name').textContent=data.member.name;$('self-avatar').textContent=initials(data.member.name);$('self-role').textContent=data.member.role;$('invite').hidden=$('sidebar-invite').hidden=data.member.role==='member';$('nav-room').classList.toggle('active',!mine);$('nav-mine').classList.toggle('active',mine);
36
+ const agents=data.sessions.filter(s=>s.provider!=='human'),opened=agents.filter(s=>s.state!=='closed');$('agent-count').textContent=String(opened.length);
37
+ const search=$('search').value.toLowerCase(),shown=agents.filter(s=>($('show-closed').checked||s.state!=='closed')&&(!mine||s.memberId===data.member.id)).filter(s=>(who(s.id)+' '+s.task+' '+s.git.repository).toLowerCase().includes(search)).sort((a,b)=>Number(recent(b))-Number(recent(a))||b.updatedAt.localeCompare(a.updatedAt));
38
+ const cards=$('cards');cards.replaceChildren();for(const s of shown){const card=make('button','agent'+(selected===s.id?' selected':'')+(s.state==='closed'?' closed':''));card.setAttribute('aria-pressed',String(selected===s.id));const top=make('div','agent-top'),status=make('span','agent-status');status.append(make('i','dot'+(recent(s)?' live':'')),make('span','',s.state==='closed'?'Closed':recent(s)?'Recent':'Away'));top.append(make('span','provider '+s.provider,s.provider==='claude'?'✳':'◈'),make('span','owner',who(s.id)),status);const bottom=make('div','agent-bottom');bottom.append(make('span','workspace-label',s.git.repository),make('span','',ago(s.updatedAt)));card.append(top,make('div','task',s.task||'No task shared yet'),bottom);card.onclick=()=>{selected=s.id;replyTo=null;render();};cards.append(card);}if(!shown.length)empty(cards,search?'No matching agents':opened.length?'No sessions in this view':'Your agents belong here.',search?'Try a person, task or workspace.':'Connect an agent, then ask it to join Relay. Its session will appear here.');
39
+ const target=$('target'),oldTarget=selected||target.value;target.replaceChildren(new Option('Choose an agent',''));for(const s of opened)target.append(new Option(who(s.id)+' — '+s.task.slice(0,45),s.id));target.value=oldTarget;$('send').disabled=!target.value||stopped;$('compose-label').textContent=replyTo?'Reply to':'To';$('cancel-reply').hidden=!replyTo;
40
+ $('people').replaceChildren();for(const m of data.members){const row=make('div','person'),text=make('div');text.append(make('strong','',m.name+(m.id===data.member.id?' (you)':'')),make('small','',opened.filter(s=>s.memberId===m.id).length+' agent sessions · '+m.role));row.append(make('span','avatar',initials(m.name)),text);$('people').append(row);}
41
+ const s=data.sessions.find(s=>s.id===selected),details=$('details');details.replaceChildren();if(s){details.append(make('h3','',who(s.id)),make('p','detail-title',s.task));for(const [label,value] of [['Workspace',s.git.repository],['Last Relay activity',new Date(s.updatedAt).toLocaleString()],['Session',s.state==='closed'?'Closed':recent(s)?'Recent activity':'No recent activity']])details.append(make('p','detail-label',label),make('div','detail-value',value));}else details.append(make('h3','','A little context goes a long way.'),make('p','rail-note','Select an agent to see its task and workspace, then start a conversation.'));
42
+ $('clear-selection').hidden=!selected;$('feed-title').textContent=s?who(s.id):mine?'Your conversations':'Shared activity';$('feed-subtitle').textContent=s?s.git.repository:'Questions, answers & context';$('older').hidden=!older;
43
+ const feed=$('feed'),wasBottom=feed.scrollHeight-feed.scrollTop-feed.clientHeight<60,scrollTop=feed.scrollTop,visible=[...events.values()].filter(e=>(!selected||e.sessionId===selected||e.targetSessionId===selected)&&(!mine||e.sessionId==='human_'+data.member.id||e.targetSessionId==='human_'+data.member.id)).sort((a,b)=>a.seq-b.seq);
44
+ feed.replaceChildren();for(const e of visible){const row=make('article','message'),meta=make('div','message-meta');meta.append(make('span','message-name',who(e.sessionId)),make('span','',e.kind==='reply'?'replied to':'→'),make('span','',who(e.targetSessionId)),make('time','',ago(e.createdAt)));row.append(meta);if(e.replyTo){const original=events.get(e.replyTo);row.append(make('div','reply-context','↳ '+(original?original.text:'An earlier question')));}row.append(make('p','message-text',e.text));const foot=make('div','message-foot'),receipt=receipts.get(e.id),state=receipt?.state||'queued',labels={queued:'Waiting for agent',accepted:'Received by client',delivered:'Read',answered:'Answered',uncertain:'Unconfirmed',held:'On hold'};const tag=make('span','tag '+state,labels[state]||state);tag.title=state==='queued'?'Stored safely. The recipient has not checked Relay yet. Enable automatic inbox checks in Connect an agent for supported clients.':receipt?.detail||'Stored for the recipient';foot.append(tag);if(e.targetSessionId==='human_'+data.member.id&&!['delivered','answered'].includes(state)){const ack=make('button','tiny-action','Mark read');ack.onclick=async()=>{try{await api({operation:'ack',eventId:e.id});await sync();}catch(error){notice(error.message);}};foot.append(ack);}if(e.phase==='progress')foot.append(make('span','tag','Progress update'));if(e.kind==='question'&&e.targetSessionId==='human_'+data.member.id&&state!=='answered'){const reply=make('button','tiny-action','Reply');reply.onclick=()=>{selected=e.sessionId;replyTo=e.id;render();$('message').focus();};foot.append(reply);}row.append(foot);feed.append(row);}if(!visible.length)empty(feed,'The conversation starts here.','Ask an agent a question. Shared questions and answers appear here as they happen.');feed.scrollTop=wasBottom?feed.scrollHeight:scrollTop;}
45
+ function resetRoom(){
46
+ stop();auth=data=null;events.clear();receipts.clear();loading=again=connecting=false;cursor=oldest=0;older=mine=false;selected='';replyTo=pending=sentNoticeId=null;
47
+ for(const id of ['message','search','connection-link'])$(id).value='';
48
+ $('show-closed').checked=false;$('mobile-view').value='all';$('target').replaceChildren(new Option('Choose an agent',''));$('send').disabled=true;$('cancel-reply').hidden=$('clear-selection').hidden=$('older').hidden=true;
49
+ for(const id of ['cards','feed','people','details'])$(id).replaceChildren();
50
+ $('room-name').textContent='Loading your room...';$('self-name').textContent='Connecting';$('self-role').textContent='';$('self-avatar').textContent='';$('agent-count').textContent='0';$('feed-title').textContent='Shared activity';$('feed-subtitle').textContent='Questions, answers & context';$('compose-label').textContent='To';$('title').textContent='A shared place to work.';
51
+ $('invite').hidden=$('sidebar-invite').hidden=true;$('reconnect').hidden=true;$('login-error').textContent='';notice('');connection('Connecting');if($('modal').open)$('modal').close();
52
+ }
53
+ async function begin(value){resetRoom();auth=value;stopped=false;retry=0;sessionStorage.setItem('relay-room',JSON.stringify(auth));$('login').hidden=true;$('shell').hidden=false;await sync();if(auth!==value)return;if(data)await live();else if(!stopped){connection('Could not connect');$('reconnect').hidden=false;}}
54
+ $('login-form').onsubmit=async event=>{event.preventDefault();try{await begin(parseLink($('connection-link').value.trim()));$('connection-link').value='';}catch(error){$('login-error').textContent=error.message;}};
55
+ $('mobile-signout').onclick=()=>$('signout').click();$('mobile-view').onchange=()=>{mine=$('mobile-view').value==='mine';selected='';replyTo=null;render();};$('search').oninput=render;$('show-closed').onchange=render;$('target').onchange=()=>{selected=$('target').value;replyTo=null;pending=null;render();};$('clear-selection').onclick=()=>{selected='';replyTo=null;render();};$('nav-room').onclick=()=>{mine=false;selected='';replyTo=null;render();};$('nav-mine').onclick=()=>{mine=true;selected='';replyTo=null;render();};$('cancel-reply').onclick=()=>{replyTo=null;render();};
56
+ $('older').onclick=async()=>{$('older').disabled=true;const height=$('feed').scrollHeight;await sync(oldest);$('feed').scrollTop+=Math.max(0,$('feed').scrollHeight-height);$('older').disabled=false;};
57
+ $('composer').onsubmit=async event=>{event.preventDefault();const text=$('message').value.trim(),targetSessionId=$('target').value;if(!text||!targetSessionId)return;const expected=auth,identity=JSON.stringify({text,targetSessionId,replyTo});if(pending?.identity!==identity)pending={identity,requestId:crypto.randomUUID()};$('send').disabled=true;try{await api({operation:'send',text,targetSessionId,requestId:pending.requestId,...(replyTo?{replyTo}:{})});if(auth!==expected)return;$('message').value='';replyTo=null;notice('Message queued. The agent will receive it on its next Relay check.');sentNoticeId=pending.requestId;pending=null;await sync();}catch(error){if(auth===expected)notice(error.message+' Your message is kept here; retrying will not duplicate it.');}finally{if(auth===expected)$('send').disabled=!$('target').value||stopped;}};
58
+ function modal(title){$('modal-title').textContent=title;$('modal-body').replaceChildren();$('modal').showModal();return $('modal-body');}
59
+ $('modal-close').onclick=()=>$('modal').close();$('modal').addEventListener('close',()=>$('modal-body').replaceChildren());
60
+ async function invite(){const expected=auth,body=modal('Invite someone to your room');body.append(make('p','','Creating a private invitation…'));try{const result=await api({operation:'invite'});if(auth!==expected||!$('modal').open)return;body.replaceChildren(make('p','','Share this link with your friend. They can join the room and connect their agent from any workspace.'));const link=make('textarea');link.readOnly=true;link.value=result.url;link.setAttribute('aria-label','Invitation link');const copy=make('button','primary','Copy invitation');copy.onclick=async()=>{try{await navigator.clipboard.writeText(result.url);copy.textContent='Copied';}catch{link.select();copy.textContent='Select and copy the link';}};body.append(link,make('p','small','Single use · expires '+new Date(result.expiresAt).toLocaleString()+'. Members can see shared room activity.'),copy);}catch(error){if(auth===expected&&$('modal').open)body.replaceChildren(make('p','',error.message));}}
61
+ $('invite').onclick=$('sidebar-invite').onclick=invite;
62
+ $('connect-agent').onclick=()=>{if(!auth)return;const body=modal('Bring your agent into the room'),url=location.origin+'/projects/'+auth.projectId+'/mcp';body.append(make('p','','Add Relay as a remote MCP server in your agent’s settings. Then say: “Join Relay and share what you’re working on.”'),make('label','','MCP server URL'),make('pre','',url));const details=make('details'),summary=make('summary','','Your private connection settings');details.append(summary,make('label','','Authorization header'),make('pre','','Bearer '+auth.token),make('label','','Codex config'),make('pre','','[mcp_servers.relay_remote]\nurl = '+JSON.stringify(url)+'\nhttp_headers = { Authorization = '+JSON.stringify('Bearer '+auth.token)+' }'));body.append(details,make('p','small','Use your own token in your private client settings. Invite friends with their own invitation link. Reopen the client after setup.'));const inbox=make('details');inbox.append(make('summary','','Automatic inbox checks (Codex)'),make('p','','Merge these hooks into ~/.codex/hooks.json. Reopen Codex and review them in /hooks. Relay checks messages when a turn starts and before it finishes. No download is needed; idle sessions still wait for a new turn.'),make('pre','',JSON.stringify(${JSON.stringify(codexRemoteHooks())},null,2)));body.append(inbox);};
63
+ $('signout').onclick=()=>{resetRoom();sessionStorage.removeItem('relay-room');$('shell').hidden=true;$('login').hidden=false;};
64
+ $('reconnect').onclick=()=>{stop();stopped=false;retry=0;notice('');sync().then(()=>{if(!stopped)live();});};
65
+ document.addEventListener('visibilitychange',()=>{if(document.hidden){clearTimeout(timer);clearInterval(heartbeat);if(socket){socket.onclose=null;socket.close();socket=null;}connection('Paused in background');}else if(auth&&!stopped){sync();live();}});
66
+ setInterval(()=>{if(data&&!document.hidden)render();},60000);
67
+ try{let saved;if(location.hash){saved=parseLink(location.href);history.replaceState(null,'',location.pathname);}else{const value=sessionStorage.getItem('relay-room');if(value)saved=parseLink(location.origin+'/app#'+encodeURIComponent(value));}if(saved)begin(saved);else $('login').hidden=false;}catch(error){$('login').hidden=false;$('login-error').textContent='Open your private room link to connect.';history.replaceState(null,'',location.pathname);}
68
+ </script></body></html>`, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'` } });
69
+ }