@comput/pi-intercom 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { appendFile, lstat, mkdir, opendir, rename, stat, unlink } from 'node:fs/promises';
4
+
5
+ export const LOG_DIRECTORY = '.pi-intercom/logs';
6
+ export const LOG_FILE_PATTERN = /^writer-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl(?:\.[12]|\.closed)?$/;
7
+ export const LOG_LIMITS = { fileBytes: 1024 * 1024, filesPerInstance: 3, queueEntries: 256, entryBytes: 4096, retentionMs: 7 * 24 * 60 * 60 * 1000, directoryBytes: 32 * 1024 * 1024, scanEntries: 4096, closeTimeoutMs: 250 } as const;
8
+ export const EVENT_TYPES = ['runtime.starting', 'runtime.ready', 'runtime.closed', 'runtime.failed', 'config.changed', 'config.reloaded', 'transport.send', 'transport.receipt', 'transport.failed', 'transport.received', 'transport.rejected', 'registration.received', 'status.received', 'launch.result', 'host.submission', 'host.activity', 'host.ui_prompt'] as const;
9
+ export type EventType = typeof EVENT_TYPES[number];
10
+ const OUTCOMES = ['attempted', 'returned', 'failed', 'http_receipt', 'handler_failed', 'started', 'settled', 'snapshot', 'ended', 'written', 'removed', 'ready'] as const;
11
+ const ERROR_CODES = ['operation_failed', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC'] as const;
12
+ const KINDS = ['message', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'] as const;
13
+ const OPERATIONS = ['configure_worker', 'set_multiplexer', 'remove_worker', 'create_worker', 'resume_worker', 'port_update', 'initialize'] as const;
14
+ export interface EventMetadata {
15
+ peerSessionId?: string;
16
+ peerName?: string;
17
+ correlationId?: string;
18
+ kind?: typeof KINDS[number];
19
+ busy?: boolean;
20
+ port?: number;
21
+ role?: 'coordinator' | 'worker' | 'anonymous';
22
+ outcome?: typeof OUTCOMES[number];
23
+ errorCode?: typeof ERROR_CODES[number];
24
+ operation?: typeof OPERATIONS[number];
25
+ }
26
+ export interface Observation extends EventMetadata {
27
+ version: 1;
28
+ timestamp: string;
29
+ /** Log-file writer identity only: never a transport or agent identity. */
30
+ writerId: string;
31
+ sessionId: string;
32
+ event: EventType;
33
+ }
34
+ export interface Observer { record(event: EventType, metadata?: EventMetadata): void; close(): Promise<void>; maintain?(): void }
35
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
36
+ function safeText(value: unknown, max: number): value is string {
37
+ return typeof value === 'string' && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
38
+ }
39
+ function includes<T extends string>(list: readonly T[], value: unknown): value is T { return typeof value === 'string' && list.includes(value as T); }
40
+ /** Project only explicitly allowed metadata, including when reading untrusted on-disk logs. */
41
+ export function sanitizeObservation(value: unknown): Observation | undefined {
42
+ if (!value || typeof value !== 'object') return;
43
+ const v = value as Record<string, unknown>;
44
+ if (v.version !== 1 || !includes(EVENT_TYPES, v.event) || !safeText(v.sessionId, 256) || typeof v.writerId !== 'string' || !uuid.test(v.writerId)) return;
45
+ if (typeof v.timestamp !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v.timestamp) || !Number.isFinite(Date.parse(v.timestamp))) return;
46
+ const result: Observation = { version: 1, timestamp: v.timestamp, writerId: v.writerId, sessionId: v.sessionId, event: v.event };
47
+ if (safeText(v.peerSessionId, 256)) result.peerSessionId = v.peerSessionId;
48
+ if (safeText(v.peerName, 128)) result.peerName = v.peerName;
49
+ if (safeText(v.correlationId, 128) && /^[a-zA-Z0-9-]+$/.test(v.correlationId)) result.correlationId = v.correlationId;
50
+ if (includes(KINDS, v.kind)) result.kind = v.kind;
51
+ if (typeof v.busy === 'boolean') result.busy = v.busy;
52
+ if (Number.isInteger(v.port) && Number(v.port) >= 1 && Number(v.port) <= 65535) result.port = Number(v.port);
53
+ if (includes(['coordinator', 'worker', 'anonymous'], v.role)) result.role = v.role;
54
+ if (includes(OUTCOMES, v.outcome)) result.outcome = v.outcome;
55
+ if (includes(ERROR_CODES, v.errorCode)) result.errorCode = v.errorCode;
56
+ if (includes(OPERATIONS, v.operation)) result.operation = v.operation;
57
+ return result;
58
+ }
59
+ /** Never inspect error.message/stack, response bodies, paths, or arbitrary error codes. */
60
+ export function observationError(error: unknown): EventMetadata['errorCode'] {
61
+ try {
62
+ const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined;
63
+ return includes(ERROR_CODES, code) ? code : 'operation_failed';
64
+ } catch { return 'operation_failed'; }
65
+ }
66
+ export class LocalObserver implements Observer {
67
+ readonly writerId = randomUUID();
68
+ readonly directory: string;
69
+ readonly file: string;
70
+ private queue: string[] = [];
71
+ private pending?: Promise<void>;
72
+ private initialized = false;
73
+ private bytes = 0;
74
+ private closed = false;
75
+ private disabled = false;
76
+ private maintenance = false;
77
+ private timer?: ReturnType<typeof setInterval>;
78
+ dropped = 0;
79
+ constructor(root: string, readonly sessionId: string, private readonly limits: { fileBytes?: number; queueEntries?: number } = {}) {
80
+ this.directory = path.join(root, LOG_DIRECTORY);
81
+ this.file = path.join(this.directory, `writer-${this.writerId}.jsonl`);
82
+ }
83
+ record(event: EventType, metadata: EventMetadata = {}): void {
84
+ try {
85
+ if (this.closed || this.disabled) return;
86
+ const observation = sanitizeObservation({ ...metadata, version: 1, timestamp: new Date().toISOString(), writerId: this.writerId, sessionId: this.sessionId, event });
87
+ if (!observation) return;
88
+ const line = JSON.stringify(observation) + '\n';
89
+ const capacity = Math.max(1, Math.min(LOG_LIMITS.queueEntries, this.limits.queueEntries ?? LOG_LIMITS.queueEntries));
90
+ if (Buffer.byteLength(line) > LOG_LIMITS.entryBytes || this.queue.length >= capacity) { this.dropped++; return; }
91
+ this.queue.push(line);
92
+ this.schedule();
93
+ } catch { /* Observability must never throw into communication. */ }
94
+ }
95
+ private schedule(): void {
96
+ if (this.pending || this.disabled) return;
97
+ this.pending = Promise.resolve().then(() => this.drain()).catch(() => {
98
+ // Disable this writer after an I/O failure; no retry queue and no communication failure.
99
+ this.disabled = true; this.dropped += this.queue.length; this.queue = [];
100
+ }).finally(() => {
101
+ this.pending = undefined;
102
+ if (this.queue.length && !this.disabled) this.schedule();
103
+ });
104
+ }
105
+ /** Runtime calls only on the coordinator. No process probes or activity inference. */
106
+ maintain(): void {
107
+ if (this.closed || this.disabled || this.timer) return;
108
+ const request = () => { this.maintenance = true; this.schedule(); };
109
+ this.timer = setInterval(request, 60_000); this.timer.unref(); request();
110
+ }
111
+ protected append(file: string, line: string): Promise<void> { return appendFile(file, line, { encoding: 'utf8', mode: 0o600 }); }
112
+ private async prune(): Promise<void> {
113
+ const files: { file: string; base: string; mtime: number; bytes: number }[] = [];
114
+ const closed = new Set<string>();
115
+ let scanned = 0;
116
+ for await (const entry of await opendir(this.directory)) {
117
+ if (++scanned > LOG_LIMITS.scanEntries) break;
118
+ if (!entry.isFile() || !LOG_FILE_PATTERN.test(entry.name)) continue;
119
+ const file = path.join(this.directory, entry.name);
120
+ const base = file.replace(/\.(?:closed|[12])$/, '');
121
+ if (file.endsWith('.closed')) closed.add(base);
122
+ const info = await stat(file).catch(() => undefined);
123
+ if (info) files.push({ file, base, mtime: info.mtimeMs, bytes: info.size });
124
+ }
125
+ let total = files.reduce((sum, file) => sum + file.bytes, 0);
126
+ const groups = [...closed].map(base => {
127
+ const members = files.filter(file => file.base === base);
128
+ return { members, newest: Math.max(...members.map(file => file.mtime)) };
129
+ }).sort((a, b) => a.newest - b.newest);
130
+ for (const group of groups) {
131
+ // Only gracefully closed writer groups are eligible. Active/crashed writers stay untouched.
132
+ if (Date.now() - group.newest > LOG_LIMITS.retentionMs || total > LOG_LIMITS.directoryBytes) {
133
+ // Remove the closed marker last, retaining eligibility if an earlier unlink fails.
134
+ for (const file of group.members.sort((a, b) => Number(a.file.endsWith('.closed')) - Number(b.file.endsWith('.closed')))) {
135
+ try { await unlink(file.file); total -= file.bytes; } catch { break; }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ private async drain(): Promise<void> {
141
+ if (!this.initialized) {
142
+ const parent = path.dirname(this.directory);
143
+ await mkdir(parent, { recursive: true, mode: 0o700 });
144
+ if ((await lstat(parent)).isSymbolicLink()) throw new Error('log parent must not be a symlink');
145
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
146
+ if ((await lstat(this.directory)).isSymbolicLink()) throw new Error('log directory must not be a symlink');
147
+ this.initialized = true;
148
+ }
149
+ if (this.maintenance) { this.maintenance = false; await this.prune().catch(() => {}); }
150
+ const maxBytes = Math.max(LOG_LIMITS.entryBytes, Math.min(LOG_LIMITS.fileBytes, this.limits.fileBytes ?? LOG_LIMITS.fileBytes));
151
+ while (this.queue.length) {
152
+ const line = this.queue.shift()!, size = Buffer.byteLength(line);
153
+ if (this.bytes + size > maxBytes) {
154
+ await unlink(`${this.file}.2`).catch(e => { if (e.code !== 'ENOENT') throw e; });
155
+ await rename(`${this.file}.1`, `${this.file}.2`).catch(e => { if (e.code !== 'ENOENT') throw e; });
156
+ await rename(this.file, `${this.file}.1`).catch(e => { if (e.code !== 'ENOENT') throw e; });
157
+ this.bytes = 0;
158
+ }
159
+ await this.append(this.file, line); this.bytes += size;
160
+ }
161
+ }
162
+ async close(): Promise<void> {
163
+ if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
164
+ this.closed = true;
165
+ while (this.pending) await this.pending;
166
+ // An atomic suffix change is the sole inactive-writer signal. Crashes leave no such signal.
167
+ if (this.initialized && !this.disabled) await rename(this.file, `${this.file}.closed`).catch(() => {});
168
+ }
169
+ }
package/src/runtime.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
- import { ConfigStore, coordinator, directory, fail, named, requireCoordinator, type Agent, type Config } from './config.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { LocalObserver, LOG_LIMITS, observationError, type Observer, type EventType, type EventMetadata } from './observability.js';
4
+ import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, type Agent, type Config } from './config.js';
3
5
  import { listen, send, type Endpoint, type Envelope, type Kind } from './transport.js';
4
6
 
5
7
  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).';
@@ -17,81 +19,200 @@ export interface RuntimeOptions {
17
19
  listen?: typeof listen;
18
20
  send?: typeof send;
19
21
  store?: (root: string) => ConfigStore;
22
+ observe?: (root: string, sessionId: string) => Observer;
20
23
  }
21
24
  export class Intercom {
22
25
  store!: ConfigStore;
23
26
  endpoint?: Endpoint;
24
27
  private active = false;
28
+ private generation = 0;
25
29
  private initialId = '';
26
30
  responsibility?: Agent;
31
+ private observer?: Observer;
32
+ private closing?: Promise<void>;
33
+ private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
34
+ const observer = this.observer;
35
+ return (event, metadata = {}) => {
36
+ try { observer?.record(event, metadata); } catch { /* Logging cannot affect communication. */ }
37
+ };
38
+ }
39
+ recordObservation(event: EventType, metadata: EventMetadata = {}): void { this.captureObserver()(event, metadata); }
27
40
  constructor(readonly host: Host, readonly options: RuntimeOptions) {}
28
41
  private id(): string {
29
42
  const id = this.host.sessionId();
30
43
  if (!this.active || id !== this.initialId) fail('session is inactive or replaced');
31
44
  return id;
32
45
  }
46
+ private validity(): () => void {
47
+ const generation = this.generation;
48
+ this.id();
49
+ return () => {
50
+ this.id();
51
+ if (generation !== this.generation) fail('runtime lifecycle replaced');
52
+ };
53
+ }
33
54
  async start(): Promise<void> {
55
+ if (this.closing) await this.closing;
56
+ if (this.active) fail('runtime already active');
34
57
  this.initialId = this.host.sessionId(); this.active = true;
58
+ const generation = ++this.generation, assertValid = this.validity();
59
+ let record = this.captureObserver();
35
60
  try {
36
61
  const discovered = await ConfigStore.discover(this.host.cwd);
62
+ assertValid();
37
63
  this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
64
+ try { this.observer = this.options.observe ? this.options.observe(this.store.root, this.initialId) : new LocalObserver(this.store.root, this.initialId); } catch { /* Logging is optional on failure. */ }
65
+ record = this.captureObserver();
66
+ record('runtime.starting');
38
67
  const previous = discovered ? await this.store.read() : undefined;
68
+ assertValid();
39
69
  const own = previous?.agents.find(a => a.sessionId === this.id());
40
- this.endpoint = await (this.options.listen ?? listen)(own?.port, m => this.receive(m));
41
- if (!previous) await this.store.initialize(this.id(), this.endpoint.port);
70
+ const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); });
71
+ try { assertValid(); } catch (error) { await endpoint.close(); throw error; }
72
+ this.endpoint = endpoint;
73
+ if (!previous) {
74
+ const initialized = await this.store.initialize(this.id(), this.endpoint.port, assertValid);
75
+ if (initialized) record('config.changed', { operation: 'initialize', outcome: 'written' });
76
+ }
42
77
  const config = await this.store.read();
78
+ assertValid();
43
79
  const me = config.agents.find(a => a.sessionId === this.id());
44
80
  if (me?.coordinator) {
45
- await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; });
46
- try { await this.reload(); } catch (e) { this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); }
81
+ try { this.observer?.maintain?.(); } catch { /* No cleanup failure affects readiness. */ }
82
+ await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
83
+ assertValid();
84
+ record('config.changed', { operation: 'port_update', outcome: 'written', port: this.endpoint.port });
85
+ try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); }
86
+ assertValid();
47
87
  this.host.notify(`Intercom Coordinator ready at 127.0.0.1:${this.endpoint.port}; waiting for user input.`);
