@comput/pi-intercom 0.1.3 → 0.2.1
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 +32 -4
- 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 +44 -4
- 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).
|
|
@@ -99,9 +101,21 @@ HTTP binds **127.0.0.1 only**, `POST /intercom`, UTF-8 JSON, at most 64 KiB. Env
|
|
|
99
101
|
|
|
100
102
|
Kinds: `message`, `registration` (`port`, `projectDirectory`), `status` (`port`, `busy`), `request_status`, `reload`, `stop`, `close` (empty control payload). Sender session identity is the envelope `from`; registration/status agent notifications include an explicit `sessionId` field. Agent messages require configured sender and recipient; controls require the current coordinator's sender ID and a worker recipient. Registration/unknown status are intentional exceptions to configured-sender checks, accepted only by coordinator. All receivers verify expected recipient ID, protecting against stale ports reaching another session. Local processes are trusted: this is role validation, **not authentication**. No remote networking/proxies/redirects or credentials.
|
|
101
103
|
|
|
102
|
-
`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.
|
|
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. Starting in 0.2.1, Intercom always supplies `deliverAs: 'steer'`: Pi starts normally when idle and steers when busy. This avoids the idle-snapshot-to-busy race that caused “Agent is already processing” errors; it does not fix manual-compaction rejection. 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.
|
|
105
|
+
|
|
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.
|
|
103
117
|
|
|
104
|
-
|
|
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,19 @@ 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.1 delivery regression validation
|
|
161
|
+
|
|
162
|
+
Always specifying steering fixes the missing-streaming-mode race without adding retries or an Intercom queue. Coordinator and Quality independently passed typecheck and all **64 tests**, including a source-backed Pi 0.84.4 regression. Test fixtures now isolate discovery from real ancestor configurations; production discovery boundaries are unchanged and remain a separate design issue.
|
|
163
|
+
|
|
164
|
+
### 0.2.0 observability validation
|
|
165
|
+
|
|
166
|
+
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`.
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
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
171
|
|
|
144
172
|
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
173
|
|
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.1
|
|
15
|
+
"version": "0.2.1",
|
|
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');
|