@comput/pi-intercom 0.3.0 → 0.4.2

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 (58) hide show
  1. package/README.md +137 -25
  2. package/dist/activity.d.ts +12 -0
  3. package/dist/activity.js +73 -0
  4. package/dist/activity.js.map +1 -0
  5. package/dist/config.d.ts +39 -0
  6. package/dist/config.js +182 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.js +186 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/launcher.d.ts +17 -0
  12. package/dist/launcher.js +77 -0
  13. package/dist/launcher.js.map +1 -0
  14. package/dist/monitor-launcher.d.ts +17 -0
  15. package/dist/monitor-launcher.js +136 -0
  16. package/dist/monitor-launcher.js.map +1 -0
  17. package/dist/monitor-view.d.ts +6 -0
  18. package/dist/monitor-view.js +134 -0
  19. package/dist/monitor-view.js.map +1 -0
  20. package/dist/monitor.d.ts +22 -0
  21. package/dist/monitor.js +171 -0
  22. package/dist/monitor.js.map +1 -0
  23. package/dist/observability.d.ts +79 -0
  24. package/dist/observability.js +208 -0
  25. package/dist/observability.js.map +1 -0
  26. package/dist/reports.d.ts +12 -0
  27. package/dist/reports.js +160 -0
  28. package/dist/reports.js.map +1 -0
  29. package/dist/runtime.d.ts +59 -0
  30. package/dist/runtime.js +458 -0
  31. package/dist/runtime.js.map +1 -0
  32. package/dist/snapshot.d.ts +15 -0
  33. package/dist/snapshot.js +128 -0
  34. package/dist/snapshot.js.map +1 -0
  35. package/dist/transport.d.ts +24 -0
  36. package/dist/transport.js +122 -0
  37. package/dist/transport.js.map +1 -0
  38. package/dist/worker-status.d.ts +26 -0
  39. package/dist/worker-status.js +76 -0
  40. package/dist/worker-status.js.map +1 -0
  41. package/package.json +13 -7
  42. package/references/observability.md +66 -60
  43. package/references/roadmap.md +68 -0
  44. package/src/activity.ts +66 -0
  45. package/src/config.ts +1 -1
  46. package/src/index.ts +51 -23
  47. package/src/monitor-launcher.ts +110 -0
  48. package/src/monitor-view.ts +123 -0
  49. package/src/monitor.ts +143 -0
  50. package/src/observability.ts +6 -1
  51. package/src/reports.ts +122 -0
  52. package/src/runtime.ts +45 -7
  53. package/src/{dashboard.ts → snapshot.ts} +9 -60
  54. package/src/transport.ts +11 -2
  55. package/src/worker-status.ts +66 -0
  56. package/dashboard/app.js +0 -182
  57. package/dashboard/index.html +0 -41
  58. package/dashboard/style.css +0 -82
