@comput/pi-intercom 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,11 +19,21 @@ Alternatively install from npm:
19
19
  pi install npm:@comput/pi-intercom
20
20
  ```
21
21
 
22
- Then start a new Pi session or run `/reload`. Nothing here changes global/project Pi settings automatically. Do not load both the source and built output as separate extensions.
22
+ Then start a new Pi session or run Pi `/reload`. `pi install` records the package in user settings by default; add `-l` for project settings. The Intercom extension itself does not change Pi settings. Load only one copy: do not enable the installed package alongside an explicit source checkout, or load both source and built output.
23
+
24
+ ### Updating the loaded copy
25
+
26
+ - **npm installation:** update the installed package, not this checkout: `pi update npm:@comput/pi-intercom`. A pinned version must be explicitly changed (for example, `pi install npm:@comput/pi-intercom@0.1.2`, using the same settings scope as the original installation). `pi list` shows configured package sources; inspect that installation's `package.json` to confirm its version.
27
+ - **Source loading (`pi -e .../src/index.ts`):** update that checkout. `npm run build` produces test output; it does not update a separate npm installation.
28
+ - After obtaining the intended code, start a new session or use Pi `/reload` in each affected running host. **`intercom_reload_worker` only reads shared configuration; it cannot load extension-code fixes.** Workers launched by a coordinator receive the coordinator extension's actual file path. Updating a different copy does not change that path, and existing workers do not automatically reload.
29
+
30
+ The 0.1.2 update/reload and subsequent worker creation were observed live; comprehensive reload/session-replacement lifecycle coverage remains pending.
31
+
32
+ ### Startup and launchers
23
33
 
24
34
  Without shared config, the current working directory becomes the coordinator root. Startup creates `.pi-intercom/config.json`, opens a loopback listener, synchronizes the name `Coordinator`, and waits for user input (no model turn). Existing config is found by walking upward. Invalid/unreadable config is an error and is not overwritten. Unknown session IDs, including forks, are workers, never replacement coordinators.
25
35
 
26
- Default launcher `herdr` requires the coordinator to be inside Herdr (`HERDR_ENV=1` and workspace context), with `herdr` on PATH. Each worker gets **one tab, one pane, no split, no focus change**. The `none` launcher requires `pi.cmd` and Windows PowerShell on PATH and opens a separate visible PowerShell terminal. Missing launcher is an error, not fallback. These launchers have mocked tests but have **not been exercised with live workers**.
36
+ Default launcher `herdr` requires the coordinator to be inside Herdr (`HERDR_ENV=1` and workspace context), with `herdr` on PATH and `powershell.exe` plus `pi.ps1` available in the worker pane. Each worker gets **one tab, one pane, no split, no focus change**. The `none` launcher requires `pi.cmd` and Windows PowerShell on PATH and opens a separate visible PowerShell terminal. Missing launcher is an error, not fallback. Launch acknowledgment means command submission/terminal creation, not Pi readiness. A failed launch can leave a tab or process behind; no automatic cleanup occurs. See the scoped live evidence below.
27
37
 
28
38
  ## Explicit workflow
29
39
 
@@ -34,25 +44,25 @@ Default launcher `herdr` requires the coordinator to be inside Herdr (`HERDR_ENV
34
44
  5. Coordinator sends explicit work using `intercom_send({to:"Builder",message:"..."})`. Workers can communicate directly with configured peers. Findings do not authorize unrelated implementation. Progress questions ask for reporting and continuation, not cancellation.
35
45
  6. Workers report as instructed and wait, without autonomous exit. Coordinator unavailability does not close the worker or cancel its assignment.
36
46
 
37
- Existing workers restore responsibility by session ID, bind their saved port or an OS-selected fallback, and report their actual port. Coordinator updates existing ports as bookkeeping. Status from removed/unknown sessions notifies Pi but creates no entry. Resume uses the configured project directory and full session ID, with a saved-session lookup first; a never-used Pi session might not yet exist on disk. Failure leaves config unchanged, without fabricated history or forced model turns.
47
+ Existing workers restore responsibility by session ID, bind their saved port or, if it is already in use, an OS-selected fallback, and report their actual port. Coordinator updates existing ports as bookkeeping. Status from removed/unknown sessions notifies Pi but creates no entry. Resume uses the configured project directory and full session ID, with a saved-session lookup first; a never-used Pi session might not yet exist on disk. Failure leaves config unchanged, without fabricated history or forced model turns.
38
48
 
39
49
  ## Tools and permissions
40
50
 
41
51
  Every tool rereads current config and checks the live Pi session ID. Names are case-insensitive for lookup/uniqueness; display capitalization is preserved. `Coordinator` is reserved.
42
52
 
