@comput/pi-intercom 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.
@@ -1,6 +1,8 @@
1
1
  # PiIntercom V1 Specification
2
2
 
3
- This is the consolidated record of the agreed V1 design. It supersedes conflicting wording in the original architecture proposal and historical discussion notes. Implementation gaps are listed separately at the end; they are not silently approved decisions.
3
+ This is the consolidated **target design**, authoritative over conflicting wording in the original proposal and historical discussion notes. It is not a claim that every capability is currently supported. See [README](../README.md) for current behavior, release evidence and validation limits.
4
+
5
+ **Current capability exception:** `intercom_stop_worker` and `intercom_close_worker` are registered but disabled on every host, before cancellation/shutdown or outgoing control. Their intended guarantees below remain unchanged; see the [cancellation blocker](implementation-blocker.md). Manual-compaction delivery also has a known acceptance gap: HTTP receipt does not guarantee Pi accepted the input. Unresolved contract questions are not silently approved decisions.
4
6
 
5
7
  ## 1. Scope and principles
6
8
 
@@ -34,7 +36,7 @@ Required configuration information:
34
36
  | Agent | Pi session ID, name, coordinator flag, responsibility description, port |
35
37
  | Worker | Project directory relative to coordinator root |
36
38
 
37
- Exact schema and versioning remain implementation gaps. Do not treat a saved endpoint or session ID as proof that its process exists.
39
+ The implemented schema is version 1; its fields and validation are documented in [README: Config and wire contracts](../README.md#config-and-wire-contracts). Do not treat a saved endpoint or session ID as proof that its process exists.
38
40
 
39
41
  ## 3. Startup and registration
40
42
 
@@ -94,7 +96,7 @@ No worker-specific config path, callback address, instance ID, or preassigned re
94
96
  - Include sender identity and label agent messages with sender and purpose.
95
97
  - Incoming coordinator-only controls check sender session ID against current config. This is role validation, not authentication.
96
98
  - Receipt timeout is 5 seconds. No automatic retries. A failure or timeout is reported; the agent decides recovery.
97
- - Dedicated durable message logging is deferred until the basic system works.
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.
98
100
 
99
101
  ### Agent delivery
100
102
 
@@ -122,12 +124,12 @@ Every invocation reads the current Pi session ID and shared config, determines r
122
124
  | `intercom_create_worker` | Coordinator | `projectDirectory?` (default `.`) | Launch new anonymous worker with extension loaded; report launch success/failure without awaiting registration |
123
125
  | `intercom_configure_worker` | Coordinator | `sessionId`, `port`, `projectDirectory`, `name`, `description` | Write the explicitly supplied worker configuration; no notification |
124
126
  | `intercom_reload_worker` | Coordinator | `to` (name) | Send config-read control only |
125
- | `intercom_send` | Both | `to` (name), `message` | Deliver agent prompt/steering; return send result, not agent reply |
126
- | `intercom_list` | Both | None | Reread config; list names, responsibilities, IDs, roles, ports, without live probing |
127
+ | `intercom_send` | Configured, responsibility-loaded sessions | `to` (name), `message` | Deliver agent prompt/steering; return send result, not agent reply |
128
+ | `intercom_list` | Configured sessions | None | Reread config; list names, responsibilities, IDs, roles, ports, without live probing |
127
129
  | `intercom_request_status` | Coordinator | `to` (name) | Ask worker extension to send independent runtime status report |
128
- | `intercom_report_status` | Worker | None | Invoke shared reporting function; reject on coordinator |
129
- | `intercom_stop_worker` | Coordinator | `to` (name) | Cancel current work; leave Pi and extension running |
130
- | `intercom_close_worker` | Coordinator | `to` (name) | Cancel work, gracefully exit Pi, preserve session/config |
130
+ | `intercom_report_status` | Worker, including anonymous | None | Invoke shared reporting function; reject on coordinator |
131
+ | `intercom_stop_worker` | Coordinator | `to` (name) | Target: cancel current work; leave Pi and extension running. Currently disabled; unsupported-host error |
132
+ | `intercom_close_worker` | Coordinator | `to` (name) | Target: cancel work, gracefully exit Pi, preserve session/config. Currently disabled; unsupported-host error |
131
133
  | `intercom_resume_worker` | Coordinator | `to` (name) | Launch saved session using configured directory and launcher; failures leave config unchanged |
132
134
  | `intercom_remove_worker` | Coordinator | `to` (name) | Remove config entry only; no process stop or session-file deletion |
133
135
  | `intercom_set_multiplexer` | Coordinator | `multiplexer` | Set `herdr` or `none` for future launches, including resume |
@@ -137,7 +139,7 @@ Every invocation reads the current Pi session ID and shared config, determines r
137
139
  - Configure rejects duplicate names, including `Coordinator`, case-insensitively. Keeping a session's own name is allowed.
138
140
  - Configure and reload are deliberately separate. Reload is not Pi's extension-code reload, a process restart, cancellation, or a work assignment. Explain this in tool and user documentation.
139
141
  - Reload synchronizes Pi session and applicable Herdr tab names to config.
140
- - Stop/close use Pi cancellation and graceful shutdown; no force-kill, automatic commit, or rollback. Close does not require a separate stop call.
142
+ - Target contract: stop/close use Pi cancellation and graceful shutdown; no force-kill, automatic commit, or rollback. Close does not require a separate stop call. **Currently both fail closed; no stop or shutdown is performed.**
141
143
  - Close success at transport level is not proof that process exit has completed.
142
144
  - Remove a running worker only after explicitly closing it; removal itself never closes it.
143
145
  - Resume failure must be surfaced; no implicit removal, replacement, or reassignment.
@@ -156,21 +158,21 @@ Every invocation reads the current Pi session ID and shared config, determines r
156
158
  ## 8. Explicitly deferred
157
159
 
158
160
  - Other operating systems and multiplexers.
159
- - Durable message logging, including storage/retention policy.
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.
160
162
  - Duplicate Pi-session activation detection or session-ID locks.
161
163
  - Automatic retry queues and recovery orchestration are not V1 behavior.
162
164
  - Bulk tools: use individual operations instead.
163
165
 
164
166
  ## 9. Remaining implementation gaps — not new approved behavior
165
167
 
166
- Consolidate these before coding; avoid reopening settled user flows.
168
+ Original design-gap inventory with current implementation annotations. Avoid reopening settled user flows. Implemented mechanisms are not proof of comprehensive live-host validation.
167
169
 
168
170
  1. **Registration handoff — resolved:** deliver reported session ID, port, and project directory into the coordinator Pi context. The coordinator agent decides what to do and supplies all values explicitly to configure_worker. No hidden pending-registration map, placeholder entry, or automatic configuration/reload workflow.
169
171
  2. **Retrying anonymous registration:** status reports do not create registrations. Define an explicit way to repeat a failed initial registration without conflating it with known-worker status.
170
- 3. **Wire/config schemas:** finalize schema version, field names, control types, message envelope, validation, body limits, optional message IDs and receipt acknowledgment boundary. No synchronous agent reply semantics.
172
+ 3. **Wire/config schemas — implemented:** schema version 1, documented fields/control kinds, validation, 64 KiB body bound and extension-receipt acknowledgment are described in README. No message IDs, deduplication or synchronous agent replies. Receipt is not proof of Pi input acceptance or model completion.
171
173
  4. **Pi lifecycle APIs:** verify extension loading, session-ID resume lookup, cancellation, queued-message treatment on stop/close, busy detection, and session-switch cleanup. Stop must not unexpectedly restart queued work; exact API feasibility needs checking.
172
174
  5. **Launch integration:** verify installed Herdr operations and a Windows visible-terminal implementation, including readiness/launch-failure observability and tab association. A successful process launch is not worker registration success.
173
- 6. **Configuration writes:** implement safe serialized/atomic coordinator writes and first-creation race handling. Define bounded handling of incomplete initialization. No malformed-file overwrites.
175
+ 6. **Configuration writes — implemented, validation limits remain:** serialized atomic updates and complete-file no-replace initial publication; invalid config is not overwritten. See README for filesystem assumptions and unverified cross-process/network-drive cases.
174
176
  7. **Missing coordinator session:** workers report connection failure and remain reachable. Explicit coordinator takeover is not specified or approved; do not invent it.
175
177
  8. **Unconfigured worker permissions:** explicitly define allowed worker-side tools before its entry exists, while retaining registration/reporting ability and forbidding coordinator operations.
176
178
  9. **Protocol control checks:** distinguish initial registration from configured-sender validation. Reload must reach the anonymous worker after the coordinator has created its entry.
@@ -180,4 +182,4 @@ Consolidate these before coding; avoid reopening settled user flows.
180
182
  - Read installed Pi documentation and relevant Herdr skills before implementation.
181
183
  - Document tool permissions, startup/registration flow, passive responsibility loading, separate configure/reload calls, and failure semantics.
182
184
  - Test role gates, port fallback, recipient mismatch, duplicate names, config errors, explicit recovery, and asynchronous steering/status paths.
183
- - No implementation code has been produced during this design discussion.
185
+ - The original design discussion produced no implementation code. A supported-core implementation and releases now exist; current evidence and limitations are maintained in README.
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');
@@ -74,41 +80,60 @@ export class ConfigStore {
74
80
  const parent = path.dirname(at); if (parent === at) return undefined; at = parent;
75
81
  }
76
82
  }