package/src/reports.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ export type ReportStatus = 'blocked' | 'needs_decision' | 'ready_for_review' | 'clear';
7
+ export interface WorkerReport {
8
+ version: 1;
9
+ sessionId: string;
10
+ status: ReportStatus;
11
+ summary: string;
12
+ updatedAt: string;
13
+ }
14
+ const MAX_BYTES = 8 * 1024;
15
+ const MAX_IDS = 256;
16
+ const statuses = new Set<ReportStatus>(['blocked', 'needs_decision', 'ready_for_review', 'clear']);
17
+ const controls = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/;
18
+ function validId(id: unknown): id is string {
19
+ return typeof id === 'string' && id.length > 0 && id.length <= 256 && id.trim().length > 0 && !/[\x00-\x1f\x7f-\x9f]/.test(id);
20
+ }
21
+ function validate(value: unknown): WorkerReport | undefined {
22
+ if (!value || typeof value !== 'object') return;
23
+ const v = value as Record<string, unknown>;
24
+ if (v.version !== 1 || !validId(v.sessionId) || !statuses.has(v.status as ReportStatus)) return;
25
+ if (typeof v.summary !== 'string' || v.summary.length > 2000 || controls.test(v.summary)) return;
26
+ if (v.status === 'clear' ? v.summary !== '' : !v.summary.trim()) return;
27
+ if (typeof v.updatedAt !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v.updatedAt)) return;
28
+ const timestamp = Date.parse(v.updatedAt);
29
+ if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== v.updatedAt) return;
30
+ return { version: 1, sessionId: v.sessionId, status: v.status as ReportStatus, summary: v.summary, updatedAt: v.updatedAt };
31
+ }
32
+ function filename(id: string): string {
33
+ return `${createHash('sha256').update(id).digest('hex')}.json`;
34
+ }
35
+ /** Reject links at the root and both extension-owned directory components. */
36
+ async function directory(root: string, create: boolean, assertCurrent?: () => void): Promise<string> {
37
+ if ((await lstat(root)).isSymbolicLink()) throw new Error('unsafe root');
38
+ let current = await realpath(root);
39
+ for (const segment of ['.pi-intercom', 'reports']) {
40
+ current = path.join(current, segment);
41
+ if (create) {
42
+ assertCurrent?.();
43
+ try { await mkdir(current, { mode: 0o700 }); }
44
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
45
+ }
46
+ const info = await lstat(current);
47
+ if (!info.isDirectory() || info.isSymbolicLink() || path.relative(current, await realpath(current)) !== '') throw new Error('unsafe directory');
48
+ }
49
+ return current;
50
+ }
51
+ async function regularOrAbsent(file: string): Promise<void> {
52
+ try {
53
+ const info = await lstat(file);
54
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('unsafe file');
55
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
56
+ }
57
+
58
+ /** Persist only the explicitly authored public report; clear is an atomic tombstone. */
59
+ export async function saveWorkerReport(root: string, report: WorkerReport, assertCurrent?: () => void): Promise<void> {
60
+ let temporary: string | undefined;
61
+ let ownsTemporary = false;
62
+ let dir: string | undefined;
63
+ try {
64
+ const safe = validate(report);
65
+ if (!safe) throw new Error('invalid report');
66
+ const content = JSON.stringify(safe) + '\n';
67
+ if (Buffer.byteLength(content) > MAX_BYTES) throw new Error('oversized report');
68
+ assertCurrent?.();
69
+ dir = await directory(root, true, assertCurrent);
70
+ const target = path.join(dir, filename(safe.sessionId));
71
+ await regularOrAbsent(target);
72
+ temporary = path.join(dir, `.${filename(safe.sessionId)}.${randomUUID()}.tmp`);
73
+ assertCurrent?.();
74
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
75
+ ownsTemporary = true;
76
+ try { assertCurrent?.(); await handle.writeFile(content, 'utf8'); }
77
+ finally { await handle.close(); }
78
+ if (await directory(root, false) !== dir) throw new Error('directory changed');
79
+ await regularOrAbsent(target);
80
+ assertCurrent?.();
81
+ await rename(temporary, target);
82
+ temporary = undefined;
83
+ } catch { throw new Error('Worker report could not be saved.'); }
84
+ finally {
85
+ if (temporary && ownsTemporary && dir) {
86
+ // Cleanup only our own unpublished temporary file, even if authorization expired.
87
+ try { if (await directory(root, false) === dir) await unlink(temporary); } catch { /* No raw filesystem errors or unsafe cleanup. */ }
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Read at most 256 configured IDs; no directory scan, repair, transport, or host access. */
93
+ export async function readWorkerReports(root: string, sessionIds: readonly string[]): Promise<WorkerReport[]> {
94
+ const reports: WorkerReport[] = [];
95
+ let dir: string;
96
+ try { dir = await directory(root, false); } catch { return reports; }
97
+ const seen = new Set<string>();
98
+ for (const id of sessionIds.slice(0, MAX_IDS)) {
99
+ if (!validId(id) || seen.has(id)) continue;
100
+ seen.add(id);
101
+ try {
102
+ if (await directory(root, false) !== dir) break;
103
+ const file = path.join(dir, filename(id));
104
+ const info = await lstat(file);
105
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES) continue;
106
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
107
+ let text: string;
108
+ try {
109
+ const stat = await handle.stat();
110
+ if (!stat.isFile() || stat.size > MAX_BYTES) continue;
111
+ // One extra byte detects concurrent growth, while keeping reads bounded.
112
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
113
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
114
+ if (bytesRead > MAX_BYTES) continue;
115
+ text = buffer.subarray(0, bytesRead).toString('utf8');
116
+ } finally { await handle.close(); }
117
+ const report = validate(JSON.parse(text));
118
+ if (report?.sessionId === id) reports.push(report);
119
+ } catch { /* Missing, unsafe, malformed or concurrently replaced evidence is unavailable. */ }
120
+ }
121
+ return reports;
122
+ }
package/src/runtime.ts CHANGED
@@ -2,7 +2,10 @@ import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { LocalObserver, LOG_LIMITS, observationError, type Observer, type EventType, type EventMetadata } from './observability.js';
4
4
  import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, type Agent, type Config } from './config.js';
5
- import { listen, send, type Endpoint, type Envelope, type Kind } from './transport.js';
5
+ import { listen, send, reportPayload, type Endpoint, type Envelope, type Kind } from './transport.js';
6
+ import { saveWorkerReport } from './reports.js';
7
+ import { readObservationSnapshot } from './snapshot.js';
8
+ import { workerStatusPage } from './worker-status.js';
6
9
 
7
10
  export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and close_worker are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. See references/implementation-blocker.md. A verified supported host API is required (no version-only override).';
8
11
  export interface Host {
@@ -30,6 +33,8 @@ export class Intercom {
30
33
  responsibility?: Agent;
31
34
  private observer?: Observer;
32
35
  private closing?: Promise<void>;
36
+ private reportWrites: Promise<void> = Promise.resolve();
37
+ private pendingReports = 0;
33
38
  private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
34
39
  const observer = this.observer;
35
40
  return (event, metadata = {}) => {
@@ -139,12 +144,13 @@ export class Intercom {
139
144
  assertValid();
140
145
  return { id, config, me: config.agents.find(a => a.sessionId === id) };
141
146
  }
142
- /** Persist an actual dashboard bind; saved discovery metadata, not a live-status assertion. */
143
- async recordDashboardPort(boundPort: number): Promise<void> {
147
+ /** Accept old configs, but retire the removed browser dashboard's discovery metadata. */
148
+ async retireDashboardMetadata(): Promise<void> {
144
149
  const record = this.captureObserver(), assertValid = this.validity();
145
150
  const { id, config } = await this.state();
146
- assertValid(); requireCoordinator(config, id); port(boundPort);
147
- await this.store.update(id, c => { requireCoordinator(c, id).dashboardPort = boundPort; }, assertValid);
151
+ assertValid();
152
+ if (requireCoordinator(config, id).dashboardPort === undefined) return;
153
+ await this.store.update(id, c => { delete requireCoordinator(c, id).dashboardPort; }, assertValid);
148
154
  record('config.changed', { outcome: 'written' });
149
155
  }
150
156
  async reload(): Promise<void> {
@@ -233,6 +239,27 @@ export class Intercom {
233
239
  }
234
240
  return;
235
241
  }
242
+ if (message.kind === 'report') {
243
+ requireCoordinator(config, id);
244
+ if (!sender || sender.coordinator) fail('work reports require a configured worker sender');
245
+ const payload = reportPayload(message.payload);
246
+ if (this.pendingReports >= 64) fail('report queue full; no report stored');
247
+ this.pendingReports++;
248
+ const pending = this.reportWrites.then(async () => {
249
+ assertValid();
250
+ const fresh = await this.state();
251
+ requireCoordinator(fresh.config, id);
252
+ if (!fresh.config.agents.some(agent => agent.sessionId === message.from && !agent.coordinator)) fail('reporting worker is no longer configured');
253
+ await saveWorkerReport(this.store.root, { version: 1, sessionId: message.from, ...payload, updatedAt: new Date().toISOString() }, assertValid);
254
+ assertValid();
255
+ try {
256
+ notify('worker report', `Report: ${payload.status}\n${payload.summary}\nThis is a worker-authored public report, not approval or verified completion. Evaluate it before deciding any next step. Clearing retires the report only; it does not resume or complete work.`);
257
+ } catch { fail('Report stored, but coordinator notification failed or became uncertain; do not blindly resend'); }
258
+ });
259
+ this.reportWrites = pending.catch(() => {}).finally(() => { this.pendingReports--; });
260
+ await pending;
261
+ return;
262
+ }
236
263
  if (message.kind === 'message') {
237
264
  if (!sender || !me) fail('agent messaging requires configured sender and recipient; anonymous permissions TODO');
238
265
  if (!this.responsibility) fail('worker must load responsibility before receiving work');
@@ -260,12 +287,23 @@ export class Intercom {
260
287
  const assertValid = this.validity();
261
288
  const { id, config, me } = await this.state();
262
289
  assertValid();
290
+ if (operation === 'worker_status') {
291
+ if (!me) fail('worker_status requires a configured session');
292
+ const snapshot = await readObservationSnapshot(this.store.root);
293
+ assertValid();
294
+ return workerStatusPage(snapshot, { name: args.name as string | undefined, offset: args.offset as number | undefined, limit: args.limit as number | undefined });
295
+ }
263
296
  if (operation === 'list') {
264
297
  if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
265
- const dashboardPort = coordinator(config).dashboardPort;
266
- return { ...config, dashboardUrl: dashboardPort === undefined ? null : `http://127.0.0.1:${dashboardPort}/` };
298
+ return { ...config, agents: config.agents.map(({ dashboardPort: _retired, ...agent }) => agent) };
267
299
  }
268
300
  if (operation === 'report_status') { await this.report(); return { accepted: true }; }
301
+ if (operation === 'report_work') {
302
+ if (!me || me.coordinator || !this.responsibility) fail('report_work requires a configured, responsibility-loaded worker');
303
+ const payload = reportPayload(args);
304
+ await this.transmit(coordinator(config).name, 'report', payload);
305
+ return { accepted: true, coordinatorEvaluation: 'not awaited' };
306
+ }
269
307
  if (operation === 'send') {
270
308
  if (!me || !this.responsibility) fail('anonymous/unloaded worker cannot send agent messages (permissions TODO)');
271
309
  await this.transmit(args.to as string, 'message', { message: args.message }); return { accepted: true, completion: 'not awaited' };
@@ -1,19 +1,19 @@
1
- import { createServer } from 'node:http';
2
1
  import { constants } from 'node:fs';
3
2
  import { lstat, open, opendir, realpath } from 'node:fs/promises';
4
3
  import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
4
  import { validateConfig, type Config } from './config.js';
5
+ import { readWorkerReports, type WorkerReport } from './reports.js';
7
6
  import { LOG_DIRECTORY, LOG_FILE_PATTERN, sanitizeObservation, type Observation } from './observability.js';
8
7
 
9
8
  const CONFIG_BYTES = 1024 * 1024, TAIL_BYTES = 128 * 1024, TOTAL_BYTES = 2 * 1024 * 1024;
10
9
  const MAX_FILES = 32, MAX_ENTRIES = 512, MAX_EVENTS = 500, MAX_AGENTS = 256;
11
- export interface DashboardSnapshot {
10
+ export interface ObservationSnapshot {
12
11
  version: 1;
13
12
  generatedAt: string;
14
13
  staleAfterMs: number;
15
14
  config: Pick<Config, 'multiplexer' | 'agents'> | null;
16
15
  events: Observation[];
16
+ reports?: WorkerReport[];
17
17
  truncated: boolean;
18
18
  errors: string[];
19
19
  }
@@ -53,8 +53,9 @@ async function boundedRead(file: string, limit: number, tail = false): Promise<{
53
53
  } finally { await handle.close(); }
54
54
  }
55
55
 
56
- export async function readDashboardSnapshot(root: string): Promise<DashboardSnapshot> {
57
- const result: DashboardSnapshot = { version: 1, generatedAt: new Date().toISOString(), staleAfterMs: 60000, config: null, events: [], truncated: false, errors: [] };
56
+ /** Bounded, sanitized local evidence, independent of any UI or HTTP server. */
57
+ export async function readObservationSnapshot(root: string): Promise<ObservationSnapshot> {
58
+ const result: ObservationSnapshot = { version: 1, generatedAt: new Date().toISOString(), staleAfterMs: 60000, config: null, events: [], truncated: false, errors: [] };
58
59
  try {
59
60
  const file = await confined(root, '.pi-intercom/config.json');
60
61
  const config = validateConfig(JSON.parse((await boundedRead(file, CONFIG_BYTES)).text));
@@ -62,7 +63,6 @@ export async function readDashboardSnapshot(root: string): Promise<DashboardSnap
62
63
  result.config = { multiplexer: config.multiplexer, agents: config.agents.slice(0, MAX_AGENTS).map(a => ({
63
64
  sessionId: a.sessionId, name: a.name, coordinator: a.coordinator, description: a.description,
64
65
  projectDirectory: a.projectDirectory, port: a.port,
65
- ...(a.dashboardPort === undefined ? {} : { dashboardPort: a.dashboardPort }),
66
66
  })) };
67
67
  } catch { result.errors.push('config_unavailable'); }
68
68
  try {
@@ -100,59 +100,8 @@ export async function readDashboardSnapshot(root: string): Promise<DashboardSnap
100
100
  result.events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
101
101
  if (result.events.length > MAX_EVENTS) { result.events = result.events.slice(-MAX_EVENTS); result.truncated = true; }
102
102
  } catch { result.errors.push('logs_unavailable'); }
103
+ result.reports = result.config
104
+ ? await readWorkerReports(root, result.config.agents.filter(agent => !agent.coordinator).map(agent => agent.sessionId))
105
+ : [];
103
106
  return result;
104
107
  }
105
-
106
- export interface Dashboard { url: string; port: number; close(): Promise<void> }
107
- export async function startDashboard(root: string, options: { assetDirectory?: string } = {}): Promise<Dashboard> {
108
- const assets = options.assetDirectory ?? fileURLToPath(new URL('../dashboard/', import.meta.url));
109
- const routes: Record<string, [string, string]> = {
110
- '/': ['index.html', 'text/html; charset=utf-8'],
111
- '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
112
- '/style.css': ['style.css', 'text/css; charset=utf-8'],
113
- };
114
- let port = 0, active = 0, closing = false;
115
- let pending: Promise<DashboardSnapshot> | undefined;
116
- const server = createServer(async (request, response) => {
117
- response.setHeader('Cache-Control', 'no-store');
118
- response.setHeader('X-Content-Type-Options', 'nosniff');
119
- response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'");
120
- response.setHeader('Referrer-Policy', 'no-referrer');
121
- const end = (code: number, body: string, type = 'text/plain; charset=utf-8') => {
122
- response.writeHead(code, { 'Content-Type': type }); response.end(body);
123
- };
124
- const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
125
- if (!hosts.includes(request.headers.host ?? '') ||
126
- (request.headers.origin !== undefined && !hosts.some(host => request.headers.origin === `http://${host}`)) ||
127
- request.headers['sec-fetch-site'] === 'cross-site') return end(403, 'Forbidden');
128
- if (request.method !== 'GET') { response.setHeader('Allow', 'GET'); return end(405, 'GET only'); }
129
- if (closing || active >= 8) return end(503, 'Unavailable');
130
- active++;
131
- try {
132
- if (request.url === '/api/snapshot') {
133
- pending ??= readDashboardSnapshot(root).finally(() => { pending = undefined; });
134
- return end(200, JSON.stringify(await pending), 'application/json; charset=utf-8');
135
- }
136
- const route = Object.hasOwn(routes, request.url ?? '') ? routes[request.url!] : undefined;
137
- if (!route) return end(404, 'Not found');
138
- const file = await confined(assets, route[0]);
139
- return end(200, (await boundedRead(file, 256 * 1024)).text, route[1]);
140
- } catch { return end(503, 'Unavailable'); }
141
- finally { active--; }
142
- });
143
- server.requestTimeout = 5000; server.headersTimeout = 5000; server.keepAliveTimeout = 1000;
144
- server.maxConnections = 16;
145
- await new Promise<void>((resolve, reject) => {
146
- server.once('error', reject);
147
- server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
148
- });
149
- port = (server.address() as { port: number }).port;
150
- let closed: Promise<void> | undefined;
151
- return { port, url: `http://127.0.0.1:${port}/`, close() {
152
- closing = true;
153
- return closed ??= new Promise<void>((resolve, reject) => {
154
- server.close(error => error ? reject(error) : resolve());
155
- server.closeAllConnections();
156
- });
157
- } };
158
- }
package/src/transport.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import http from 'node:http';
2
2
  import { fail, port, text } from './config.js';
3
+ import type { ReportStatus } from './reports.js';
3
4
 
4
5
  export const BODY_LIMIT = 64 * 1024;
5
6
  export const RECEIPT_TIMEOUT = 5000;
6
- export type Kind = 'message' | 'registration' | 'status' | 'request_status' | 'reload' | 'stop' | 'close';
7
+ export type Kind = 'message' | 'report' | 'registration' | 'status' | 'request_status' | 'reload' | 'stop' | 'close';
7
8
  export interface Envelope {
8
9
  version: 1;
9
10
  kind: Kind;
@@ -13,13 +14,21 @@ export interface Envelope {
13
14
  /** Optional diagnostics correlation only; never authentication or agent identity. */
14
15
  correlationId?: string;
15
16
  }
17
+ export function reportPayload(payload: Record<string, unknown>): { status: ReportStatus; summary: string } {
18
+ if (!['blocked', 'needs_decision', 'ready_for_review', 'clear'].includes(payload.status as string)) fail('invalid report status');
19
+ const status = payload.status as ReportStatus;
20
+ const summary = payload.summary ?? '';
21
+ if (typeof summary !== 'string' || summary.length > 2000 || (status !== 'clear' && !summary.trim()) || (status === 'clear' && summary !== '')) fail('report requires a public summary of 1–2000 characters, or empty summary for clear');
22
+ return { status, summary };
23
+ }
16
24
  export function envelope(value: unknown): Envelope {
17
25
  const m = value as Envelope;
18
- if (!m || m.version !== 1 || !['message', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'].includes(m.kind)) fail('invalid wire schema/version/kind');
26
+ if (!m || m.version !== 1 || !['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'].includes(m.kind)) fail('invalid wire schema/version/kind');
19
27
  text(m.from, 'sender sessionId', 256); text(m.to, 'recipient sessionId', 256);
20
28
  if (m.correlationId !== undefined && (typeof m.correlationId !== 'string' || !/^[a-zA-Z0-9-]{1,128}$/.test(m.correlationId))) fail('invalid correlation ID');
21
29
  if (!m.payload || Array.isArray(m.payload) || typeof m.payload !== 'object') fail('invalid payload');
22
30
  if (m.kind === 'message') text(m.payload.message, 'message', 48000);
31
+ if (m.kind === 'report') reportPayload(m.payload);
23
32
  if (m.kind === 'registration' || m.kind === 'status') port(m.payload.port);
24
33
  if (m.kind === 'registration') text(m.payload.projectDirectory, 'projectDirectory');
25
34
  if (m.kind === 'status' && typeof m.payload.busy !== 'boolean') fail('invalid busy flag');
@@ -0,0 +1,66 @@
1
+ import type { ObservationSnapshot } from './snapshot.js';
2
+
3
+ const labels: Record<string, string> = {
4
+ processing: 'Processing', thinking: 'Thinking', responding: 'Writing response',
5
+ reading_files: 'Reading files', editing_files: 'Editing files', running_command: 'Running command',
6
+ using_tool: 'Using a tool', multiple_tools: 'Running multiple tools', settled: 'Turn settled',
7
+ };
8
+ export function workerObservation(snapshot: ObservationSnapshot, sessionId: string, now = Date.now()) {
9
+ const events = snapshot.events.filter(e => e.sessionId === sessionId &&
10
+ ['runtime.ready', 'host.activity', 'runtime.closed'].includes(e.event) &&
11
+ (e.event === 'runtime.closed' || typeof e.busy === 'boolean') && Number.isFinite(Date.parse(e.timestamp)));
12
+ const latest = events.reduce<(typeof events)[number] | undefined>((best, e) =>
13
+ !best || Date.parse(e.timestamp) > Date.parse(best.timestamp) ? e : best, undefined);
14
+ let observedStatus = 'unknown', lastActivity = 'no observation';
15
+ let observationAgeSeconds: number | null = null, stale: boolean | null = null;
16
+ let evidence = 'missing';
17
+ if (latest) {
18
+ const elapsed = now - Date.parse(latest.timestamp);
19
+ if (elapsed < 0) { evidence = 'clock_uncertain'; lastActivity = 'clock uncertain'; }
20
+ else {
21
+ observationAgeSeconds = Math.floor(elapsed / 1000);
22
+ stale = elapsed > snapshot.staleAfterMs;
23
+ const ties = events.filter(e => e.timestamp === latest.timestamp);
24
+ const conflict = ties.some(e => (e.event === 'runtime.closed') !== (latest.event === 'runtime.closed') || e.busy !== latest.busy || (e.phase && latest.phase && e.phase !== latest.phase));
25
+ if (conflict) { evidence = 'conflicting'; lastActivity = 'conflicting records'; }
26
+ else {
27
+ evidence = stale ? 'stale' : 'recent';
28
+ observedStatus = latest.event === 'runtime.closed' ? 'closed' : !latest.busy ? 'idle' : latest.phase === 'thinking' ? 'thinking' : 'working';
29
+ const detail = events.filter(e => e.writerId === latest.writerId && e.detail && !['processing', 'settled'].includes(e.detail) && Date.parse(e.timestamp) <= Date.parse(latest.timestamp))
30
+ .reduce<(typeof events)[number] | undefined>((best, e) => !best || e.timestamp > best.timestamp ? e : best, undefined);
31
+ lastActivity = latest.event === 'runtime.closed' ? 'Runtime closed'
32
+ : (detail?.detail ? labels[detail.detail] : latest.detail ? labels[latest.detail] : latest.busy ? 'Processing' : 'No active turn');
33
+ }
34
+ }
35
+ }
36
+ return { observedStatus, lastActivity, observedAt: latest?.timestamp ?? null, observationAgeSeconds, stale, evidence };
37
+ }
38
+
39
+ /** Bounded JSON data for agents/any presentation layer. No Telegram, UI, probing or model calls. */
40
+ export function workerStatusPage(snapshot: ObservationSnapshot, options: { name?: string; offset?: number; limit?: number } = {}, now = Date.now()) {
41
+ const offset = options.offset ?? 0, limit = options.limit ?? 10;
42
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) throw new Error('Invalid pagination: offset >= 0, limit 1–20');
43
+ if (options.name !== undefined && (typeof options.name !== 'string' || !options.name.trim())) throw new Error('Worker name must be nonblank');
44
+ let roster = snapshot.config?.agents.filter(a => !a.coordinator) ?? [];
45
+ if (options.name !== undefined) {
46
+ roster = roster.filter(a => a.name.toLowerCase() === options.name!.toLowerCase());
47
+ if (snapshot.config && !roster.length) throw new Error('Worker name not found in the retained snapshot');
48
+ }
49
+ const workers: Array<Record<string, unknown>> = [];
50
+ const result = { version: 1, source: 'local-observations', generatedAt: new Date(now).toISOString(),
51
+ statusMeaning: 'Last observed, not live liveness or task completion. Reports are self-reported, not approval.',
52
+ truncated: snapshot.truncated, errors: snapshot.errors, totalInSnapshot: roster.length, offset,
53
+ nextOffset: null as number | null, workers };
54
+ for (const agent of roster.slice(offset, offset + limit)) {
55
+ const report = snapshot.reports?.find(r => r.sessionId === agent.sessionId && r.status !== 'clear');
56
+ const reportAge = report ? now - Date.parse(report.updatedAt) : NaN;
57
+ const entry = { sessionId: agent.sessionId, name: agent.name, ...workerObservation(snapshot, agent.sessionId, now),
58
+ report: report ? { status: report.status, summary: report.summary, updatedAt: report.updatedAt,
59
+ ageSeconds: Number.isFinite(reportAge) && reportAge >= 0 ? Math.floor(reportAge / 1000) : null,
60
+ selfReported: true } : null };
61
+ workers.push(entry);
62
+ if (Buffer.byteLength(JSON.stringify(result, null, 2)) > 40000) { workers.pop(); break; }
63
+ }
64
+ if (offset + workers.length < roster.length) result.nextOffset = offset + workers.length;
65
+ return result;
66
+ }
package/dashboard/app.js DELETED
@@ -1,182 +0,0 @@
1
- 'use strict';
2
-
3
- (() => {
4
- const byId = id => document.getElementById(id);
5
- const ui = Object.fromEntries(['roster', 'events', 'notice', 'connection-state', 'snapshot-time', 'roster-count', 'event-count', 'event-window', 'worker-filter', 'type-filter', 'errors-filter'].map(id => [id, byId(id)]));
6
- const refreshMs = 3000;
7
- let snapshot = null;
8
- let connected = false;
9
- let eventSignature = '';
10
- const safeText = (value, max = 4096) => typeof value === 'string' ? value.slice(0, max) : '';
11
- const timestamp = value => typeof value === 'string' ? Date.parse(value) : NaN;
12
- const node = (tag, text, className) => {
13
- const result = document.createElement(tag);
14
- if (text !== undefined) result.textContent = String(text);
15
- if (className) result.className = className;
16
- return result;
17
- };
18
- const formatDate = value => Number.isFinite(timestamp(value)) ? new Date(value).toLocaleString() : 'Unknown time';
19
- const age = value => {
20
- const milliseconds = Date.now() - timestamp(value);
21
- if (!Number.isFinite(milliseconds)) return 'Age unknown';
22
- if (milliseconds < -5000) return 'Clock ahead; age uncertain';
23
- const seconds = Math.max(0, Math.floor(milliseconds / 1000));
24
- if (seconds < 60) return `${seconds}s ago`;
25
- if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
26
- return `${Math.floor(seconds / 3600)}h ago`;
27
- };
28
- const isError = event => Boolean(event.errorCode) || ['error', 'failed', 'failure', 'rejected', 'handler_failed'].includes(event.outcome) || /(?:^|\.)(?:error|failed|failure|rejected)$/.test(event.event);
29
- const agents = () => Array.isArray(snapshot?.config?.agents) ? snapshot.config.agents : [];
30
- const events = () => Array.isArray(snapshot?.events) ? snapshot.events : [];
31
- const agentName = id => safeText(agents().find(agent => agent.sessionId === id)?.name, 128) || safeText(id, 256) || 'Unknown agent';
32
-
33
- function renderRoster() {
34
- const observed = new Map();
35
- for (const event of events()) {
36
- if (!['runtime.ready', 'host.activity'].includes(event.event) || typeof event.busy !== 'boolean' || !Number.isFinite(timestamp(event.timestamp))) continue;
37
- const previous = observed.get(event.sessionId);
38
- if (!previous || timestamp(event.timestamp) >= timestamp(previous.timestamp)) observed.set(event.sessionId, event);
39
- }
40
- ui['roster-count'].textContent = String(agents().length);
41
- const fragment = document.createDocumentFragment();
42
- for (const agent of agents()) {
43
- const article = node('article', undefined, 'agent');
44
- article.append(node('h3', safeText(agent.name, 128) || 'Unnamed agent'));
45
- article.append(node('span', agent.coordinator ? 'Coordinator' : 'Worker', 'agent-role'));
46
- article.append(node('p', safeText(agent.description) || 'No responsibility recorded.', 'responsibility'));
47
- const port = Number.isInteger(agent.port) && agent.port > 0 && agent.port <= 65535 ? `127.0.0.1:${agent.port}` : 'Unknown endpoint';
48
- article.append(node('p', `Saved endpoint · ${port}`, 'endpoint'));
49
- article.append(node('p', `Pi session · ${safeText(agent.sessionId, 256)}`, 'identity'));
50
- const observation = observed.get(agent.sessionId);
51
- const badges = node('div', undefined, 'observation');
52
- if (!observation) {
53
- badges.append(node('span', 'Status unknown', 'badge'));
54
- article.append(badges, node('p', 'No busy/idle observation in the retained event window.', 'observed-time'));
55
- } else {
56
- const elapsed = Date.now() - timestamp(observation.timestamp);
57
- const stale = elapsed > snapshot.staleAfterMs;
58
- const uncertain = elapsed < -5000;
59
- badges.append(node('span', observation.busy ? 'Observed busy' : 'Observed idle', 'badge badge-observed'));
60
- badges.append(node('span', uncertain ? 'Clock uncertain' : stale ? 'Stale evidence' : 'Recent evidence', `badge${stale || uncertain ? ' badge-stale' : ''}`));
61
- article.append(badges, node('p', `${formatDate(observation.timestamp)} · ${age(observation.timestamp)}${!connected ? ' · Dashboard disconnected' : ''}`, 'observed-time'));
62
- }
63
- fragment.append(article);
64
- }
65
- if (!agents().length) fragment.append(node('p', snapshot?.config ? 'No agents in this snapshot.' : 'Saved configuration is unavailable. No roster can be inferred from events.', 'empty'));
66
- ui.roster.replaceChildren(fragment);
67
- }
68
-
69
- function syncOptions(select, values, allLabel) {
70
- const signature = JSON.stringify(values);
71
- if (select.dataset.options === signature) return;
72
- const selected = select.value;
73
- select.replaceChildren(new Option(allLabel, ''));
74
- for (const [value, label] of values) select.append(new Option(label, value));
75
- if (values.some(([value]) => value === selected)) select.value = selected;
76
- select.dataset.options = signature;
77
- }
78
-
79
- function syncFilters() {
80
- const ids = new Set(agents().map(agent => agent.sessionId));
81
- for (const event of events()) {
82
- if (event.sessionId) ids.add(event.sessionId);
83
- if (event.peerSessionId) ids.add(event.peerSessionId);
84
- }
85
- syncOptions(ui['worker-filter'], [...ids].sort().map(id => [id, agentName(id)]), 'All agents');
86
- syncOptions(ui['type-filter'], [...new Set(events().map(event => event.event))].sort().map(type => [type, type]), 'All types');
87
- }
88
-
89
- function renderEvents() {
90
- const worker = ui['worker-filter'].value;
91
- const type = ui['type-filter'].value;
92
- const onlyErrors = ui['errors-filter'].checked;
93
- const filtered = events().filter(event => (!worker || event.sessionId === worker || event.peerSessionId === worker) && (!type || event.event === type) && (!onlyErrors || isError(event))).slice().reverse();
94
- ui['event-count'].textContent = `${filtered.length} / ${events().length}`;
95
- ui['event-window'].textContent = `${snapshot?.truncated ? 'Bounded window: earlier or excess records omitted. ' : 'Retained event window. '}Newest first. Status evidence older than ${Math.round((snapshot?.staleAfterMs || 60000) / 1000)} seconds is marked stale, not offline.`;
96
- const signature = JSON.stringify([filtered, agents().map(agent => [agent.sessionId, agent.name])]);
97
- if (signature === eventSignature) return;
98
- eventSignature = signature;
99
- const opened = new Set([...ui.events.querySelectorAll('details[open]')].map(item => item.dataset.key));
100
- const focusedKey = document.activeElement?.closest('details')?.dataset.key;
101
- const fragment = document.createDocumentFragment();
102
- const metadata = [
103
- ['timestamp', 'Timestamp'], ['sessionId', 'Pi session'], ['peerSessionId', 'Peer session'],
104
- ['peerName', 'Peer label'], ['writerId', 'Telemetry writer'], ['correlationId', 'Correlation ID'],
105
- ['kind', 'Wire kind'], ['operation', 'Operation'], ['role', 'Observed role'],
106
- ['outcome', 'Outcome'], ['errorCode', 'Error code'], ['busy', 'Busy snapshot'], ['port', 'Observed port'],
107
- ];
108
- const occurrences = new Map();
109
- filtered.forEach(event => {
110
- const details = node('details', undefined, 'event');
111
- const identity = JSON.stringify(event);
112
- const occurrence = occurrences.get(identity) || 0;
113
- occurrences.set(identity, occurrence + 1);
114
- const key = `${identity}:${occurrence}`;
115
- details.dataset.key = key;
116
- details.open = opened.has(key);
117
- const summary = node('summary');
118
- const time = node('time', Number.isFinite(timestamp(event.timestamp)) ? new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : 'Unknown', 'event-time');
119
- if (Number.isFinite(timestamp(event.timestamp))) time.dateTime = event.timestamp;
120
- time.title = formatDate(event.timestamp);
121
- const label = node('span');
122
- label.append(node('span', safeText(event.event, 128), 'event-name'));
123
- label.append(node('span', `${agentName(event.sessionId)}${event.peerSessionId ? ` · peer ${agentName(event.peerSessionId)}` : ''}`, 'event-route'));
124
- if (isError(event)) label.append(node('span', 'Error recorded', 'badge badge-error'));
125
- summary.append(time, label);
126
- details.append(summary);
127
- const list = node('dl');
128
- for (const [field, title] of metadata) {
129
- const value = event[field];
130
- if (!['string', 'number', 'boolean'].includes(typeof value)) continue;
131
- list.append(node('dt', title), node('dd', typeof value === 'string' ? safeText(value) : String(value)));
132
- }
133
- details.append(list);
134
- fragment.append(details);
135
- });
136
- if (!filtered.length) fragment.append(node('p', events().length ? 'No events match these filters. Choose another agent or event type.' : 'No readable events in this window. This does not indicate that agents are idle or work is complete.', 'empty'));
137
- ui.events.replaceChildren(fragment);
138
- if (focusedKey) [...ui.events.querySelectorAll('details')].find(item => item.dataset.key === focusedKey)?.querySelector('summary')?.focus({ preventScroll: true });
139
- }
140
-
141
- const errorLabels = {
142
- config_unavailable: 'Shared configuration could not be read.',
143
- logs_unavailable: 'Event logs are unavailable.',
144
- log_read_failed: 'Some event logs could not be read.',
145
- };
146
- function renderNotice(failed = false) {
147
- const messages = failed ? ['Dashboard disconnected or snapshot unavailable. Last received data is retained below; it is not current process status. Refresh attempts continue automatically.'] : [];
148
- for (const code of snapshot?.errors || []) messages.push(errorLabels[code] || 'Part of this snapshot is unavailable.');
149
- ui.notice.textContent = [...new Set(messages)].join(' ');
150
- ui.notice.hidden = !messages.length;
151
- }
152
-
153
- async function refresh() {
154
- const controller = new AbortController();
155
- const timeout = setTimeout(() => controller.abort(), 8000);
156
- try {
157
- const response = await fetch('/api/snapshot', { cache: 'no-store', credentials: 'omit', signal: controller.signal });
158
- if (!response.ok) throw new Error('snapshot_unavailable');
159
- const value = await response.json();
160
- if (value?.version !== 1 || !Array.isArray(value.events) || !Array.isArray(value.errors) || !Number.isFinite(timestamp(value.generatedAt))) throw new Error('snapshot_invalid');
161
- snapshot = { ...value, events: value.events.filter(event => event && typeof event.event === 'string').slice(-500), staleAfterMs: Number.isFinite(value.staleAfterMs) && value.staleAfterMs > 0 ? value.staleAfterMs : 60000 };
162
- connected = true;
163
- ui['connection-state'].textContent = 'Local dashboard connected';
164
- ui['snapshot-time'].textContent = `Snapshot ${formatDate(snapshot.generatedAt)}`;
165
- renderNotice();
166
- syncFilters();
167
- renderRoster();
168
- renderEvents();
169
- } catch {
170
- connected = false;
171
- ui['connection-state'].textContent = 'Dashboard disconnected';
172
- renderNotice(true);
173
- if (snapshot) renderRoster();
174
- } finally {
175
- clearTimeout(timeout);
176
- setTimeout(refresh, refreshMs);
177
- }
178
- }
179
- document.querySelector('.filters').addEventListener('submit', event => event.preventDefault());
180
- for (const id of ['worker-filter', 'type-filter', 'errors-filter']) ui[id].addEventListener('change', renderEvents);
181
- void refresh();
182
- })();