48
88
  } else {
49
- if (me) { try { await this.reload(); } catch (e) { this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); } }
89
+ if (me) { try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); } }
90
+ assertValid();
50
91
  try {
51
92
  if (me) await this.report();
52
- else await this.transmit(coordinator(config).name, 'registration', { port: this.endpoint.port, projectDirectory: await directory(this.store.root, this.host.cwd) });
53
- } catch (e) { this.host.notify(`Coordinator unreachable/registration failed; worker remains reachable: ${String(e)}. No retry. Anonymous registration repeat is an unresolved contract; use explicit Pi /reload to restart this extension, not report_status.`); }
93
+ else {
94
+ const projectDirectory = await directory(this.store.root, this.host.cwd);
95
+ assertValid();
96
+ await this.transmit(coordinator(config).name, 'registration', { port: this.endpoint.port, projectDirectory });
97
+ }
98
+ } catch (e) { assertValid(); this.host.notify(`Coordinator unreachable/registration failed; worker remains reachable: ${String(e)}. No retry. Anonymous registration repeat is an unresolved contract; use explicit Pi /reload to restart this extension, not report_status.`); }
99
+ assertValid();
54
100
  this.host.notify(`Intercom worker ready at 127.0.0.1:${this.endpoint.port}; waiting for explicit work.`);