43
- | Tool | Permission / effect |
44
- |---|---|
45
- | `intercom_create_worker` | Coordinator; launch anonymous worker, optional directory defaults to root |
46
- | `intercom_configure_worker` | Coordinator; explicit five fields above, writes only |
47
- | `intercom_reload_worker` | Coordinator; named worker's passive config/name reload |
48
- | `intercom_send` | Configured, responsibility-loaded sessions; named asynchronous message |
49
- | `intercom_list` | Configured sessions; saved state only, never live health |
50
- | `intercom_request_status` | Coordinator; ask named worker extension for an independent report |
51
- | `intercom_report_status` | Workers including anonymous; shared report function, never registration |
52
- | `intercom_stop_worker`, `intercom_close_worker` | Coordinator role checked, then **unsupported-host error**, no side effects |
53
- | `intercom_resume_worker` | Coordinator; named saved session, configured launcher/directory |
54
- | `intercom_remove_worker` | Coordinator; config entry only, no shutdown/session deletion |
55
- | `intercom_set_multiplexer` | Coordinator; `herdr` or `none`, future launches only |
53
+ | Tool | Arguments | Permission / effect |
54
+ |---|---|---|
55
+ | `intercom_create_worker` | `projectDirectory?` (default `.`) | Coordinator; launch anonymous worker |
56
+ | `intercom_configure_worker` | `sessionId`, `port`, `projectDirectory`, `name`, `description` | Coordinator; all five explicit fields, writes only |
57
+ | `intercom_reload_worker` | `to` (worker name) | Coordinator; passive config/name reload |
58
+ | `intercom_send` | `to` (name), `message` | Configured, responsibility-loaded sessions; asynchronous message |
59
+ | `intercom_list` | None | Configured coordinator/workers; saved roster and derived `dashboardUrl`, never live health |
60
+ | `intercom_request_status` | `to` (worker name) | Coordinator; independent extension report |
61
+ | `intercom_report_status` | None | Workers including anonymous; shared report function, never registration |
62
+ | `intercom_stop_worker`, `intercom_close_worker` | `to` (worker name) | Coordinator role checked, then **unsupported-host error**, no side effects |
63
+ | `intercom_resume_worker` | `to` (worker name) | Coordinator; saved session, configured launcher/directory |
64
+ | `intercom_remove_worker` | `to` (worker name) | Coordinator; config entry only, no shutdown/session deletion |
65
+ | `intercom_set_multiplexer` | `multiplexer`: `herdr` or `none` | Coordinator; future launches only |
56
66
 
57
67
  **Removal precondition:** explicitly close a running worker before removing its entry. Since remote close is disabled, arrange explicit user closure in its terminal first. Intercom does not infer liveness from saved IDs/ports and cannot certify closure. No hidden probing or force-kill is performed.
58
68
 
@@ -77,6 +87,8 @@ Shared, source-controlled file: `<root>/.pi-intercom/config.json`. Do not ignore
77
87
  }
78
88
  ```
79
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
+
80
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.
81
93
 
82
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).
@@ -89,12 +101,26 @@ HTTP binds **127.0.0.1 only**, `POST /intercom`, UTF-8 JSON, at most 64 KiB. Env
89
101
 
90
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.
91
103
 
92
- `202 {"accepted":true}` means extension receipt/acceptance, never model completion. 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.
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.
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).
93
109
 
94
- Tool text output is bounded to 50 KiB/2000 lines. Full list data remains in the shared config file. 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).
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.
95
119
 
96
120
  ## Development and validation
97
121
 
122
+ Run these commands from a source checkout; tests and workflows are not shipped in the npm package. The [original implementation validation snapshot](https://github.com/mbundgaard/PiIntercom/blob/main/references/implementation-progress.md) is historical, not the current live-test inventory.
123
+
98
124
  ```sh
99
125
  npm ci
100
126
  npm run typecheck
@@ -108,18 +134,51 @@ Production source runs through Pi's TypeScript loader; `npm run build` generates
108
134
 
109
135
  ## Publishing
110
136
 
