@comput/pi-intercom 0.1.3 → 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.
- package/README.md +27 -3
- package/dashboard/app.js +182 -0
- package/dashboard/index.html +41 -0
- package/dashboard/style.css +82 -0
- package/package.json +4 -2
- package/references/observability.md +104 -0
- package/references/v1-specification.md +2 -2
- package/src/config.ts +6 -0
- package/src/dashboard.ts +158 -0
- package/src/index.ts +41 -3
- package/src/observability.ts +169 -0
- package/src/runtime.ts +119 -11
- package/src/transport.ts +3 -0
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Every tool rereads current config and checks the live Pi session ID. Names are c
|
|
|
56
56
|
| `intercom_configure_worker` | `sessionId`, `port`, `projectDirectory`, `name`, `description` | Coordinator; all five explicit fields, writes only |
|
|
57
57
|
| `intercom_reload_worker` | `to` (worker name) | Coordinator; passive config/name reload |
|
|
58
58
|
| `intercom_send` | `to` (name), `message` | Configured, responsibility-loaded sessions; asynchronous message |
|
|
59
|
-
| `intercom_list` | None | Configured
|
|
59
|
+
| `intercom_list` | None | Configured coordinator/workers; saved roster and derived `dashboardUrl`, never live health |
|
|
60
60
|
| `intercom_request_status` | `to` (worker name) | Coordinator; independent extension report |
|
|
61
61
|
| `intercom_report_status` | None | Workers including anonymous; shared report function, never registration |
|
|
62
62
|
| `intercom_stop_worker`, `intercom_close_worker` | `to` (worker name) | Coordinator role checked, then **unsupported-host error**, no side effects |
|
|
@@ -87,6 +87,8 @@ Shared, source-controlled file: `<root>/.pi-intercom/config.json`. Do not ignore
|
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
Version 0.2.0 dashboard support adds optional `dashboardPort` (integer 1–65535) on the **coordinator entry only**. Older config without it remains valid; workers cannot have it. This is separate from each agent's messaging `port`. `intercom_list` keeps `version`, `multiplexer` and `agents`, and adds top-level `dashboardUrl`: `http://127.0.0.1:<saved dashboardPort>/`, or `null` when no dashboard port is saved. It reads saved config without probing or starting a dashboard; the URL is last-known, not a liveness claim.
|
|
91
|
+
|
|
90
92
|
Workers have `coordinator:false` and an existing project directory equal to root or below it. Writes canonicalize directories and check real paths (including symlink escapes). Only coordinator extension operations write config; ordinary Pi file/shell tools are not restricted.
|
|
91
93
|
|
|
92
94
|
Initial creation writes/fsyncs a complete temporary file then atomically publishes via a no-replace hard link. Contenders see only complete config; exactly one wins. Unsupported hard-link filesystems fail explicitly, without unsafe fallback. Interrupted staging files can remain as ignored `.tmp` files but are never mistaken for config. Updates are serialized, join Pi's file-mutation queue, validate, fsync a temporary file and atomically rename. Duplicate activation of the same coordinator session in multiple processes and external editors racing writes remain outside V1's guarantees (session locks deferred).
|
|
@@ -101,7 +103,19 @@ Kinds: `message`, `registration` (`port`, `projectDirectory`), `status` (`port`,
|
|
|
101
103
|
|
|
102
104
|
`202 {"accepted":true}` means Intercom extension receipt/dispatch, **not guaranteed Pi input acceptance, queueing, or model completion**. In Pi 0.84.4, `sendUserMessage` can reject during manual compaction after Intercom has acknowledged receipt: its void extension API reports the asynchronous rejection only as a local host error. Intercom has no delivery queue or retry to repair this gap; inspect the recipient's local error and arrange an explicit resend after compaction if needed. Replacing it with `sendMessage` is not a safe workaround because that path can start a concurrent run. Invalid/unsupported requests return an error; excessive bodies return 413. Request-status sends its report separately after accepting control. Delivery is normal when idle and steering when busy, sampled at arrival. It does not hard-interrupt an executing tool. Each send resolves the recipient again from config; 5-second receipt deadline, **no retries**. Timeout means unknown outcome, not proof of nondelivery. No durable inbox, deduplication or replay log in V1.
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
Version 0.2.0 telemetry adds an optional envelope `correlationId` for one transport attempt; legacy envelopes without it remain accepted. It is not agent identity, deduplication, a task ID, or a reply/completion guarantee.
|
|
107
|
+
|
|
108
|
+
Tool text output is bounded to 50 KiB/2000 lines. The full saved roster remains in the shared config file; `dashboardUrl` is derived for list output. Name sync errors are surfaced; startup retains the useful endpoint, and explicit reload reports failure (Pi name/responsibility may already have changed before a Herdr tab rename fails).
|
|
109
|
+
|
|
110
|
+
## Local observation dashboard (0.2.0)
|
|
111
|
+
|
|
112
|
+
Current source adds metadata-only event logs and a coordinator-only, read-only localhost dashboard. Startup binds an OS-assigned port, then saves the actual port as the coordinator's `dashboardPort` and prints the local URL. Configured coordinator and worker sessions can retrieve the saved URL using `intercom_list`—no new dashboard tool or control. Open that URL in a local browser. The saved address can remain stale after shutdown or failure; it is not proof of reachability. Workers log metadata but do not start a dashboard. No browser opens automatically, and no agent controls, assignments or automatic recovery are added.
|
|
113
|
+
|
|
114
|
+
The roster shows saved names/responsibilities/endpoints plus **last-observed** busy/idle, timestamp and age. Evidence older than 60 seconds is marked stale, never offline; missing evidence is unknown. The timeline refreshes approximately every three seconds and can be filtered by agent, event type and errors. Expand events for safe metadata/correlation, not prompt bodies. A disconnected dashboard retains old data with an explicit warning.
|
|
115
|
+
|
|
116
|
+
Logs under `.pi-intercom/logs/` exclude message bodies and raw errors; roster responsibility text still comes from shared config, so do not put secrets there. `writerId` identifies an ephemeral log writer, not an agent; Pi session ID remains agent identity. Transport `correlationId` identifies one send attempt, not a task or completion. Logging is bounded/best effort and does not change HTTP receipt semantics or repair manual-compaction rejection. Never interpret a quiet timeline as completed work.
|
|
117
|
+
|
|
118
|
+
See [observability reference](references/observability.md) for privacy, rotation/retention limits, partial-error semantics and the HTTP boundary. This feature requires **version 0.2.0 or later**; update the package and reload each participating Pi session to activate it. The dashboard is not a durable inbox, replay log or task journal.
|
|
105
119
|
|
|
106
120
|
## Development and validation
|
|
107
121
|
|
|
@@ -131,6 +145,8 @@ Published versions are immutable. For a new release, bump package and lockfile v
|
|
|
131
145
|
- [v0.1.1](https://github.com/mbundgaard/PiIntercom/releases/tag/v0.1.1): successful npm OIDC [run 35466923379](https://github.com/mbundgaard/PiIntercom/actions/runs/35466923379).
|
|
132
146
|
- [v0.1.2](https://github.com/mbundgaard/PiIntercom/releases/tag/v0.1.2): successful npm OIDC [run 35467586052](https://github.com/mbundgaard/PiIntercom/actions/runs/35467586052). Fixes Windows Herdr launching by using `herdr pane run` on the returned pane ID with encoded PowerShell invoking `pi.ps1`, instead of the failing `Start-Process pi` wrapper. No Herdr agent alias is needed.
|
|
133
147
|
|
|
148
|
+
- [v0.1.3](https://github.com/mbundgaard/PiIntercom/releases/tag/v0.1.3): successful npm OIDC [run 35474304380](https://github.com/mbundgaard/PiIntercom/actions/runs/35474304380). Contains the fixes below, not the observability feature added in 0.2.0.
|
|
149
|
+
|
|
134
150
|
### 0.1.3 changes and verification
|
|
135
151
|
|
|
136
152
|
Version 0.1.3 includes these fixes (not present in 0.1.2):
|
|
@@ -139,7 +155,15 @@ Version 0.1.3 includes these fixes (not present in 0.1.2):
|
|
|
139
155
|
- Failed temporary-file write, sync or close attempts best-effort close/unlink cleanup while preserving the primary error. Filesystem cleanup failures can still leave temporary files.
|
|
140
156
|
- Resume preflight now follows the child session-storage context: `PI_CODING_AGENT_SESSION_DIR`, then target-project/global `sessionDir` settings, then Pi's default. Relative storage paths resolve against the target working directory. Launchers do not propagate the coordinator's CLI `--session-dir`; no machine-specific session path is stored in shared config. A static persisted-session fixture and mocked launcher validate lookup, not live resumed-worker startup.
|
|
141
157
|
|
|
142
|
-
|
|
158
|
+
Pre-observability fix milestone: coordinator validation recorded `npm run typecheck` PASS; `npm test` **36/36**, no failures; `npm run check:package` PASS; `git diff --check` PASS (line-ending warnings only). Quality independently confirmed typecheck and all 36 tests, with no remaining actionable regression identified in those fixes. This count predates the logging/dashboard changes; it is not their aggregate validation. Tests include actual extension-adapter event handling against a mocked Pi API/context and runtime-to-runtime loopback HTTP; they do not establish real Pi queue or compaction behavior.
|
|
159
|
+
|
|
160
|
+
### 0.2.0 observability validation
|
|
161
|
+
|
|
162
|
+
Coordinator and Quality independently ran `npm run typecheck && npm test`: **63/63 tests passed, zero skips**. Coordinator also confirmed the 15-file package dry-run (dashboard assets and observability reference included; runtime logs excluded) and a clean `git diff --check`.
|
|
163
|
+
|
|
164
|
+
Coverage includes bounded metadata logging, transport/runtime instrumentation, read-only dashboard HTTP/security, actual UI code against hostile-text DOM fixtures, actual bound-port persistence before readiness, passive saved-URL discovery, bind/persistence failures and shutdown races. These use mocked Pi hosts and test-only loopback services; no live Pi dashboard startup or URL-liveness guarantee is claimed.
|
|
165
|
+
|
|
166
|
+
A separate synthetic-browser smoke check verified desktop rendering, literal hostile labels, unknown/stale evidence, error filtering and expandable metadata. Browser native-click automation timed out; programmatic DOM clicks verified the interactions instead, so this is not a full keyboard/mouse accessibility or live Pi end-to-end test.
|
|
143
167
|
|
|
144
168
|
Tests use temporary directories, local test-only HTTP servers, static session fixtures and mocked Pi hosts/launch executors. They never start agents, tabs, terminals, contact models, services or bridges, or access credentials.
|
|
145
169
|
|
package/dashboard/app.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
})();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light">
|
|
7
|
+
<title>PiIntercom · Observation ledger</title>
|
|
8
|
+
<link rel="stylesheet" href="/style.css">
|
|
9
|
+
<script src="/app.js" defer></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<a class="skip-link" href="#timeline">Skip to event timeline</a>
|
|
13
|
+
<header class="masthead">
|
|
14
|
+
<div><p class="eyebrow">PiIntercom / Local observation</p><h1>Communication, observed.</h1>
|
|
15
|
+
<p class="intro">A read-only record of agents and their exchanges. Pi decides the work.</p></div>
|
|
16
|
+
<div class="connection"><span id="connection-state" role="status">Connecting to local dashboard…</span><p id="snapshot-time">No snapshot received</p><span class="read-only">Read only · Refreshes every 3 seconds</span></div>
|
|
17
|
+
</header>
|
|
18
|
+
<main>
|
|
19
|
+
<div id="notice" class="notice" role="status" hidden></div>
|
|
20
|
+
<div class="workspace">
|
|
21
|
+
<section class="roster-panel" aria-labelledby="roster-heading">
|
|
22
|
+
<div class="section-heading"><h2 id="roster-heading">Configured agents</h2><span id="roster-count" class="count">—</span></div>
|
|
23
|
+
<p class="help">Saved endpoints are not proof of a running process. Busy and idle are last-observed snapshots, not availability.</p>
|
|
24
|
+
<div id="roster" class="roster"><p class="empty">Waiting for the saved roster.</p></div>
|
|
25
|
+
</section>
|
|
26
|
+
<section id="timeline" class="timeline-panel" aria-labelledby="timeline-heading" tabindex="-1">
|
|
27
|
+
<div class="section-heading"><h2 id="timeline-heading">Exchange ledger</h2><span id="event-count" class="count">—</span></div>
|
|
28
|
+
<p class="help">Metadata only. Receipt does not prove Pi accepted the input or completed work.</p>
|
|
29
|
+
<form class="filters" aria-label="Filter event timeline">
|
|
30
|
+
<div><label for="worker-filter">Agent</label><select id="worker-filter"><option value="">All agents</option></select></div>
|
|
31
|
+
<div><label for="type-filter">Event type</label><select id="type-filter"><option value="">All types</option></select></div>
|
|
32
|
+
<label class="checkbox"><input id="errors-filter" type="checkbox">Errors only</label>
|
|
33
|
+
</form>
|
|
34
|
+
<p id="event-window" class="window-note">Waiting for event metadata.</p>
|
|
35
|
+
<div id="events" class="events"><p class="empty">The ledger will appear after the first snapshot.</p></div>
|
|
36
|
+
</section>
|
|
37
|
+
</div>
|
|
38
|
+
</main>
|
|
39
|
+
<footer><strong>Observe, don't infer.</strong> A quiet timeline may mean no recent events, unavailable logging, or an unreachable dashboard—not completed work. No assignments or agent controls are available here.</footer>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--canvas: #e9eff5;
|
|
3
|
+
--paper: #ffffff;
|
|
4
|
+
--ink: #20354b;
|
|
5
|
+
--muted: #526477;
|
|
6
|
+
--blue: #326080;
|
|
7
|
+
--line: #c7d3df;
|
|
8
|
+
--wash: #f3f6fa;
|
|
9
|
+
--warning: #854617;
|
|
10
|
+
--warning-wash: #fff3e6;
|
|
11
|
+
font-family: "Segoe UI", system-ui, sans-serif;
|
|
12
|
+
color: var(--ink);
|
|
13
|
+
background: var(--canvas);
|
|
14
|
+
font-synthesis: none;
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
body { margin: 0; }
|
|
18
|
+
[hidden] { display: none !important; }
|
|
19
|
+
a { color: var(--blue); }
|
|
20
|
+
:focus-visible { outline: 3px solid var(--blue); outline-offset: 4px; }
|
|
21
|
+
.skip-link { position: absolute; left: 16px; top: -80px; padding: 12px; background: var(--paper); z-index: 2; }
|
|
22
|
+
.skip-link:focus { top: 12px; }
|
|
23
|
+
.masthead { display: flex; justify-content: space-between; gap: 32px; padding: 38px clamp(20px, 4vw, 64px) 30px; border-top: 7px solid var(--blue); }
|
|
24
|
+
.eyebrow { margin: 0 0 12px; color: var(--blue); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; font-weight: 750; }
|
|
25
|
+
h1 { font: 400 clamp(2rem, 3.3vw, 3.2rem)/1.08 Georgia, "Times New Roman", serif; margin: 0; letter-spacing: -.025em; }
|
|
26
|
+
.intro { color: var(--muted); line-height: 1.5; margin: 14px 0 0; }
|
|
27
|
+
.connection { align-self: center; flex: 0 0 275px; border-left: 2px solid var(--line); padding-left: 22px; font-size: .86rem; }
|
|
28
|
+
#connection-state { font-weight: 700; }
|
|
29
|
+
#snapshot-time { color: var(--muted); margin: 7px 0 12px; line-height: 1.4; }
|
|
30
|
+
.read-only { font: .72rem/1.5 Consolas, monospace; color: var(--muted); }
|
|
31
|
+
main { margin: 0 clamp(16px, 4vw, 64px); }
|
|
32
|
+
.notice { background: var(--warning-wash); border-left: 4px solid var(--warning); padding: 14px 18px; margin-bottom: 18px; line-height: 1.5; color: var(--warning); }
|
|
33
|
+
.workspace { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); border: 1px solid var(--line); background: var(--paper); border-radius: 8px; overflow: hidden; }
|
|
34
|
+
.roster-panel { background: var(--wash); border-right: 1px solid var(--line); padding: 26px 22px; }
|
|
35
|
+
.timeline-panel { padding: 26px clamp(18px, 2.5vw, 36px); min-width: 0; }
|
|
36
|
+
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
37
|
+
h2 { margin: 0; font-size: 1.1rem; font-weight: 700; }
|
|
38
|
+
.count { font: .8rem Consolas, monospace; color: var(--muted); }
|
|
39
|
+
.help { color: var(--muted); font-size: .82rem; line-height: 1.55; margin: 12px 0 23px; }
|
|
40
|
+
.agent { border-top: 1px solid var(--line); padding: 20px 0; overflow-wrap: anywhere; }
|
|
41
|
+
.agent h3 { font-size: 1rem; margin: 0 0 7px; }
|
|
42
|
+
.agent-role { font: .7rem Consolas, monospace; color: var(--blue); text-transform: uppercase; letter-spacing: .06em; }
|
|
43
|
+
.responsibility { font-size: .84rem; line-height: 1.55; margin: 10px 0 14px; }
|
|
44
|
+
.endpoint, .identity { font: .72rem/1.5 Consolas, monospace; color: var(--muted); margin: 4px 0; }
|
|
45
|
+
.observation { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 15px; }
|
|
46
|
+
.badge { display: inline-block; padding: 4px 8px; border: 1px solid var(--line); border-radius: 4px; font-size: .72rem; font-weight: 650; color: var(--muted); background: var(--paper); }
|
|
47
|
+
.badge-observed { color: var(--blue); border-color: #a8bfce; }
|
|
48
|
+
.badge-stale, .badge-error { color: var(--warning); border-color: #d5b594; background: var(--warning-wash); }
|
|
49
|
+
.observed-time { color: var(--muted); font-size: .72rem; line-height: 1.5; margin: 8px 0 0; }
|
|
50
|
+
.filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px; padding: 16px; background: var(--wash); border-radius: 5px; }
|
|
51
|
+
.filters > div { flex: 1 1 160px; min-width: 0; }
|
|
52
|
+
.filters label { display: block; font-size: .76rem; font-weight: 650; margin-bottom: 7px; }
|
|
53
|
+
select { width: 100%; min-height: 38px; font: inherit; font-size: .84rem; border: 1px solid #98acbf; border-radius: 3px; padding: 7px 24px 7px 9px; color: var(--ink); background: var(--paper); }
|
|
54
|
+
.filters .checkbox { display: flex; gap: 7px; align-items: center; min-height: 38px; margin: 0; }
|
|
55
|
+
input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--blue); }
|
|
56
|
+
.window-note { font-size: .73rem; color: var(--muted); line-height: 1.5; margin: 16px 0; }
|
|
57
|
+
.event { border-top: 1px solid var(--line); padding: 15px 0; }
|
|
58
|
+
.event summary { display: grid; grid-template-columns: 82px minmax(0, 1fr) auto; gap: 12px; cursor: pointer; align-items: baseline; list-style: none; }
|
|
59
|
+
.event summary::-webkit-details-marker { display: none; }
|
|
60
|
+
.event summary::after { content: "+"; color: var(--blue); font: 1.15rem Consolas, monospace; }
|
|
61
|
+
.event[open] summary::after { content: "−"; }
|
|
62
|
+
.event-time { font: .76rem/1.5 Consolas, monospace; color: var(--muted); }
|
|
63
|
+
.event-name { display: block; font-size: .88rem; font-weight: 650; overflow-wrap: anywhere; }
|
|
64
|
+
.event-route { display: block; margin-top: 5px; font-size: .76rem; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
|
|
65
|
+
.event dl { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 8px 14px; margin: 15px 0 0 94px; padding: 15px; background: var(--wash); font-size: .76rem; }
|
|
66
|
+
.event dt { color: var(--muted); }
|
|
67
|
+
.event dd { margin: 0; font-family: Consolas, monospace; overflow-wrap: anywhere; }
|
|
68
|
+
.empty { padding: 28px 0; color: var(--muted); line-height: 1.6; font-size: .9rem; }
|
|
69
|
+
footer { max-width: 950px; color: var(--muted); margin: 24px clamp(20px, 4vw, 64px) 40px; font-size: .77rem; line-height: 1.6; }
|
|
70
|
+
footer strong { color: var(--ink); }
|
|
71
|
+
@media (max-width: 850px) {
|
|
72
|
+
.masthead { display: block; padding-top: 28px; }
|
|
73
|
+
.connection { margin-top: 24px; padding-left: 15px; }
|
|
74
|
+
.workspace { grid-template-columns: 1fr; }
|
|
75
|
+
.roster-panel { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
76
|
+
.roster { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0 22px; }
|
|
77
|
+
}
|
|
78
|
+
@media (max-width: 480px) {
|
|
79
|
+
.event summary { grid-template-columns: 65px minmax(0, 1fr) auto; gap: 8px; }
|
|
80
|
+
.event dl { margin-left: 0; grid-template-columns: 90px minmax(0, 1fr); }
|
|
81
|
+
}
|
|
82
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } }
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"bugs": {
|
|
13
13
|
"url": "https://github.com/mbundgaard/PiIntercom/issues"
|
|
14
14
|
},
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.2.0",
|
|
16
16
|
"description": "Explicit local communication between Pi sessions; Pi orchestrates.",
|
|
17
17
|
"type": "module",
|
|
18
18
|
"keywords": [
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"src",
|
|
28
|
+
"dashboard",
|
|
28
29
|
"README.md",
|
|
30
|
+
"references/observability.md",
|
|
29
31
|
"references/v1-specification.md",
|
|
30
32
|
"references/implementation-blocker.md"
|
|
31
33
|
],
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
"scripts": {
|
|
46
48
|
"build": "tsc",
|
|
47
49
|
"typecheck": "tsc --noEmit",
|
|
48
|
-
"test": "npm run build && node --test tests/core.test.mjs tests/integration.test.mjs tests/launcher.test.mjs tests/extension.test.mjs tests/runtime-http.test.mjs tests/resume-storage.test.mjs",
|
|
50
|
+
"test": "npm run build && node --test tests/core.test.mjs tests/integration.test.mjs tests/launcher.test.mjs tests/extension.test.mjs tests/runtime-http.test.mjs tests/resume-storage.test.mjs tests/observability.test.mjs tests/dashboard.test.mjs tests/dashboard-security.test.mjs tests/observability-integration.test.mjs",
|
|
49
51
|
"test:host": "node --test tests/pi-cancellation-probe.test.mjs",
|
|
50
52
|
"check:package": "npm pack --dry-run --ignore-scripts"
|
|
51
53
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Local event logs and read-only dashboard
|
|
2
|
+
|
|
3
|
+
Available starting in **version 0.2.0**; not part of version 0.1.3. Validation status is tracked in [README](../README.md). This is observation, not orchestration, a durable task journal, or proof of agent liveness.
|
|
4
|
+
|
|
5
|
+
## Opening the dashboard
|
|
6
|
+
|
|
7
|
+
Coordinator startup automatically binds a separate HTTP listener to `127.0.0.1` on an OS-selected port, writes the actual bound port to its shared coordinator entry as `dashboardPort`, and prints the local dashboard URL in Pi. Open that exact URL in a local browser. No browser is opened automatically. Workers write local event metadata but do not start dashboard listeners. Extension teardown closes the dashboard and its active connections; a later startup can use a different URL.
|
|
8
|
+
|
|
9
|
+
`dashboardPort` is optional and coordinator-only, an integer from 1 through 65535. Existing configuration without it remains valid. It is **not the messaging `port`** and does not change agent-message routing. The saved port is last-known metadata; it is not a requested bind port or a guarantee that any process is listening.
|
|
10
|
+
|
|
11
|
+
Configured coordinators and workers can call existing `intercom_list` to obtain top-level `dashboardUrl`, derived as `http://127.0.0.1:<saved dashboardPort>/`, or `null` if the coordinator has no saved dashboard port. The result retains `version`, `multiplexer` and `agents`. Listing only reads shared config: it does not probe, start, rebind or control the dashboard. A previously saved address remains after closure or startup failure, so a non-null URL is **not proof of dashboard liveness**. No new tool or dashboard control is added.
|
|
12
|
+
|
|
13
|
+
The URL is announced only after the new bound port has been saved and startup lifecycle checks still pass. If saving fails, the newly created dashboard is closed and a local error is shown; Intercom messaging remains available. The previous saved URL, if any, is not cleared and can be stale. Shutdown likewise leaves the saved field intact. Startup does not request or reuse the saved dashboard port.
|
|
14
|
+
|
|
15
|
+
The dashboard polls its read-only snapshot endpoint every three seconds after the preceding request settles, with an eight-second client timeout. It has no agent controls, assignment forms, send buttons, stop/close, recovery actions, or retry controls. Its refresh loop only rereads telemetry; it does not resend Intercom messages or probe workers.
|
|
16
|
+
|
|
17
|
+
### Reading the screen
|
|
18
|
+
|
|
19
|
+
- **Configured agents:** names, responsibilities, saved endpoints and Pi session IDs come from shared configuration. Saved entries do not establish whether a process exists. Responsibility is not an assignment.
|
|
20
|
+
- **Observed busy / observed idle:** the most recent `runtime.ready` or `host.activity` event in the retained window for that Pi session. The event's own timestamp and age are shown. `status.received` is not attributed to its writer as that writer's busy state; its `busy` value describes the reporting peer.
|
|
21
|
+
- **Stale evidence:** an observation older than 60 seconds. It does not mean offline: a legitimately idle host may emit no new activity. **Unknown** means no eligible observation in the retained window, not idle or unavailable. Future-dated observations are marked clock-uncertain.
|
|
22
|
+
- **Exchange ledger:** newest metadata first; filter by agent (including either side of an exchange), event type, or errors. Expand a row for allowlisted fields and correlation. Receipt, submission, and host activity are separate facts, not task-completion states.
|
|
23
|
+
- **Disconnected / partial snapshot:** the banner states the problem. Previously received data may remain visible and continues to age; it must not be treated as current process status. An empty timeline does not prove no activity occurred.
|
|
24
|
+
|
|
25
|
+
The UI uses local HTML/CSS/JavaScript, with no CDN or remote fonts. Event/config text is inserted as text, never as executable HTML. It displays selected roster fields, not a raw config dump.
|
|
26
|
+
|
|
27
|
+
## Privacy and event contract
|
|
28
|
+
|
|
29
|
+
Logs live under `<coordinator-root>/.pi-intercom/logs/`. They are **metadata-only**: there is no prompt-body logging mode in this implementation. Message bodies, tool arguments/results, credentials, raw error messages/stacks, and responsibility text are not copied into log events. The dashboard separately reads configured names and responsibilities for its roster; do not put secrets in those fields. Metadata can still be sensitive: session IDs, human labels, endpoints, timings and communication patterns may identify your work. Keep logs local and do not commit or publish them. Shared `config.json` remains the source-controlled configuration; logging does not move IDs or ports out of it.
|
|
30
|
+
|
|
31
|
+
Version 1 event fields:
|
|
32
|
+
|
|
33
|
+
| Field | Meaning |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `version` | `1` |
|
|
36
|
+
| `timestamp` | UTC ISO observation time, not a global ordering guarantee |
|
|
37
|
+
| `sessionId` | Actual Pi agent/session identity |
|
|
38
|
+
| `writerId` | Ephemeral UUID for this log writer/file group only; **not a new agent or transport identity** |
|
|
39
|
+
| `event` | Allowlisted event type |
|
|
40
|
+
| `peerSessionId`, `peerName` | Optional peer metadata |
|
|
41
|
+
| `correlationId` | Optional identifier for one transport attempt, not a task, reply promise or completion token |
|
|
42
|
+
| `kind`, `operation`, `role`, `outcome` | Optional allowlisted metadata values |
|
|
43
|
+
| `busy`, `port` | Optional observed boolean / port |
|
|
44
|
+
| `errorCode` | Optional fixed safe error label; no raw exception text |
|
|
45
|
+
|
|
46
|
+
Current 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`.
|
|
47
|
+
|
|
48
|
+
A sender generates an optional wire-envelope `correlationId` for each send; the receiver can record it too. Legacy envelopes without it are accepted. It provides trace correlation only: no deduplication, replay, durable delivery or synchronous request/reply behavior is introduced.
|
|
49
|
+
|
|
50
|
+
### What the records do not establish
|
|
51
|
+
|
|
52
|
+
- `transport.receipt` / `http_receipt` means the sender saw HTTP acceptance. It does **not** prove Pi accepted or queued the input, nor that a model turn or assignment completed.
|
|
53
|
+
- `host.submission` with `attempted`, `returned` or `failed` records a synchronous host call. Pi 0.84.4's void `sendUserMessage` binding can report an asynchronous manual-compaction rejection only locally after HTTP acceptance. Logging does not fix or reliably observe that asynchronous rejection.
|
|
54
|
+
- `host.activity` `started`/`settled` observes host activity, not a particular message's result. Busy snapshots are not availability guarantees.
|
|
55
|
+
- `launch.result` does not establish agent readiness. `host.ui_prompt` does not authorize work. Neither monitoring nor errors trigger orchestration.
|
|
56
|
+
- Stop/close remain disabled. Telemetry does not add cancellation, forced shutdown, retries, recovery or rollback.
|
|
57
|
+
|
|
58
|
+
## Storage bounds and failure behavior
|
|
59
|
+
|
|
60
|
+
Each writer appends newline-delimited JSON to `writer-<UUID>.jsonl` and rotates through `.jsonl.1` and `.jsonl.2`.
|
|
61
|
+
|
|
62
|
+
| Bound | Policy |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Active/rotated file size | 1 MiB each, up to three data files per writer |
|
|
65
|
+
| Event size | At most 4 KiB per JSONL record |
|
|
66
|
+
| In-memory writer queue | At most 256 queued entries plus one in-flight entry; excess records are dropped |
|
|
67
|
+
| Closed-group retention | Seven days; cleanup can remove older closed groups earlier to target 32 MiB aggregate storage |
|
|
68
|
+
| Cleanup scan | At most 4096 directory entries per maintenance pass |
|
|
69
|
+
| Maintenance | Coordinator only, at startup and every 60 seconds |
|
|
70
|
+
|
|
71
|
+
A graceful writer close renames its active file to `.jsonl.closed`; that suffix is the cleanup eligibility marker, not a live process probe. Cleanup removes only closed writer groups, oldest first. Active and crash-orphan groups are left untouched. The 32 MiB directory target is therefore **best effort, not a hard disk cap**, especially if active/orphan groups or scan limits dominate. Filesystem failures may leave files behind.
|
|
72
|
+
|
|
73
|
+
Logging is non-authoritative and best effort. Discovery failures before root resolution and invalid HTTP envelopes rejected before runtime handling are not emitted as runtime log events. Sanitization/size/queue failures can discard observations. An append/rotation I/O failure disables that writer and drops queued records without retrying or failing Intercom communication. Maintenance failures are ignored by the communication path. Ordinary record/send/delivery operations never await logging I/O. During teardown, the runtime invalidates its lifecycle and closes its HTTP endpoint before waiting at most **250 ms** for logger shutdown. Concurrent close callers share the same close operation. This is not a guaranteed flush: timed-out I/O cannot be cancelled and may finish later.
|
|
74
|
+
|
|
75
|
+
The writer refuses pre-existing symbolic links at `.pi-intercom` or its `logs` directory; these checks are best effort, not race-proof protection against a hostile filesystem. The dashboard cannot certify that logging was complete or that a quiet writer is healthy; inspect local storage when diagnosing missing evidence. Logs are not fsynced as a durable delivery journal.
|
|
76
|
+
|
|
77
|
+
## HTTP snapshot boundary
|
|
78
|
+
|
|
79
|
+
Only the coordinator exposes these GET routes:
|
|
80
|
+
|
|
81
|
+
| Route | Response |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `/` | Local `dashboard/index.html` |
|
|
84
|
+
| `/app.js`, `/style.css` | Explicitly allowlisted local assets |
|
|
85
|
+
| `/api/snapshot` | Sanitized JSON snapshot |
|
|
86
|
+
|
|
87
|
+
Snapshot shape:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
{ version: 1, generatedAt, staleAfterMs: 60000,
|
|
91
|
+
config: { multiplexer, agents: [selected validated roster fields] } | null,
|
|
92
|
+
events: [sanitized v1 events, oldest first], truncated: boolean,
|
|
93
|
+
errors: [fixed safe labels] }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Reads are bounded: config at most 1 MiB and 256 agents; scan at most 512 log-directory entries, select up to 32 newest matching files among those scanned (not necessarily the globally newest files in an oversized directory), tail at most 128 KiB per file and 2 MiB total, and return at most 500 latest events. `truncated` indicates bounded omissions, not complete history. Unsupported, malformed or partial records are ignored. Reads can overlap rotation and are not a transactional global snapshot.
|
|
97
|
+
|
|
98
|
+
Partial snapshots return HTTP 200 with `config_unavailable`, `logs_unavailable` and/or `log_read_failed` labels; no raw read errors or paths are returned. Unknown routes (including query variants) return 404; non-GET requests return 405; rejected Host/Origin/cross-site requests return 403; overload or static-asset failures return 503. The listener admits at most eight active requests and 16 connections. Concurrent snapshot requests share the pending read, not a worker probe.
|
|
99
|
+
|
|
100
|
+
The backend rejects linked/redirected config, log and asset path components and serves no arbitrary file route. It sets no-store, nosniff, same-origin-only content policy, no-referrer and frame-ancestor restrictions. These checks reduce exposure but are **not authentication or a security boundary against hostile local processes**; localhost remains trusted. Do not proxy or expose the dashboard remotely.
|
|
101
|
+
|
|
102
|
+
## Validation scope
|
|
103
|
+
|
|
104
|
+
See README for verified test results. Synthetic filesystem/HTTP/DOM fixtures do not prove actual Pi queue acceptance, live resume, host name persistence, or full interactive lifecycle correctness. No dashboard observation should be used to infer unobserved completion or availability.
|
|
@@ -96,7 +96,7 @@ No worker-specific config path, callback address, instance ID, or preassigned re
|
|
|
96
96
|
- Include sender identity and label agent messages with sender and purpose.
|
|
97
97
|
- Incoming coordinator-only controls check sender session ID against current config. This is role validation, not authentication.
|
|
98
98
|
- Receipt timeout is 5 seconds. No automatic retries. A failure or timeout is reported; the agent decides recovery.
|
|
99
|
-
- Dedicated durable message logging is deferred
|
|
99
|
+
- Dedicated durable message logging is deferred. A subsequently approved, separate scope adds best-effort metadata telemetry and a coordinator-only read-only dashboard; see [observability](observability.md). This is not a durable inbox, task journal, replay mechanism, completion guarantee or orchestration layer.
|
|
100
100
|
|
|
101
101
|
### Agent delivery
|
|
102
102
|
|
|
@@ -158,7 +158,7 @@ Every invocation reads the current Pi session ID and shared config, determines r
|
|
|
158
158
|
## 8. Explicitly deferred
|
|
159
159
|
|
|
160
160
|
- Other operating systems and multiplexers.
|
|
161
|
-
- Durable message
|
|
161
|
+
- Durable message/task journals and replay remain deferred. The newly approved [metadata-only observation feature](observability.md) has bounded best-effort storage/retention, not durable-delivery guarantees.
|
|
162
162
|
- Duplicate Pi-session activation detection or session-ID locks.
|
|
163
163
|
- Automatic retry queues and recovery orchestration are not V1 behavior.
|
|
164
164
|
- Bulk tools: use individual operations instead.
|
package/src/config.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface Agent {
|
|
|
8
8
|
coordinator: boolean;
|
|
9
9
|
description: string;
|
|
10
10
|
port: number;
|
|
11
|
+
/** Last bound dashboard port, coordinator only; not evidence of a live server. */
|
|
12
|
+
dashboardPort?: number;
|
|
11
13
|
projectDirectory: string;
|
|
12
14
|
}
|
|
13
15
|
export interface Config { version: 1; multiplexer: 'herdr' | 'none'; agents: Agent[] }
|
|
@@ -33,6 +35,10 @@ export function validateConfig(value: unknown): Config {
|
|
|
33
35
|
text(a.sessionId, 'sessionId', 256); text(a.name, 'name', 128); text(a.description, 'description');
|
|
34
36
|
if (a.name !== a.name.trim() || /[\r\n\x00-\x1f]/.test(a.name)) fail('invalid name');
|
|
35
37
|
port(a.port); relativeDirectory(a.projectDirectory);
|
|
38
|
+
if (a.dashboardPort !== undefined) {
|
|
39
|
+
if (!a.coordinator) fail('dashboardPort is coordinator-only');
|
|
40
|
+
port(a.dashboardPort);
|
|
41
|
+
}
|
|
36
42
|
if (names.has(key(a.name)) || ids.has(a.sessionId)) fail('duplicate name or session ID');
|
|
37
43
|
names.add(key(a.name)); ids.add(a.sessionId);
|
|
38
44
|
if (a.coordinator ? (a.name !== 'Coordinator' || a.projectDirectory !== '.') : key(a.name) === 'coordinator') fail('reserved Coordinator identity');
|
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, opendir, realpath } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { validateConfig, type Config } from './config.js';
|
|
7
|
+
import { LOG_DIRECTORY, LOG_FILE_PATTERN, sanitizeObservation, type Observation } from './observability.js';
|
|
8
|
+
|
|
9
|
+
const CONFIG_BYTES = 1024 * 1024, TAIL_BYTES = 128 * 1024, TOTAL_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const MAX_FILES = 32, MAX_ENTRIES = 512, MAX_EVENTS = 500, MAX_AGENTS = 256;
|
|
11
|
+
export interface DashboardSnapshot {
|
|
12
|
+
version: 1;
|
|
13
|
+
generatedAt: string;
|
|
14
|
+
staleAfterMs: number;
|
|
15
|
+
config: Pick<Config, 'multiplexer' | 'agents'> | null;
|
|
16
|
+
events: Observation[];
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
errors: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Only fixed, extension-owned paths are read. Reject symlink/junction components
|
|
22
|
+
// rather than allowing a local log/config link to expose an unrelated file.
|
|
23
|
+
async function confined(root: string, relative: string): Promise<string> {
|
|
24
|
+
const base = await realpath(root);
|
|
25
|
+
let current = base;
|
|
26
|
+
for (const segment of relative.split(/[\\/]/)) {
|
|
27
|
+
if (!segment || segment === '.' || segment === '..') throw new Error('invalid path');
|
|
28
|
+
current = path.join(current, segment);
|
|
29
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error('linked path');
|
|
30
|
+
if (path.relative(current, await realpath(current)) !== '') throw new Error('redirected path');
|
|
31
|
+
}
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function boundedRead(file: string, limit: number, tail = false): Promise<{ text: string; truncated: boolean; bytes: number }> {
|
|
36
|
+
if (!(await lstat(file)).isFile()) throw new Error('not a file');
|
|
37
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
38
|
+
try {
|
|
39
|
+
const stat = await handle.stat();
|
|
40
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
41
|
+
if (!tail && stat.size > limit) throw new Error('file too large');
|
|
42
|
+
const size = Math.min(stat.size, limit), start = tail ? Math.max(0, stat.size - size) : 0;
|
|
43
|
+
const buffer = Buffer.alloc(size);
|
|
44
|
+
const { bytesRead } = await handle.read(buffer, 0, size, start);
|
|
45
|
+
let text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
46
|
+
// Drop potentially partial UTF-8/JSON at both tail boundaries. Writers append
|
|
47
|
+
// complete newline-terminated records; incomplete records are not evidence.
|
|
48
|
+
if (tail) {
|
|
49
|
+
if (start > 0) text = text.slice(text.indexOf('\n') + 1);
|
|
50
|
+
text = text.slice(0, text.lastIndexOf('\n') + 1);
|
|
51
|
+
}
|
|
52
|
+
return { text, truncated: stat.size > limit, bytes: bytesRead };
|
|
53
|
+
} finally { await handle.close(); }
|
|
54
|
+
}
|
|
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: [] };
|
|
58
|
+
try {
|
|
59
|
+
const file = await confined(root, '.pi-intercom/config.json');
|
|
60
|
+
const config = validateConfig(JSON.parse((await boundedRead(file, CONFIG_BYTES)).text));
|
|
61
|
+
result.truncated ||= config.agents.length > MAX_AGENTS;
|
|
62
|
+
result.config = { multiplexer: config.multiplexer, agents: config.agents.slice(0, MAX_AGENTS).map(a => ({
|
|
63
|
+
sessionId: a.sessionId, name: a.name, coordinator: a.coordinator, description: a.description,
|
|
64
|
+
projectDirectory: a.projectDirectory, port: a.port,
|
|
65
|
+
...(a.dashboardPort === undefined ? {} : { dashboardPort: a.dashboardPort }),
|
|
66
|
+
})) };
|
|
67
|
+
} catch { result.errors.push('config_unavailable'); }
|
|
68
|
+
try {
|
|
69
|
+
const directory = await confined(root, LOG_DIRECTORY);
|
|
70
|
+
const files: { name: string; modified: number }[] = [];
|
|
71
|
+
const entries = await opendir(directory);
|
|
72
|
+
let scanned = 0;
|
|
73
|
+
for await (const entry of entries) {
|
|
74
|
+
if (++scanned > MAX_ENTRIES) { result.truncated = true; break; }
|
|
75
|
+
if (!entry.isFile() || !LOG_FILE_PATTERN.test(entry.name)) continue;
|
|
76
|
+
try {
|
|
77
|
+
const file = await confined(root, `${LOG_DIRECTORY}/${entry.name}`);
|
|
78
|
+
files.push({ name: entry.name, modified: (await lstat(file)).mtimeMs });
|
|
79
|
+
} catch { /* Rotations can remove a file while scanning. */ }
|
|
80
|
+
}
|
|
81
|
+
files.sort((a, b) => b.modified - a.modified || a.name.localeCompare(b.name));
|
|
82
|
+
result.truncated ||= files.length > MAX_FILES;
|
|
83
|
+
let budget = TOTAL_BYTES;
|
|
84
|
+
for (const file of files.slice(0, MAX_FILES)) {
|
|
85
|
+
if (budget <= 0) { result.truncated = true; break; }
|
|
86
|
+
try {
|
|
87
|
+
const resolved = await confined(root, `${LOG_DIRECTORY}/${file.name}`);
|
|
88
|
+
const read = await boundedRead(resolved, Math.min(TAIL_BYTES, budget), true);
|
|
89
|
+
budget -= read.bytes;
|
|
90
|
+
result.truncated ||= read.truncated;
|
|
91
|
+
for (const line of read.text.split('\n')) {
|
|
92
|
+
if (!line) continue;
|
|
93
|
+
try {
|
|
94
|
+
const event = sanitizeObservation(JSON.parse(line));
|
|
95
|
+
if (event) result.events.push(event);
|
|
96
|
+
} catch { /* Ignore malformed/partial/foreign records, never expose raw data. */ }
|
|
97
|
+
}
|
|
98
|
+
} catch { if (!result.errors.includes('log_read_failed')) result.errors.push('log_read_failed'); }
|
|
99
|
+
}
|
|
100
|
+
result.events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
101
|
+
if (result.events.length > MAX_EVENTS) { result.events = result.events.slice(-MAX_EVENTS); result.truncated = true; }
|
|
102
|
+
} catch { result.errors.push('logs_unavailable'); }
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
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/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getAgentDir, SessionManager, SettingsManager, truncateHead, withFileMut
|
|
|
5
5
|
import { ConfigStore } from './config.js';
|
|
6
6
|
import { Intercom, UNSUPPORTED_CANCELLATION } from './runtime.js';
|
|
7
7
|
import { launchers } from './launcher.js';
|
|
8
|
+
import { startDashboard, type Dashboard } from './dashboard.js';
|
|
8
9
|
|
|
9
10
|
const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
|
|
10
11
|
const tools: [string, string, TSchema][] = [
|
|
@@ -42,11 +43,15 @@ class PiConfigStore extends ConfigStore {
|
|
|
42
43
|
export default function intercomExtension(pi: ExtensionAPI): void {
|
|
43
44
|
let runtime: Intercom | undefined;
|
|
44
45
|
let context: ExtensionContext | undefined;
|
|
46
|
+
let dashboard: Dashboard | undefined;
|
|
47
|
+
let generation = 0;
|
|
45
48
|
const launcher = launchers({
|
|
46
49
|
extension: fileURLToPath(import.meta.url),
|
|
47
50
|
sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
|
|
48
51
|
});
|
|
49
52
|
pi.on('session_start', async (_event, ctx) => {
|
|
53
|
+
const started = ++generation;
|
|
54
|
+
await dashboard?.close(); dashboard = undefined;
|
|
50
55
|
await runtime?.close(); context = ctx;
|
|
51
56
|
if (process.platform !== 'win32' || ctx.mode !== 'tui') throw new Error('PiIntercom V1 requires Windows interactive Pi. No resources started.');
|
|
52
57
|
if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
|
|
@@ -58,10 +63,43 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
58
63
|
setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
|
|
59
64
|
notify: text => ctx.ui.notify(text, 'info'),
|
|
60
65
|
}, { launch: launcher.launch, store: root => new PiConfigStore(root) });
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
const current = runtime;
|
|
67
|
+
try { await current.start(); }
|
|
68
|
+
catch (e) { if (runtime === current) runtime = undefined; ctx.ui.notify(String(e), 'error'); return; }
|
|
69
|
+
if (started !== generation) return;
|
|
70
|
+
let server: Dashboard | undefined;
|
|
71
|
+
let savingPort = false;
|
|
72
|
+
try {
|
|
73
|
+
if ((await current.state()).me?.coordinator) {
|
|
74
|
+
server = await startDashboard(current.store.root);
|
|
75
|
+
if (started !== generation) { await server.close(); return; }
|
|
76
|
+
savingPort = true;
|
|
77
|
+
await current.recordDashboardPort(server.port);
|
|
78
|
+
if (started !== generation) { await server.close(); return; }
|
|
79
|
+
dashboard = server;
|
|
80
|
+
ctx.ui.notify(`Intercom read-only dashboard: ${server.url} (saved URL is last-known, not live availability)`, 'info');
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
await server?.close().catch(() => {});
|
|
84
|
+
if (started !== generation) return;
|
|
85
|
+
ctx.ui.notify(savingPort
|
|
86
|
+
? 'Intercom dashboard port could not be saved; the new dashboard was closed. Any saved URL is last-known only. Messaging remains available.'
|
|
87
|
+
: 'Intercom dashboard unavailable; communication remains independent.', 'error');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
pi.on('session_shutdown', async () => {
|
|
91
|
+
generation++;
|
|
92
|
+
await dashboard?.close(); dashboard = undefined;
|
|
93
|
+
await runtime?.close(); runtime = undefined; context = undefined;
|
|
94
|
+
});
|
|
95
|
+
pi.on('agent_start', async (_event, ctx) => {
|
|
96
|
+
context = ctx;
|
|
97
|
+
runtime?.recordObservation('host.activity', { busy: !ctx.isIdle(), outcome: 'started' });
|
|
98
|
+
});
|
|
99
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
100
|
+
context = ctx;
|
|
101
|
+
runtime?.recordObservation('host.activity', { busy: !ctx.isIdle(), outcome: 'settled' });
|
|
63
102
|
});
|
|
64
|
-
pi.on('session_shutdown', async () => { await runtime?.close(); runtime = undefined; context = undefined; });
|
|
65
103
|
pi.on('before_agent_start', async (event, ctx) => {
|
|
66
104
|
context = ctx;
|
|
67
105
|
if (!runtime) return;
|
|
@@ -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 {
|
|
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,6 +19,7 @@ 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;
|
|
@@ -25,6 +28,15 @@ export class Intercom {
|
|
|
25
28
|
private generation = 0;
|
|
26
29
|
private initialId = '';
|
|
27
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); }
|
|
28
40
|
constructor(readonly host: Host, readonly options: RuntimeOptions) {}
|
|
29
41
|
private id(): string {
|
|
30
42
|
const id = this.host.sessionId();
|
|
@@ -40,26 +52,36 @@ export class Intercom {
|
|
|
40
52
|
};
|
|
41
53
|
}
|
|
42
54
|
async start(): Promise<void> {
|
|
55
|
+
if (this.closing) await this.closing;
|
|
43
56
|
if (this.active) fail('runtime already active');
|
|
44
57
|
this.initialId = this.host.sessionId(); this.active = true;
|
|
45
58
|
const generation = ++this.generation, assertValid = this.validity();
|
|
59
|
+
let record = this.captureObserver();
|
|
46
60
|
try {
|
|
47
61
|
const discovered = await ConfigStore.discover(this.host.cwd);
|
|
48
62
|
assertValid();
|
|
49
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');
|
|
50
67
|
const previous = discovered ? await this.store.read() : undefined;
|
|
51
68
|
assertValid();
|
|
52
69
|
const own = previous?.agents.find(a => a.sessionId === this.id());
|
|
53
70
|
const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); });
|
|
54
71
|
try { assertValid(); } catch (error) { await endpoint.close(); throw error; }
|
|
55
72
|
this.endpoint = endpoint;
|
|
56
|
-
if (!previous)
|
|
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
|
+
}
|
|
57
77
|
const config = await this.store.read();
|
|
58
78
|
assertValid();
|
|
59
79
|
const me = config.agents.find(a => a.sessionId === this.id());
|
|
60
80
|
if (me?.coordinator) {
|
|
81
|
+
try { this.observer?.maintain?.(); } catch { /* No cleanup failure affects readiness. */ }
|
|
61
82
|
await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
|
|
62
83
|
assertValid();
|
|
84
|
+
record('config.changed', { operation: 'port_update', outcome: 'written', port: this.endpoint.port });
|
|
63
85
|
try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); }
|
|
64
86
|
assertValid();
|
|
65
87
|
this.host.notify(`Intercom Coordinator ready at 127.0.0.1:${this.endpoint.port}; waiting for user input.`);
|
|
@@ -77,13 +99,39 @@ export class Intercom {
|
|
|
77
99
|
assertValid();
|
|
78
100
|
this.host.notify(`Intercom worker ready at 127.0.0.1:${this.endpoint.port}; waiting for explicit work.`);
|
|
79
101
|
}
|
|
80
|
-
|
|
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
|
+
}
|
|
81
110
|
}
|
|
82
111
|
async close(): Promise<void> {
|
|
112
|
+
if (this.closing) return this.closing;
|
|
113
|
+
this.recordObservation('runtime.closed');
|
|
114
|
+
const observer = this.observer; this.observer = undefined;
|
|
83
115
|
this.active = false; this.generation++;
|
|
84
116
|
const endpoint = this.endpoint; this.endpoint = undefined;
|
|
85
117
|
this.responsibility = undefined;
|
|
86
|
-
|
|
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; }
|
|
87
135
|
}
|
|
88
136
|
async state(): Promise<{ id: string; config: Config; me?: Agent }> {
|
|
89
137
|
const assertValid = this.validity();
|
|
@@ -91,7 +139,16 @@ export class Intercom {
|
|
|
91
139
|
assertValid();
|
|
92
140
|
return { id, config, me: config.agents.find(a => a.sessionId === id) };
|
|
93
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
|
+
}
|
|
94
150
|
async reload(): Promise<void> {
|
|
151
|
+
const record = this.captureObserver();
|
|
95
152
|
const assertValid = this.validity();
|
|
96
153
|
const { me } = await this.state();
|
|
97
154
|
assertValid();
|
|
@@ -99,14 +156,24 @@ export class Intercom {
|
|
|
99
156
|
this.responsibility = { ...me };
|
|
100
157
|
await this.host.setName(me.name);
|
|
101
158
|
assertValid();
|
|
159
|
+
record('config.reloaded', { role: me.coordinator ? 'coordinator' : 'worker' });
|
|
102
160
|
this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
|
|
103
161
|
}
|
|
104
162
|
async transmit(to: string, kind: Kind, payload: Record<string, unknown> = {}): Promise<void> {
|
|
163
|
+
const record = this.captureObserver();
|
|
105
164
|
const assertValid = this.validity();
|
|
106
165
|
const { id, config } = await this.state();
|
|
107
166
|
assertValid();
|
|
108
|
-
const target = named(config, to);
|
|
109
|
-
|
|
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
|
+
}
|
|
110
177
|
}
|
|
111
178
|
async report(): Promise<void> {
|
|
112
179
|
const assertValid = this.validity();
|
|
@@ -117,15 +184,35 @@ export class Intercom {
|
|
|
117
184
|
await this.transmit(coordinator(config).name, 'status', { port: this.endpoint.port, busy: this.host.busy() });
|
|
118
185
|
}
|
|
119
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();
|
|
120
198
|
const assertValid = this.validity();
|
|
121
199
|
const { id, config, me } = await this.state();
|
|
122
200
|
assertValid();
|
|
123
201
|
if (message.to !== id) fail('recipient session ID mismatch (stale endpoint)');
|
|
124
202
|
const sender = config.agents.find(a => a.sessionId === message.from);
|
|
125
203
|
const busy = this.host.busy();
|
|
204
|
+
const metadata = { peerSessionId: message.from, peerName: sender?.name, correlationId: message.correlationId, kind: message.kind, busy };
|
|
126
205
|
const notify = (purpose: string, content: string) => {
|
|
127
206
|
assertValid();
|
|
128
|
-
|
|
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
|
+
}
|
|
129
216
|
};
|
|
130
217
|
if (message.kind === 'registration' || message.kind === 'status') {
|
|
131
218
|
requireCoordinator(config, id);
|
|
@@ -133,12 +220,15 @@ export class Intercom {
|
|
|
133
220
|
if (message.kind === 'registration') {
|
|
134
221
|
const projectDirectory = await directory(this.store.root, message.payload.projectDirectory as string);
|
|
135
222
|
// No pending map, placeholder or configuration mutation.
|
|
223
|
+
record('registration.received', { ...metadata, port: message.payload.port as number });
|
|
136
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.');
|
|
137
225
|
} else {
|
|
138
226
|
if (sender) await this.store.update(id, c => {
|
|
139
227
|
const entry = c.agents.find(a => a.sessionId === message.from && !a.coordinator);
|
|
140
228
|
if (entry) entry.port = message.payload.port as number;
|
|
141
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' });
|
|
142
232
|
notify('status', JSON.stringify({ sessionId: message.from, port: message.payload.port, busy: message.payload.busy, configured: !!sender }));
|
|
143
233
|
}
|
|
144
234
|
return;
|
|
@@ -166,12 +256,14 @@ export class Intercom {
|
|
|
166
256
|
fail('unsupported control');
|
|
167
257
|
}
|
|
168
258
|
async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
|
|
259
|
+
const record = this.captureObserver();
|
|
169
260
|
const assertValid = this.validity();
|
|
170
261
|
const { id, config, me } = await this.state();
|
|
171
262
|
assertValid();
|
|
172
263
|
if (operation === 'list') {
|
|
173
264
|
if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
|
|
174
|
-
|
|
265
|
+
const dashboardPort = coordinator(config).dashboardPort;
|
|
266
|
+
return { ...config, dashboardUrl: dashboardPort === undefined ? null : `http://127.0.0.1:${dashboardPort}/` };
|
|
175
267
|
}
|
|
176
268
|
if (operation === 'report_status') { await this.report(); return { accepted: true }; }
|
|
177
269
|
if (operation === 'send') {
|
|
@@ -182,15 +274,17 @@ export class Intercom {
|
|
|
182
274
|
if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
|
|
183
275
|
if (operation === 'configure_worker') {
|
|
184
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' });
|
|
185
278
|
return { written: true, reloaded: false };
|
|
186
279
|
}
|
|
187
280
|
if (operation === 'set_multiplexer') {
|
|
188
|
-
await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }, assertValid);
|
|
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 };
|
|
189
283
|
}
|
|
190
284
|
if (operation === 'create_worker') {
|
|
191
285
|
const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
|
|
192
286
|
assertValid();
|
|
193
|
-
return this.
|
|
287
|
+
return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
|
|
194
288
|
}
|
|
195
289
|
const target = named(config, args.to as string);
|
|
196
290
|
if (target.coordinator) fail('operation requires a worker target');
|
|
@@ -201,13 +295,27 @@ export class Intercom {
|
|
|
201
295
|
if (operation === 'resume_worker') {
|
|
202
296
|
const relative = await directory(this.store.root, target.projectDirectory);
|
|
203
297
|
assertValid();
|
|
204
|
-
return this.
|
|
298
|
+
return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
|
|
205
299
|
}
|
|
206
300
|
if (operation === 'remove_worker') {
|
|
207
301
|
// No live process claim: the caller must explicitly ensure closure first.
|
|
208
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' });
|
|
209
304
|
return { removed: true, processStopped: false, sessionDeleted: false };
|
|
210
305
|
}
|
|
211
306
|
fail(`unknown operation ${operation}`);
|
|
212
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
|
+
}
|
|
213
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);
|