55
101
  }
56
- } catch (e) { await this.close(); throw e; }
102
+ record('runtime.ready', { port: this.endpoint.port, role: me?.coordinator ? 'coordinator' : me ? 'worker' : 'anonymous', busy: this.host.busy(), outcome: 'ready' });
103
+ } catch (e) {
104
+ if (generation === this.generation) {
105
+ record('runtime.failed', { errorCode: observationError(e), outcome: 'failed' });
106
+ await this.close();
107
+ }
108
+ throw e;
109
+ }
57
110
  }
58
111
  async close(): Promise<void> {
59
- this.active = false;
112
+ if (this.closing) return this.closing;
113
+ this.recordObservation('runtime.closed');
114
+ const observer = this.observer; this.observer = undefined;
115
+ this.active = false; this.generation++;
60
116
  const endpoint = this.endpoint; this.endpoint = undefined;
61
- if (endpoint) await endpoint.close();
62
117
  this.responsibility = undefined;
118
+ const closing = (async () => {
119
+ try { if (endpoint) await endpoint.close(); }
120
+ finally {
121
+ if (observer) {
122
+ let timer: ReturnType<typeof setTimeout> | undefined;
123
+ try {
124
+ // Only shutdown waits, and only for this bounded deadline. Already-issued I/O cannot be cancelled.
125
+ await Promise.race([
126
+ Promise.resolve().then(() => observer.close()).catch(() => {}),
127
+ new Promise<void>(resolve => { timer = setTimeout(resolve, LOG_LIMITS.closeTimeoutMs); }),
128
+ ]);
129
+ } finally { if (timer) clearTimeout(timer); }
130
+ }
131
+ }
132
+ })();
133
+ this.closing = closing;
134
+ try { await closing; } finally { if (this.closing === closing) this.closing = undefined; }
63
135
  }