83
+ protected openTemporary(file: string) { return open(file, 'wx'); }
77
84
  private async temporary(config: Config): Promise<string> {
78
85
  validateConfig(config);
79
86
  await mkdir(path.dirname(this.file), { recursive: true });
80
87
  const temp = `${this.file}.${randomUUID()}.tmp`;
81
- const handle = await open(temp, 'wx');
82
- try { await handle.writeFile(JSON.stringify(config, null, 2) + '\n'); await handle.sync(); }
83
- finally { await handle.close(); }
84
- return temp;
88
+ const handle = await this.openTemporary(temp);
89
+ try {
90
+ await handle.writeFile(JSON.stringify(config, null, 2) + '\n');
91
+ await handle.sync();
92
+ await handle.close();
93
+ return temp;
94
+ } catch (error) {
95
+ // Cleanup failures must not replace the original write/sync/close error.
96
+ await handle.close().catch(() => {});
97
+ await unlink(temp).catch(() => {});
98
+ throw error;
99
+ }
85
100
  }
86
- async initialize(id: string, boundPort: number): Promise<boolean> {
101
+ async initialize(id: string, boundPort: number, assertValid: () => void = () => {}): Promise<boolean> {
87
102
  // Publish an already complete file using an atomic, no-replace hard link.
88
103
  // Losers only ever see complete JSON; unsupported filesystems fail explicitly.
104
+ assertValid();
89
105
  const temp = await this.temporary({ version: 1, multiplexer: 'herdr', agents: [{ sessionId: id, name: 'Coordinator', coordinator: true, description: DEFAULT_DESCRIPTION, port: boundPort, projectDirectory: '.' }] });
90
- try { await link(temp, this.file); return true; }
91
- catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') { await this.read(); return false; } throw e; }
92
- finally { await unlink(temp); }
106
+ try {
107
+ assertValid();
108
+ try { await link(temp, this.file); }
109
+ catch (e) {
110
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
111
+ await this.read(); await unlink(temp); return false;
112
+ }
113
+ await unlink(temp); return true;
114
+ } catch (error) { await unlink(temp).catch(() => {}); throw error; }
93
115
  }
94
- async update(id: string, mutate: (config: Config) => void | Promise<void>): Promise<Config> {
116
+ async update(id: string, mutate: (config: Config) => void | Promise<void>, assertValid: () => void = () => {}): Promise<Config> {
95
117
  const operation = this.tail.then(async () => {
96
- const c = await this.read(); requireCoordinator(c, id);
97
- await mutate(c); validateConfig(c);
118
+ assertValid();
119
+ const c = await this.read(); assertValid(); requireCoordinator(c, id);
120
+ await mutate(c); assertValid(); validateConfig(c);
98
121
  const temp = await this.temporary(c);
99
- try { await rename(temp, this.file); }
100
- finally { await unlink(temp).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
122
+ try {
123
+ // Last check before publication; an already submitted OS rename cannot be undone.
124
+ assertValid(); await rename(temp, this.file);
125
+ } catch (error) { await unlink(temp).catch(() => {}); throw error; }
101
126
  return c;
102
127
  });
103
128
  this.tail = operation.catch(() => {}); return operation;
104
129
  }
105
- async configure(id: string, values: Omit<Agent, 'coordinator'>): Promise<Config> {
130
+ async configure(id: string, values: Omit<Agent, 'coordinator'>, assertValid: () => void = () => {}): Promise<Config> {
106
131
  return this.update(id, async c => {
107
132
  if (values.sessionId === coordinator(c).sessionId) fail('cannot configure coordinator as worker');
108
133
  const projectDirectory = await directory(this.root, values.projectDirectory);
109
134
  const a: Agent = { sessionId: values.sessionId, name: values.name, description: values.description, port: values.port, projectDirectory, coordinator: false };
110
135
  const index = c.agents.findIndex(old => old.sessionId === a.sessionId);
111
136
  if (index < 0) c.agents.push(a); else c.agents[index] = a;
112
- });
137
+ }, assertValid);
113
138
  }
114
139
  }
@@ -0,0 +1,158 @@
1
+ import { createServer } from 'node:http';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, opendir, realpath } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { validateConfig, type Config } from './config.js';
7
+ import { LOG_DIRECTORY, LOG_FILE_PATTERN, sanitizeObservation, type Observation } from './observability.js';
8
+
9
+ const CONFIG_BYTES = 1024 * 1024, TAIL_BYTES = 128 * 1024, TOTAL_BYTES = 2 * 1024 * 1024;
10
+ const MAX_FILES = 32, MAX_ENTRIES = 512, MAX_EVENTS = 500, MAX_AGENTS = 256;
11
+ export interface DashboardSnapshot {
12
+ version: 1;
13
+ generatedAt: string;
14
+ staleAfterMs: number;
15
+ config: Pick<Config, 'multiplexer' | 'agents'> | null;
16
+ events: Observation[];
17
+ truncated: boolean;
18
+ errors: string[];
19
+ }
20
+
21
+ // Only fixed, extension-owned paths are read. Reject symlink/junction components
22
+ // rather than allowing a local log/config link to expose an unrelated file.
23
+ async function confined(root: string, relative: string): Promise<string> {
24
+ const base = await realpath(root);
25
+ let current = base;
26
+ for (const segment of relative.split(/[\\/]/)) {
27
+ if (!segment || segment === '.' || segment === '..') throw new Error('invalid path');
28
+ current = path.join(current, segment);
29
+ if ((await lstat(current)).isSymbolicLink()) throw new Error('linked path');
30
+ if (path.relative(current, await realpath(current)) !== '') throw new Error('redirected path');
31
+ }
32
+ return current;
33
+ }
34
+
35
+ async function boundedRead(file: string, limit: number, tail = false): Promise<{ text: string; truncated: boolean; bytes: number }> {
36
+ if (!(await lstat(file)).isFile()) throw new Error('not a file');
37
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
38
+ try {
39
+ const stat = await handle.stat();
40
+ if (!stat.isFile()) throw new Error('not a file');
41
+ if (!tail && stat.size > limit) throw new Error('file too large');
42
+ const size = Math.min(stat.size, limit), start = tail ? Math.max(0, stat.size - size) : 0;
43
+ const buffer = Buffer.alloc(size);
44
+ const { bytesRead } = await handle.read(buffer, 0, size, start);
45
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
46
+ // Drop potentially partial UTF-8/JSON at both tail boundaries. Writers append
47
+ // complete newline-terminated records; incomplete records are not evidence.
48
+ if (tail) {
49
+ if (start > 0) text = text.slice(text.indexOf('\n') + 1);
50
+ text = text.slice(0, text.lastIndexOf('\n') + 1);
51
+ }
52
+ return { text, truncated: stat.size > limit, bytes: bytesRead };
53
+ } finally { await handle.close(); }
54
+ }
55
+
56
+ export async function readDashboardSnapshot(root: string): Promise<DashboardSnapshot> {
57
+ const result: DashboardSnapshot = { version: 1, generatedAt: new Date().toISOString(), staleAfterMs: 60000, config: null, events: [], truncated: false, errors: [] };
58
+ try {
59
+ const file = await confined(root, '.pi-intercom/config.json');
60
+ const config = validateConfig(JSON.parse((await boundedRead(file, CONFIG_BYTES)).text));
61
+ result.truncated ||= config.agents.length > MAX_AGENTS;
62
+ result.config = { multiplexer: config.multiplexer, agents: config.agents.slice(0, MAX_AGENTS).map(a => ({
63
+ sessionId: a.sessionId, name: a.name, coordinator: a.coordinator, description: a.description,
64
+ projectDirectory: a.projectDirectory, port: a.port,
65
+ ...(a.dashboardPort === undefined ? {} : { dashboardPort: a.dashboardPort }),
66
+ })) };
67
+ } catch { result.errors.push('config_unavailable'); }
68
+ try {
69
+ const directory = await confined(root, LOG_DIRECTORY);
70
+ const files: { name: string; modified: number }[] = [];
71
+ const entries = await opendir(directory);
72
+ let scanned = 0;
73
+ for await (const entry of entries) {
74
+ if (++scanned > MAX_ENTRIES) { result.truncated = true; break; }
75
+ if (!entry.isFile() || !LOG_FILE_PATTERN.test(entry.name)) continue;
76
+ try {
77
+ const file = await confined(root, `${LOG_DIRECTORY}/${entry.name}`);
78
+ files.push({ name: entry.name, modified: (await lstat(file)).mtimeMs });
79
+ } catch { /* Rotations can remove a file while scanning. */ }
80
+ }
81
+ files.sort((a, b) => b.modified - a.modified || a.name.localeCompare(b.name));
82
+ result.truncated ||= files.length > MAX_FILES;
83
+ let budget = TOTAL_BYTES;
84
+ for (const file of files.slice(0, MAX_FILES)) {
85
+ if (budget <= 0) { result.truncated = true; break; }
86
+ try {
87
+ const resolved = await confined(root, `${LOG_DIRECTORY}/${file.name}`);
88
+ const read = await boundedRead(resolved, Math.min(TAIL_BYTES, budget), true);
89
+ budget -= read.bytes;
90
+ result.truncated ||= read.truncated;
91
+ for (const line of read.text.split('\n')) {
92
+ if (!line) continue;
93
+ try {
94
+ const event = sanitizeObservation(JSON.parse(line));
95
+ if (event) result.events.push(event);
96
+ } catch { /* Ignore malformed/partial/foreign records, never expose raw data. */ }
97
+ }
98
+ } catch { if (!result.errors.includes('log_read_failed')) result.errors.push('log_read_failed'); }
99
+ }
100
+ result.events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
101
+ if (result.events.length > MAX_EVENTS) { result.events = result.events.slice(-MAX_EVENTS); result.truncated = true; }
102
+ } catch { result.errors.push('logs_unavailable'); }
103
+ return result;
104
+ }
105
+
106
+ export interface Dashboard { url: string; port: number; close(): Promise<void> }
107
+ export async function startDashboard(root: string, options: { assetDirectory?: string } = {}): Promise<Dashboard> {
108
+ const assets = options.assetDirectory ?? fileURLToPath(new URL('../dashboard/', import.meta.url));
109
+ const routes: Record<string, [string, string]> = {
110
+ '/': ['index.html', 'text/html; charset=utf-8'],
111
+ '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
112
+ '/style.css': ['style.css', 'text/css; charset=utf-8'],
113
+ };
114
+ let port = 0, active = 0, closing = false;
115
+ let pending: Promise<DashboardSnapshot> | undefined;
116
+ const server = createServer(async (request, response) => {
117
+ response.setHeader('Cache-Control', 'no-store');
118
+ response.setHeader('X-Content-Type-Options', 'nosniff');
119
+ response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'");
120
+ response.setHeader('Referrer-Policy', 'no-referrer');
121
+ const end = (code: number, body: string, type = 'text/plain; charset=utf-8') => {
122
+ response.writeHead(code, { 'Content-Type': type }); response.end(body);
123
+ };
124
+ const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
125
+ if (!hosts.includes(request.headers.host ?? '') ||
126
+ (request.headers.origin !== undefined && !hosts.some(host => request.headers.origin === `http://${host}`)) ||
127
+ request.headers['sec-fetch-site'] === 'cross-site') return end(403, 'Forbidden');
128
+ if (request.method !== 'GET') { response.setHeader('Allow', 'GET'); return end(405, 'GET only'); }
129
+ if (closing || active >= 8) return end(503, 'Unavailable');
130
+ active++;
131
+ try {
132
+ if (request.url === '/api/snapshot') {
133
+ pending ??= readDashboardSnapshot(root).finally(() => { pending = undefined; });
134
+ return end(200, JSON.stringify(await pending), 'application/json; charset=utf-8');
135
+ }
136
+ const route = Object.hasOwn(routes, request.url ?? '') ? routes[request.url!] : undefined;
137
+ if (!route) return end(404, 'Not found');
138
+ const file = await confined(assets, route[0]);
139
+ return end(200, (await boundedRead(file, 256 * 1024)).text, route[1]);
140
+ } catch { return end(503, 'Unavailable'); }
141
+ finally { active--; }
142
+ });
143
+ server.requestTimeout = 5000; server.headersTimeout = 5000; server.keepAliveTimeout = 1000;
144
+ server.maxConnections = 16;
145
+ await new Promise<void>((resolve, reject) => {
146
+ server.once('error', reject);
147
+ server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
148
+ });
149
+ port = (server.address() as { port: number }).port;
150
+ let closed: Promise<void> | undefined;
151
+ return { port, url: `http://127.0.0.1:${port}/`, close() {
152
+ closing = true;
153
+ return closed ??= new Promise<void>((resolve, reject) => {
154
+ server.close(error => error ? reject(error) : resolve());
155
+ server.closeAllConnections();
156
+ });
157
+ } };
158
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { fileURLToPath } from 'node:url';
2
+ import path from 'node:path';
2
3
  import { Type, type TSchema } from 'typebox';
3
- import { SessionManager, truncateHead, withFileMutationQueue, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
4
+ import { getAgentDir, SessionManager, SettingsManager, truncateHead, withFileMutationQueue, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
4
5
  import { ConfigStore } from './config.js';
5
6
  import { Intercom, UNSUPPORTED_CANCELLATION } from './runtime.js';
6
7
  import { launchers } from './launcher.js';
8
+ import { startDashboard, type Dashboard } from './dashboard.js';
7
9
 
8
10
  const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
9
11
  const tools: [string, string, TSchema][] = [
@@ -21,20 +23,35 @@ const tools: [string, string, TSchema][] = [
21
23
  ['set_multiplexer', 'Coordinator only. Set herdr (default) or none (separate visible Windows terminals) for future launches. Never move/restart existing workers or fall back.', Type.Object({ multiplexer: Type.String({ enum: ['herdr', 'none'] }) })],
22
24
  ];
23
25
 
26
+ // Launchers pass no --session-dir. Match child Pi's env > per-cwd settings >
27
+ // default lookup, resolving relative paths against the child's cwd, not ours.
28
+ // SettingsManager's public getter applies Pi's own path/tilde normalization.
29
+ export function resumeSessionDirectory(cwd: string, envSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR, agentDir = getAgentDir()): string | undefined {
30
+ const settings = envSessionDir
31
+ ? SettingsManager.inMemory({ sessionDir: envSessionDir })
32
+ : SettingsManager.create(cwd, path.resolve(cwd, agentDir));
33
+ const sessionDir = settings.getSessionDir();
34
+ return sessionDir ? path.resolve(cwd, sessionDir) : undefined;
35
+ }
36
+
24
37
  // Participate in Pi's own per-file mutation queue as well as Intercom serialization.
25
38
  class PiConfigStore extends ConfigStore {
26
- override update(id: string, mutate: Parameters<ConfigStore['update']>[1]) {
27
- return withFileMutationQueue(this.file, () => super.update(id, mutate));
39
+ override update(id: string, mutate: Parameters<ConfigStore['update']>[1], assertValid?: () => void) {
40
+ return withFileMutationQueue(this.file, () => super.update(id, mutate, assertValid));
28
41
  }
29
42
  }
30
43
  export default function intercomExtension(pi: ExtensionAPI): void {
31
44
  let runtime: Intercom | undefined;
32
45
  let context: ExtensionContext | undefined;
46
+ let dashboard: Dashboard | undefined;
47
+ let generation = 0;
33
48
  const launcher = launchers({
34
49
  extension: fileURLToPath(import.meta.url),
35
- sessionExists: async (cwd, id) => (await SessionManager.list(cwd, process.env.PI_CODING_AGENT_SESSION_DIR)).some(s => s.id === id),
50
+ sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
36
51
  });
37
52
  pi.on('session_start', async (_event, ctx) => {
53
+ const started = ++generation;
54
+ await dashboard?.close(); dashboard = undefined;
38
55
  await runtime?.close(); context = ctx;
39
56
  if (process.platform !== 'win32' || ctx.mode !== 'tui') throw new Error('PiIntercom V1 requires Windows interactive Pi. No resources started.');
40
57
  if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
@@ -46,10 +63,43 @@ export default function intercomExtension(pi: ExtensionAPI): void {
46
63
  setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
47
64
  notify: text => ctx.ui.notify(text, 'info'),
48
65
  }, { launch: launcher.launch, store: root => new PiConfigStore(root) });
49
- try { await runtime.start(); }
50
- catch (e) { runtime = undefined; ctx.ui.notify(String(e), 'error'); }
66
+ const current = runtime;
67
+ try { await current.start(); }
68
+ catch (e) { if (runtime === current) runtime = undefined; ctx.ui.notify(String(e), 'error'); return; }
69
+ if (started !== generation) return;
70
+ let server: Dashboard | undefined;
71
+ let savingPort = false;
72
+ try {
73
+ if ((await current.state()).me?.coordinator) {
74
+ server = await startDashboard(current.store.root);
75
+ if (started !== generation) { await server.close(); return; }
76
+ savingPort = true;
77
+ await current.recordDashboardPort(server.port);
78
+ if (started !== generation) { await server.close(); return; }
79
+ dashboard = server;
80
+ ctx.ui.notify(`Intercom read-only dashboard: ${server.url} (saved URL is last-known, not live availability)`, 'info');
81
+ }
82
+ } catch {
83
+ await server?.close().catch(() => {});
84
+ if (started !== generation) return;
85
+ ctx.ui.notify(savingPort
86
+ ? 'Intercom dashboard port could not be saved; the new dashboard was closed. Any saved URL is last-known only. Messaging remains available.'
87
+ : 'Intercom dashboard unavailable; communication remains independent.', 'error');
88
+ }
89
+ });
90
+ pi.on('session_shutdown', async () => {
91
+ generation++;
92
+ await dashboard?.close(); dashboard = undefined;
93
+ await runtime?.close(); runtime = undefined; context = undefined;
94
+ });
95
+ pi.on('agent_start', async (_event, ctx) => {
96
+ context = ctx;
97
+ runtime?.recordObservation('host.activity', { busy: !ctx.isIdle(), outcome: 'started' });
98
+ });
99
+ pi.on('agent_settled', async (_event, ctx) => {
100
+ context = ctx;
101
+ runtime?.recordObservation('host.activity', { busy: !ctx.isIdle(), outcome: 'settled' });
51
102
  });
52
- pi.on('session_shutdown', async () => { await runtime?.close(); runtime = undefined; context = undefined; });
53
103
  pi.on('before_agent_start', async (event, ctx) => {
54
104
  context = ctx;
55
105
  if (!runtime) return;