111
- `.github/workflows/publish.yml` publishes through npm Trusted Publishing (GitHub OIDC), without an npm token secret. Configure the npm trusted publisher as owner `mbundgaard`, repository `PiIntercom`, workflow `publish.yml`, with no environment.
137
+ [`.github/workflows/publish.yml`](https://github.com/mbundgaard/PiIntercom/blob/main/.github/workflows/publish.yml) publishes through npm Trusted Publishing (GitHub OIDC), without an npm token secret. Configure the npm trusted publisher as owner `mbundgaard`, repository `PiIntercom`, workflow `publish.yml`, with no environment.
112
138
 
113
139
  The workflow validates on Windows, then publishes with provenance from a GitHub-hosted Ubuntu runner. It runs when a GitHub release is published or when manually dispatched. Release tags must be `v<package.json version>`.
114
140
 
115
- Version `0.1.0` is already published and cannot be republished. For the next release, bump package and lockfile versions, commit/push, then publish a matching GitHub release. Do not dispatch publishing for an already published version. CI validates pushes and pull requests separately without publishing.
141
+ Published versions are immutable. For a new release, bump package and lockfile versions, commit/push, then publish a matching GitHub release. Manual dispatch publishes the selected ref and is **not a dry run**; the release-tag check only applies when a release tag is present. Do not dispatch publishing for an already published version. CI validates pushes to `main` and pull requests separately without publishing.
142
+
143
+ ### Release evidence
144
+
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).
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.
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
+
150
+ ### 0.1.3 changes and verification
151
+
152
+ Version 0.1.3 includes these fixes (not present in 0.1.2):
153
+
154
+ - Lifecycle generation/session checks reject stale queued or in-flight operations before submitting new launches, sends, deliveries or config publication. This includes delayed listener startup, anonymous registration and deferred status reporting. An OS link/rename or external action already submitted before invalidation is **not cancelled or rolled back**; there is no guarantee that in-flight external actions are drained.
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.
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.
157
+
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.
167
+
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.
169
+
170
+ ### Observed live behavior
171
+
172
+ The release/session evidence reported during the 0.1.2 validation establishes these specific observations, not blanket lifecycle compliance:
116
173
 
117
- Tests use temporary directories, local test-only HTTP servers, and mocked Pi hosts/launch executors. They never start agents, tabs, terminals, contact models, services or bridges, or access credentials.
174
+ - Encoded PowerShell invoking `pi.ps1` recovered an existing failed Herdr tab; Pi started and the worker registered.
175
+ - After package update and Pi reload, coordinator `intercom_create_worker` launched three workers; each registered, then was explicitly configured and separately reloaded. Integration's configured name was applied through configure/reload.
176
+ - An asynchronous Core → Coordinator message was relayed with `telegram_send` and receipt was confirmed by the owner. Telegram is a separate integration, not an Intercom transport or automatic forwarding feature.
118
177
 
119
- ### Not live-tested / remaining limitations
178
+ ### Remaining limitations / not comprehensively live-tested
120
179
 
121
- - Actual Pi extension loading, idle/steering queue delivery, busy snapshots, session-name persistence, trust UI and session replacement/reload lifecycle with live Pi hosts.
122
- - Complete Herdr create/register/configure/rename flow still needs end-to-end validation. The Windows launch path uses `herdr pane run` on the returned pane ID to invoke `pi.ps1` through explicitly encoded PowerShell, avoiding Herdr's failing `Start-Process pi` wrapper. Command submission is not agent readiness. This command path was live-tested in the previously failed tab: Pi started and registered successfully. No Herdr agent alias is needed.
180
+ - Idle/steering queue edge cases, busy snapshots, session-name persistence across restarts, trust UI and session replacement/reload lifecycle. The specific manual-compaction rejection gap described above remains unresolved; successful ordinary messaging does not verify that case.
181
+ - The observations above do not establish all Herdr failure paths, tab-association edge cases or readiness guarantees.
123
182
  - Windows visible terminal/Pi process startup. Success only proves terminal creation, **not Pi readiness**; startup failures remain visible in that terminal. No live readiness handshake was added.
124
183
  - Actual persisted-session lookup and resumed worker startup; never-used session failure.
125
184
  - Cross-process/network-drive atomicity and Windows ACL/sharing failures (same-process temp-filesystem races are tested).
@@ -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.2",
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",
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
  }
@@ -1,6 +1,6 @@
1
1
  # V1 cancellation capability blocked: Pi cancellation contract
2
2
 
3
- > **Current status:** The user subsequently approved implementing independent features. The supported core now exists; see `implementation-progress.md` and `../README.md`. Only stop/close remain disabled, failing before cancellation/shutdown. The historical feasibility report below is preserved as evidence; its statements that no implementation exists describe the earlier run, not current project status.
3
+ > **Current status:** The user subsequently approved implementing independent features. The supported core now exists; see the [historical implementation snapshot](https://github.com/mbundgaard/PiIntercom/blob/main/references/implementation-progress.md) and [current README](../README.md). Stop/close remain disabled, failing before cancellation/shutdown; README also records the separate manual-compaction input-acceptance gap. The historical feasibility report below is preserved as evidence; its statements that no implementation exists describe the earlier run, not current project status.
4
4
 
5
5
  ## Historical outcome
6
6
 
@@ -40,7 +40,7 @@ No private-runtime access, repeated abort polling, synthetic Escape input, setti
40
40
 
41
41
  Added `tests/pi-cancellation-probe.test.mjs`, a dependency-free compatibility probe. It extracts selected installed methods and executes them with mocks; it does not construct a Pi session, read credentials, contact a model, bind a server, launch a process, or modify Herdr.
42
42
 
43
- Git Bash invocation:
43
+ Git Bash invocation from a source checkout (the probe is not shipped in the npm package):
44
44
 
45
45
  ```bash
46
46
  PI_INTERCOM_PI_ROOT='C:/Users/martin/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent' \