64
136
  async state(): Promise<{ id: string; config: Config; me?: Agent }> {
137
+ const assertValid = this.validity();
65
138
  const id = this.id(), config = await this.store.read();
66
- this.id();
139
+ assertValid();
67
140
  return { id, config, me: config.agents.find(a => a.sessionId === id) };
68
141
  }
142
+ /** Persist an actual dashboard bind; saved discovery metadata, not a live-status assertion. */
143
+ async recordDashboardPort(boundPort: number): Promise<void> {
144
+ const record = this.captureObserver(), assertValid = this.validity();
145
+ 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);
148
+ record('config.changed', { outcome: 'written' });
149
+ }
69
150
  async reload(): Promise<void> {
151
+ const record = this.captureObserver();
152
+ const assertValid = this.validity();
70
153
  const { me } = await this.state();
154
+ assertValid();
71
155
  if (!me) fail('worker not configured; configure_worker must run before reload_worker');
72
156
  this.responsibility = { ...me };
73
157
  await this.host.setName(me.name);
158
+ assertValid();
159
+ record('config.reloaded', { role: me.coordinator ? 'coordinator' : 'worker' });
74
160
  this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
75
161
  }
76
162
  async transmit(to: string, kind: Kind, payload: Record<string, unknown> = {}): Promise<void> {
163
+ const record = this.captureObserver();
164
+ const assertValid = this.validity();
77
165
  const { id, config } = await this.state();
78
- const target = named(config, to);
79
- await (this.options.send ?? send)(target.port, { version: 1, from: id, to: target.sessionId, kind, payload });
166
+ assertValid();
167
+ const target = named(config, to), correlationId = randomUUID();
168
+ const metadata = { peerSessionId: target.sessionId, peerName: target.name, correlationId, kind };
169
+ record('transport.send', { ...metadata, outcome: 'attempted' });
170
+ try {
171
+ await (this.options.send ?? send)(target.port, { version: 1, from: id, to: target.sessionId, kind, payload, correlationId });
172
+ record('transport.receipt', { ...metadata, outcome: 'http_receipt' });
173
+ } catch (error) {
174
+ record('transport.failed', { ...metadata, outcome: 'failed', errorCode: observationError(error) });
175
+ throw error;
176
+ }
80
177
  }
