@comput/pi-intercom 0.1.2 → 0.1.3

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 sessions; saved state only, 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
 
@@ -89,12 +99,14 @@ HTTP binds **127.0.0.1 only**, `POST /intercom`, UTF-8 JSON, at most 64 KiB. Env
89
99
 
90
100
  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
101
 
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.
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. 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.
93
103
 
94
104
  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).
95
105
 
96
106
  ## Development and validation
97
107
 
108
+ 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.
109
+
98
110
  ```sh
99
111
  npm ci
100
112
  npm run typecheck
@@ -108,18 +120,41 @@ Production source runs through Pi's TypeScript loader; `npm run build` generates
108
120
 
109
121
  ## Publishing
110
122
 
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.
123
+ [`.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
124
 
113
125
  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
126
 
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.
127
+ 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.
128
+
129
+ ### Release evidence
130
+
131
+ - [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
+ - [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
+
134
+ ### 0.1.3 changes and verification
135
+
136
+ Version 0.1.3 includes these fixes (not present in 0.1.2):
137
+
138
+ - 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.
139
+ - 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
+ - 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
+
142
+ Coordinator validation of this source snapshot: `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 these fixes. 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.
143
+
144
+ 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
+
146
+ ### Observed live behavior
147
+
148
+ The release/session evidence reported during the 0.1.2 validation establishes these specific observations, not blanket lifecycle compliance:
116
149
 
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.
150
+ - Encoded PowerShell invoking `pi.ps1` recovered an existing failed Herdr tab; Pi started and the worker registered.
151
+ - 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.
152
+ - 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
153
 
119
- ### Not live-tested / remaining limitations
154
+ ### Remaining limitations / not comprehensively live-tested
120
155
 
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.
156
+ - 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.
157
+ - The observations above do not establish all Herdr failure paths, tab-association edge cases or readiness guarantees.
123
158
  - 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
159
  - Actual persisted-session lookup and resumed worker startup; never-used session failure.
125
160
  - Cross-process/network-drive atomicity and Windows ACL/sharing failures (same-process temp-filesystem races are tested).
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.1.3",
16
16
  "description": "Explicit local communication between Pi sessions; Pi orchestrates.",
17
17
  "type": "module",
18
18
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "scripts": {
46
46
  "build": "tsc",
47
47
  "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",
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",
49
49
  "test:host": "node --test tests/pi-cancellation-probe.test.mjs",
50
50
  "check:package": "npm pack --dry-run --ignore-scripts"
51
51
  }
@@ -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' \
@@ -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
 
@@ -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.
@@ -163,14 +165,14 @@ Every invocation reads the current Pi session ID and shared config, determines r
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
@@ -74,41 +74,60 @@ export class ConfigStore {
74
74
  const parent = path.dirname(at); if (parent === at) return undefined; at = parent;
75
75
  }
76
76
  }
77
+ protected openTemporary(file: string) { return open(file, 'wx'); }
77
78
  private async temporary(config: Config): Promise<string> {
78
79
  validateConfig(config);
79
80
  await mkdir(path.dirname(this.file), { recursive: true });
80
81
  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;
82
+ const handle = await this.openTemporary(temp);
83
+ try {
84
+ await handle.writeFile(JSON.stringify(config, null, 2) + '\n');
85
+ await handle.sync();
86
+ await handle.close();
87
+ return temp;
88
+ } catch (error) {
89
+ // Cleanup failures must not replace the original write/sync/close error.
90
+ await handle.close().catch(() => {});
91
+ await unlink(temp).catch(() => {});
92
+ throw error;
93
+ }
85
94
  }
86
- async initialize(id: string, boundPort: number): Promise<boolean> {
95
+ async initialize(id: string, boundPort: number, assertValid: () => void = () => {}): Promise<boolean> {
87
96
  // Publish an already complete file using an atomic, no-replace hard link.
88
97
  // Losers only ever see complete JSON; unsupported filesystems fail explicitly.
98
+ assertValid();
89
99
  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); }
100
+ try {
101
+ assertValid();
102
+ try { await link(temp, this.file); }
103
+ catch (e) {
104
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
105
+ await this.read(); await unlink(temp); return false;
106
+ }
107
+ await unlink(temp); return true;
108
+ } catch (error) { await unlink(temp).catch(() => {}); throw error; }
93
109
  }
94
- async update(id: string, mutate: (config: Config) => void | Promise<void>): Promise<Config> {
110
+ async update(id: string, mutate: (config: Config) => void | Promise<void>, assertValid: () => void = () => {}): Promise<Config> {
95
111
  const operation = this.tail.then(async () => {
96
- const c = await this.read(); requireCoordinator(c, id);
97
- await mutate(c); validateConfig(c);
112
+ assertValid();
113
+ const c = await this.read(); assertValid(); requireCoordinator(c, id);
114
+ await mutate(c); assertValid(); validateConfig(c);
98
115
  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; }); }
116
+ try {
117
+ // Last check before publication; an already submitted OS rename cannot be undone.
118
+ assertValid(); await rename(temp, this.file);
119
+ } catch (error) { await unlink(temp).catch(() => {}); throw error; }
101
120
  return c;
102
121
  });
103
122
  this.tail = operation.catch(() => {}); return operation;
104
123
  }
105
- async configure(id: string, values: Omit<Agent, 'coordinator'>): Promise<Config> {
124
+ async configure(id: string, values: Omit<Agent, 'coordinator'>, assertValid: () => void = () => {}): Promise<Config> {
106
125
  return this.update(id, async c => {
107
126
  if (values.sessionId === coordinator(c).sessionId) fail('cannot configure coordinator as worker');
108
127
  const projectDirectory = await directory(this.root, values.projectDirectory);
109
128
  const a: Agent = { sessionId: values.sessionId, name: values.name, description: values.description, port: values.port, projectDirectory, coordinator: false };
110
129
  const index = c.agents.findIndex(old => old.sessionId === a.sessionId);
111
130
  if (index < 0) c.agents.push(a); else c.agents[index] = a;
112
- });
131
+ }, assertValid);
113
132
  }
114
133
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
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';
@@ -21,10 +22,21 @@ const tools: [string, string, TSchema][] = [
21
22
  ['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
23
  ];
23
24
 
25
+ // Launchers pass no --session-dir. Match child Pi's env > per-cwd settings >
26
+ // default lookup, resolving relative paths against the child's cwd, not ours.
27
+ // SettingsManager's public getter applies Pi's own path/tilde normalization.
28
+ export function resumeSessionDirectory(cwd: string, envSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR, agentDir = getAgentDir()): string | undefined {
29
+ const settings = envSessionDir
30
+ ? SettingsManager.inMemory({ sessionDir: envSessionDir })
31
+ : SettingsManager.create(cwd, path.resolve(cwd, agentDir));
32
+ const sessionDir = settings.getSessionDir();
33
+ return sessionDir ? path.resolve(cwd, sessionDir) : undefined;
34
+ }
35
+
24
36
  // Participate in Pi's own per-file mutation queue as well as Intercom serialization.
25
37
  class PiConfigStore extends ConfigStore {
26
- override update(id: string, mutate: Parameters<ConfigStore['update']>[1]) {
27
- return withFileMutationQueue(this.file, () => super.update(id, mutate));
38
+ override update(id: string, mutate: Parameters<ConfigStore['update']>[1], assertValid?: () => void) {
39
+ return withFileMutationQueue(this.file, () => super.update(id, mutate, assertValid));
28
40
  }
29
41
  }
30
42
  export default function intercomExtension(pi: ExtensionAPI): void {
@@ -32,7 +44,7 @@ export default function intercomExtension(pi: ExtensionAPI): void {
32
44
  let context: ExtensionContext | undefined;
33
45
  const launcher = launchers({
34
46
  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),
47
+ sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
36
48
  });
37
49
  pi.on('session_start', async (_event, ctx) => {
38
50
  await runtime?.close(); context = ctx;
package/src/runtime.ts CHANGED
@@ -22,6 +22,7 @@ export class Intercom {
22
22
  store!: ConfigStore;
23
23
  endpoint?: Endpoint;
24
24
  private active = false;
25
+ private generation = 0;
25
26
  private initialId = '';
26
27
  responsibility?: Agent;
27
28
  constructor(readonly host: Host, readonly options: RuntimeOptions) {}
@@ -30,67 +31,100 @@ export class Intercom {
30
31
  if (!this.active || id !== this.initialId) fail('session is inactive or replaced');
31
32
  return id;
32
33
  }
34
+ private validity(): () => void {
35
+ const generation = this.generation;
36
+ this.id();
37
+ return () => {
38
+ this.id();
39
+ if (generation !== this.generation) fail('runtime lifecycle replaced');
40
+ };
41
+ }
33
42
  async start(): Promise<void> {
43
+ if (this.active) fail('runtime already active');
34
44
  this.initialId = this.host.sessionId(); this.active = true;
45
+ const generation = ++this.generation, assertValid = this.validity();
35
46
  try {
36
47
  const discovered = await ConfigStore.discover(this.host.cwd);
48
+ assertValid();
37
49
  this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
38
50
  const previous = discovered ? await this.store.read() : undefined;
51
+ assertValid();
39
52
  const own = previous?.agents.find(a => a.sessionId === this.id());
40
- this.endpoint = await (this.options.listen ?? listen)(own?.port, m => this.receive(m));
41
- if (!previous) await this.store.initialize(this.id(), this.endpoint.port);
53
+ const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); });
54
+ try { assertValid(); } catch (error) { await endpoint.close(); throw error; }
55
+ this.endpoint = endpoint;
56
+ if (!previous) await this.store.initialize(this.id(), this.endpoint.port, assertValid);
42
57
  const config = await this.store.read();
58
+ assertValid();
43
59
  const me = config.agents.find(a => a.sessionId === this.id());
44
60
  if (me?.coordinator) {
45
- await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; });
46
- try { await this.reload(); } catch (e) { this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); }
61
+ await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
62
+ assertValid();
63
+ try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); }
64
+ assertValid();
47
65
  this.host.notify(`Intercom Coordinator ready at 127.0.0.1:${this.endpoint.port}; waiting for user input.`);
48
66
  } else {
49
- if (me) { try { await this.reload(); } catch (e) { this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); } }
67
+ if (me) { try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); } }
68
+ assertValid();
50
69
  try {
51
70
  if (me) await this.report();
52
- else await this.transmit(coordinator(config).name, 'registration', { port: this.endpoint.port, projectDirectory: await directory(this.store.root, this.host.cwd) });
53
- } catch (e) { this.host.notify(`Coordinator unreachable/registration failed; worker remains reachable: ${String(e)}. No retry. Anonymous registration repeat is an unresolved contract; use explicit Pi /reload to restart this extension, not report_status.`); }
71
+ else {
72
+ const projectDirectory = await directory(this.store.root, this.host.cwd);
73
+ assertValid();
74
+ await this.transmit(coordinator(config).name, 'registration', { port: this.endpoint.port, projectDirectory });
75
+ }
76
+ } catch (e) { assertValid(); this.host.notify(`Coordinator unreachable/registration failed; worker remains reachable: ${String(e)}. No retry. Anonymous registration repeat is an unresolved contract; use explicit Pi /reload to restart this extension, not report_status.`); }
77
+ assertValid();
54
78
  this.host.notify(`Intercom worker ready at 127.0.0.1:${this.endpoint.port}; waiting for explicit work.`);
55
79
  }
56
- } catch (e) { await this.close(); throw e; }
80
+ } catch (e) { if (generation === this.generation) await this.close(); throw e; }
57
81
  }
58
82
  async close(): Promise<void> {
59
- this.active = false;
83
+ this.active = false; this.generation++;
60
84
  const endpoint = this.endpoint; this.endpoint = undefined;
61
- if (endpoint) await endpoint.close();
62
85
  this.responsibility = undefined;
86
+ if (endpoint) await endpoint.close();
63
87
  }
64
88
  async state(): Promise<{ id: string; config: Config; me?: Agent }> {
89
+ const assertValid = this.validity();
65
90
  const id = this.id(), config = await this.store.read();
66
- this.id();
91
+ assertValid();
67
92
  return { id, config, me: config.agents.find(a => a.sessionId === id) };
68
93
  }
69
94
  async reload(): Promise<void> {
95
+ const assertValid = this.validity();
70
96
  const { me } = await this.state();
97
+ assertValid();
71
98
  if (!me) fail('worker not configured; configure_worker must run before reload_worker');
72
99
  this.responsibility = { ...me };
73
100
  await this.host.setName(me.name);
101
+ assertValid();
74
102
  this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
75
103
  }
76
104
  async transmit(to: string, kind: Kind, payload: Record<string, unknown> = {}): Promise<void> {
105
+ const assertValid = this.validity();
77
106
  const { id, config } = await this.state();
107
+ assertValid();
78
108
  const target = named(config, to);
79
109
  await (this.options.send ?? send)(target.port, { version: 1, from: id, to: target.sessionId, kind, payload });
80
110
  }
81
111
  async report(): Promise<void> {
112
+ const assertValid = this.validity();
82
113
  const { config, me } = await this.state();
114
+ assertValid();
83
115
  if (me?.coordinator) fail('report_status is worker-only');
84
116
  if (!this.endpoint) fail('endpoint unavailable');
85
117
  await this.transmit(coordinator(config).name, 'status', { port: this.endpoint.port, busy: this.host.busy() });
86
118
  }
87
119
  async receive(message: Envelope): Promise<void> {
120
+ const assertValid = this.validity();
88
121
  const { id, config, me } = await this.state();
122
+ assertValid();
89
123
  if (message.to !== id) fail('recipient session ID mismatch (stale endpoint)');
90
124
  const sender = config.agents.find(a => a.sessionId === message.from);
91
125
  const busy = this.host.busy();
92
126
  const notify = (purpose: string, content: string) => {
93
- this.id();
127
+ assertValid();
94
128
  this.host.deliver(`[Intercom ${purpose} from ${sender?.name ?? 'unconfigured worker'} (${message.from})]\n${content}`, busy);
95
129
  };
96
130
  if (message.kind === 'registration' || message.kind === 'status') {
@@ -104,7 +138,7 @@ export class Intercom {
104
138
  if (sender) await this.store.update(id, c => {
105
139
  const entry = c.agents.find(a => a.sessionId === message.from && !a.coordinator);
106
140
  if (entry) entry.port = message.payload.port as number;
107
- });
141
+ }, assertValid);
108
142
  notify('status', JSON.stringify({ sessionId: message.from, port: message.payload.port, busy: message.payload.busy, configured: !!sender }));
109
143
  }
110
144
  return;
@@ -120,13 +154,21 @@ export class Intercom {
120
154
  if (message.kind === 'reload') { await this.reload(); return; }
121
155
  if (message.kind === 'request_status') {
122
156
  // Independent one-way report, never a synchronous status in the HTTP response.
123
- setImmediate(() => { if (this.active) void this.report().catch(e => this.host.notify(`Status report failed: ${String(e)}`)); });
157
+ setImmediate(() => {
158
+ try { assertValid(); } catch { return; } // Closed/replaced requests cannot report for a new lifecycle.
159
+ void this.report().catch(e => {
160
+ try { assertValid(); } catch { return; }
161
+ this.host.notify(`Status report failed: ${String(e)}`);
162
+ });
163
+ });
124
164
  return;
125
165
  }
126
166
  fail('unsupported control');
127
167
  }
128
168
  async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
169
+ const assertValid = this.validity();
129
170
  const { id, config, me } = await this.state();
171
+ assertValid();
130
172
  if (operation === 'list') {
131
173
  if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
132
174
  return config;
@@ -139,14 +181,15 @@ export class Intercom {
139
181
  requireCoordinator(config, id);
140
182
  if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
141
183
  if (operation === 'configure_worker') {
142
- await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>);
184
+ await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertValid);
143
185
  return { written: true, reloaded: false };
144
186
  }
145
187
  if (operation === 'set_multiplexer') {
146
- await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }); return { written: true };
188
+ await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }, assertValid); return { written: true };
147
189
  }
148
190
  if (operation === 'create_worker') {
149
191
  const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
192
+ assertValid();
150
193
  return this.options.launch({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
151
194
  }
152
195
  const target = named(config, args.to as string);
@@ -157,11 +200,12 @@ export class Intercom {
157
200
  }
158
201
  if (operation === 'resume_worker') {
159
202
  const relative = await directory(this.store.root, target.projectDirectory);
203
+ assertValid();
160
204
  return this.options.launch({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
161
205
  }
162
206
  if (operation === 'remove_worker') {
163
207
  // No live process claim: the caller must explicitly ensure closure first.
164
- await this.store.update(id, c => { c.agents = c.agents.filter(a => a.sessionId !== target.sessionId); });
208
+ await this.store.update(id, c => { c.agents = c.agents.filter(a => a.sessionId !== target.sessionId); }, assertValid);
165
209
  return { removed: true, processStopped: false, sessionDeleted: false };
166
210
  }
167
211
  fail(`unknown operation ${operation}`);