81
178
  async report(): Promise<void> {
179
+ const assertValid = this.validity();
82
180
  const { config, me } = await this.state();
181
+ assertValid();
83
182
  if (me?.coordinator) fail('report_status is worker-only');
84
183
  if (!this.endpoint) fail('endpoint unavailable');
85
184
  await this.transmit(coordinator(config).name, 'status', { port: this.endpoint.port, busy: this.host.busy() });
86
185
  }
87
186
  async receive(message: Envelope): Promise<void> {
187
+ const record = this.captureObserver();
188
+ const metadata = { peerSessionId: message.from, correlationId: message.correlationId, kind: message.kind };
189
+ record('transport.received', metadata);
190
+ try { await this.handleReceive(message); }
191
+ catch (error) {
192
+ record('transport.rejected', { ...metadata, outcome: 'handler_failed', errorCode: observationError(error) });
193
+ throw error;
194
+ }
195
+ }
196
+ private async handleReceive(message: Envelope): Promise<void> {
197
+ const record = this.captureObserver();
198
+ const assertValid = this.validity();
88
199
  const { id, config, me } = await this.state();
200
+ assertValid();
89
201
  if (message.to !== id) fail('recipient session ID mismatch (stale endpoint)');
90
202
  const sender = config.agents.find(a => a.sessionId === message.from);
91
203
  const busy = this.host.busy();
204
+ const metadata = { peerSessionId: message.from, peerName: sender?.name, correlationId: message.correlationId, kind: message.kind, busy };
92
205
  const notify = (purpose: string, content: string) => {
93
- this.id();
94
- this.host.deliver(`[Intercom ${purpose} from ${sender?.name ?? 'unconfigured worker'} (${message.from})]\n${content}`, busy);
206
+ assertValid();
207
+ record('host.submission', { ...metadata, outcome: 'attempted' });
208
+ try {
209
+ this.host.deliver(`[Intercom ${purpose} from ${sender?.name ?? 'unconfigured worker'} (${message.from})]\n${content}`, busy);
210
+ // A synchronous return is not Pi queue acceptance or model completion.
211
+ record('host.submission', { ...metadata, outcome: 'returned' });
212
+ } catch (error) {
213
+ record('host.submission', { ...metadata, outcome: 'failed', errorCode: observationError(error) });
214
+ throw error;
215
+ }
95
216
  };
96
217
  if (message.kind === 'registration' || message.kind === 'status') {
97
218
  requireCoordinator(config, id);
@@ -99,12 +220,15 @@ export class Intercom {
99
220
  if (message.kind === 'registration') {
100
221
  const projectDirectory = await directory(this.store.root, message.payload.projectDirectory as string);
101
222
  // No pending map, placeholder or configuration mutation.
223
+ record('registration.received', { ...metadata, port: message.payload.port as number });
102
224
  notify('registration', JSON.stringify({ sessionId: message.from, port: message.payload.port, projectDirectory }) + '\nThis is connection information, not a work assignment. Configure explicitly, then reload separately.');
103
225
  } else {
104
226
  if (sender) await this.store.update(id, c => {
105
227
  const entry = c.agents.find(a => a.sessionId === message.from && !a.coordinator);
106
228
  if (entry) entry.port = message.payload.port as number;
107
- });
229
+ }, assertValid);
230
+ record('status.received', { ...metadata, busy: message.payload.busy as boolean, port: message.payload.port as number });
231
+ if (sender) record('config.changed', { operation: 'port_update', peerSessionId: message.from, port: message.payload.port as number, outcome: 'written' });
108
232
  notify('status', JSON.stringify({ sessionId: message.from, port: message.payload.port, busy: message.payload.busy, configured: !!sender }));
109
233
  }
110
234
  return;
@@ -120,16 +244,26 @@ export class Intercom {
120
244
  if (message.kind === 'reload') { await this.reload(); return; }
121
245
  if (message.kind === 'request_status') {
122
246
  // Independent one-way report, never a synchronous status in the HTTP response.
123
- setImmediate(() => { if (this.active) void this.report().catch(e => this.host.notify(`Status report failed: ${String(e)}`)); });
247
+ setImmediate(() => {
248
+ try { assertValid(); } catch { return; } // Closed/replaced requests cannot report for a new lifecycle.
249
+ void this.report().catch(e => {
250
+ try { assertValid(); } catch { return; }
251
+ this.host.notify(`Status report failed: ${String(e)}`);
252
+ });
253
+ });
124
254
  return;
125
255
  }
126
256
  fail('unsupported control');
127
257
  }
128
258
  async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
259
+ const record = this.captureObserver();
260
+ const assertValid = this.validity();
129
261
  const { id, config, me } = await this.state();
262
+ assertValid();
130
263
  if (operation === 'list') {
131
264
  if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
132
- return config;
265
+ const dashboardPort = coordinator(config).dashboardPort;
266
+ return { ...config, dashboardUrl: dashboardPort === undefined ? null : `http://127.0.0.1:${dashboardPort}/` };
133
267
  }
134
268
  if (operation === 'report_status') { await this.report(); return { accepted: true }; }
135
269
  if (operation === 'send') {
@@ -139,15 +273,18 @@ export class Intercom {
139
273
  requireCoordinator(config, id);
140
274
  if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
141
275
  if (operation === 'configure_worker') {
142
- await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>);
276
+ await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertValid);
277
+ record('config.changed', { operation: 'configure_worker', peerSessionId: args.sessionId as string, peerName: args.name as string, outcome: 'written' });
143
278
  return { written: true, reloaded: false };
144
279
  }
145
280
  if (operation === 'set_multiplexer') {
146
- await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }); return { written: true };
281
+ await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }, assertValid);
282
+ record('config.changed', { operation: 'set_multiplexer', outcome: 'written' }); return { written: true };
147
283
  }
148
284
  if (operation === 'create_worker') {
149
285
  const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
150
- return this.options.launch({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
286
+ assertValid();
287
+ return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
151
288
  }
152
289
  const target = named(config, args.to as string);
153
290
  if (target.coordinator) fail('operation requires a worker target');
@@ -157,13 +294,28 @@ export class Intercom {
157
294
  }
158
295
  if (operation === 'resume_worker') {
159
296
  const relative = await directory(this.store.root, target.projectDirectory);
160
- return this.options.launch({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
297
+ assertValid();
298
+ return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
161
299
  }
162
300
  if (operation === 'remove_worker') {
163
301
  // No live process claim: the caller must explicitly ensure closure first.
164
- await this.store.update(id, c => { c.agents = c.agents.filter(a => a.sessionId !== target.sessionId); });
302
+ await this.store.update(id, c => { c.agents = c.agents.filter(a => a.sessionId !== target.sessionId); }, assertValid);
303
+ record('config.changed', { operation: 'remove_worker', peerSessionId: target.sessionId, peerName: target.name, outcome: 'removed' });
165
304
  return { removed: true, processStopped: false, sessionDeleted: false };
166
305
  }
167
306
  fail(`unknown operation ${operation}`);
168
307
  }
308
+ private async launchObserved(request: LaunchRequest): Promise<unknown> {
309
+ const record = this.captureObserver();
310
+ const metadata: EventMetadata = { operation: request.sessionId ? 'resume_worker' : 'create_worker', peerSessionId: request.sessionId };
311
+ record('launch.result', { ...metadata, outcome: 'attempted' });
312
+ try {
313
+ const result = await this.options.launch(request);
314
+ record('launch.result', { ...metadata, outcome: 'returned' });
315
+ return result;
316
+ } catch (error) {
317
+ record('launch.result', { ...metadata, outcome: 'failed', errorCode: observationError(error) });
318
+ throw error;
319
+ }
320
+ }
169
321
  }
package/src/transport.ts CHANGED
@@ -10,11 +10,14 @@ export interface Envelope {
10
10
  from: string;
11
11
  to: string;
12
12
  payload: Record<string, unknown>;
13
+ /** Optional diagnostics correlation only; never authentication or agent identity. */
14
+ correlationId?: string;
13
15
  }
14
16
  export function envelope(value: unknown): Envelope {
15
17
  const m = value as Envelope;
16
18
  if (!m || m.version !== 1 || !['message', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'].includes(m.kind)) fail('invalid wire schema/version/kind');
17
19
  text(m.from, 'sender sessionId', 256); text(m.to, 'recipient sessionId', 256);
20
+ if (m.correlationId !== undefined && (typeof m.correlationId !== 'string' || !/^[a-zA-Z0-9-]{1,128}$/.test(m.correlationId))) fail('invalid correlation ID');
18
21
  if (!m.payload || Array.isArray(m.payload) || typeof m.payload !== 'object') fail('invalid payload');
19
22
  if (m.kind === 'message') text(m.payload.message, 'message', 48000);
20
23
  if (m.kind === 'registration' || m.kind === 'status') port(m.payload.port);