@comput/pi-intercom 0.1.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 +119 -0
- package/package.json +20 -0
- package/references/implementation-blocker.md +71 -0
- package/references/v1-specification.md +183 -0
- package/src/config.ts +114 -0
- package/src/index.ts +72 -0
- package/src/launcher.ts +61 -0
- package/src/runtime.ts +169 -0
- package/src/transport.ts +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# PiIntercom (V1 supported core)
|
|
2
|
+
|
|
3
|
+
One Windows interactive Pi extension for coordinator and workers. **Intercom communicates; Pi orchestrates.** No automatic assignment, retry, removal, replacement, launcher fallback, commit, or rollback.
|
|
4
|
+
|
|
5
|
+
**`intercom_stop_worker` and `intercom_close_worker` are disabled.** They throw an unsupported-host error before sending control or calling cancellation/shutdown. Incoming stop/close controls also fail. Pi 0.84.4 leaves retry continuation alive after extension abort. See [blocker](references/implementation-blocker.md). Newer versions are not automatically assumed safe; a verified supported lifecycle API and tests are required before enabling these capabilities.
|
|
6
|
+
|
|
7
|
+
## Use
|
|
8
|
+
|
|
9
|
+
Requires Windows, Node 22+, interactive Pi with project trust, and this package's peer dependencies supplied by Pi (`@earendil-works/pi-coding-agent`, `typebox`). Launch the coordinator explicitly from the intended root:
|
|
10
|
+
|
|
11
|
+
```powershell
|
|
12
|
+
cd D:\Source\YourProject
|
|
13
|
+
pi -e D:\Source\PiIntercom\src\index.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Alternatively install from npm:
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
pi install npm:@comput/pi-intercom
|
|
20
|
+
```
|
|
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.
|
|
23
|
+
|
|
24
|
+
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
|
+
|
|
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**.
|
|
27
|
+
|
|
28
|
+
## Explicit workflow
|
|
29
|
+
|
|
30
|
+
1. Coordinator calls `intercom_create_worker({projectDirectory:"."})`. No worker identity, responsibility, callback address or special config path is passed.
|
|
31
|
+
2. Anonymous worker opens its own endpoint and sends its session ID, actual port and root-relative project directory to coordinator Pi. Registration starts a normal coordinator turn when idle or queues steering when busy. It creates **no config entry or hidden pending-registration map**.
|
|
32
|
+
3. Coordinator decides and calls `intercom_configure_worker` with **all five explicit fields**: `sessionId`, `port`, `projectDirectory`, `name`, `description`. This only writes config.
|
|
33
|
+
4. Coordinator separately calls `intercom_reload_worker({to:"Builder"})`. Worker rereads responsibility and synchronizes Pi/Herdr tab names. **This is not Pi `/reload`, a restart, cancellation, or work assignment.** No model turn starts.
|
|
34
|
+
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
|
+
6. Workers report as instructed and wait, without autonomous exit. Coordinator unavailability does not close the worker or cancel its assignment.
|
|
36
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
## Tools and permissions
|
|
40
|
+
|
|
41
|
+
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
|
+
|
|
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 |
|
|
56
|
+
|
|
57
|
+
**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
|
+
|
|
59
|
+
Anonymous `send`/`list` and all coordinator operations fail explicitly. Anonymous registration and status remain available. A dedicated repeat-registration operation and broader anonymous permissions remain **unresolved contract TODOs**, not silently added tools. If initial registration fails, the worker remains reachable and reports the error locally. Explicit user Pi `/reload` restarts this extension and repeats startup registration; status is not a substitute and there is no automatic retry.
|
|
60
|
+
|
|
61
|
+
## Config and wire contracts
|
|
62
|
+
|
|
63
|
+
Shared, source-controlled file: `<root>/.pi-intercom/config.json`. Do not ignore it or split IDs/ports into runtime config. Example **schema only** (not automatically installed placeholder data):
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"version": 1,
|
|
68
|
+
"multiplexer": "herdr",
|
|
69
|
+
"agents": [{
|
|
70
|
+
"sessionId": "actual-pi-session-id",
|
|
71
|
+
"name": "Coordinator",
|
|
72
|
+
"coordinator": true,
|
|
73
|
+
"description": "Coordinate workers, delegate work, and manage shared configuration.",
|
|
74
|
+
"port": 49152,
|
|
75
|
+
"projectDirectory": "."
|
|
76
|
+
}]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
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
|
+
|
|
82
|
+
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).
|
|
83
|
+
|
|
84
|
+
HTTP binds **127.0.0.1 only**, `POST /intercom`, UTF-8 JSON, at most 64 KiB. Envelope:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{"version":1,"kind":"message","from":"sender-session-id","to":"expected-recipient-session-id","payload":{"message":"explicit message"}}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
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
|
+
|
|
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.
|
|
93
|
+
|
|
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).
|
|
95
|
+
|
|
96
|
+
## Development and validation
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npm install --ignore-scripts
|
|
100
|
+
npm run typecheck
|
|
101
|
+
npm test
|
|
102
|
+
npm run check:package
|
|
103
|
+
# Optional installed-host compatibility probe (confirms the known defect):
|
|
104
|
+
PI_INTERCOM_PI_ROOT='C:/path/to/pi-coding-agent' npm run test:host
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Production source runs through Pi's TypeScript loader; `npm run build` generates `dist` for tests. Pi core/typebox are peer dependencies, not bundled. This implementation run installed only cached development dependencies offline with `--legacy-peer-deps`, then used local ignored junctions to installed Pi/typebox for validation. No installed dependency, global setting or unrelated project was changed.
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
### Not live-tested / remaining limitations
|
|
112
|
+
|
|
113
|
+
- Actual Pi extension loading, idle/steering queue delivery, busy snapshots, session-name persistence, trust UI and session replacement/reload lifecycle with live Pi hosts.
|
|
114
|
+
- Herdr tab creation, agent readiness, JSON response shapes, current-pane association and tab rename with live workers. Launcher CLI syntax was checked against installed help only. Herdr's temporary launcher alias is not Intercom/Pi identity and is not saved in config.
|
|
115
|
+
- 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.
|
|
116
|
+
- Actual persisted-session lookup and resumed worker startup; never-used session failure.
|
|
117
|
+
- Cross-process/network-drive atomicity and Windows ACL/sharing failures (same-process temp-filesystem races are tested).
|
|
118
|
+
- Stop/close remain disabled on all hosts until a verified cancellation API covers retries, compaction continuations and queues. No workaround or weakened guarantee.
|
|
119
|
+
- Explicit repeat-registration tool and broad anonymous permissions require a contract decision. Duplicate-session locks, takeover, durable logging and non-Windows support are deferred.
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@comput/pi-intercom",
|
|
3
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Explicit local communication between Pi sessions; Pi orchestrates.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": ["pi-package"],
|
|
8
|
+
"pi": { "extensions": ["./src/index.ts"] },
|
|
9
|
+
"files": ["src", "README.md", "references/v1-specification.md", "references/implementation-blocker.md"],
|
|
10
|
+
"engines": { "node": ">=22" },
|
|
11
|
+
"peerDependencies": { "@earendil-works/pi-coding-agent": "*", "typebox": "*" },
|
|
12
|
+
"devDependencies": { "typescript": "^5.9.3", "@types/node": "^22.0.0" },
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"test": "npm run build && node --test tests/core.test.mjs tests/integration.test.mjs tests/launcher.test.mjs tests/extension.test.mjs",
|
|
17
|
+
"test:host": "node --test tests/pi-cancellation-probe.test.mjs",
|
|
18
|
+
"check:package": "npm pack --dry-run --ignore-scripts"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# V1 cancellation capability blocked: Pi cancellation contract
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Historical outcome
|
|
6
|
+
|
|
7
|
+
Implementation was stopped at the API feasibility gate required by the implementation request. **PiIntercom V1 is not implemented.** No package, extension, launcher, transport, or shared configuration has been created. Existing project settings and installed Pi files were not changed.
|
|
8
|
+
|
|
9
|
+
Installed Pi version inspected: **0.84.4**.
|
|
10
|
+
|
|
11
|
+
## Concrete blocker
|
|
12
|
+
|
|
13
|
+
The V1 stop/close contract requires cancelling current work without automatically restarting queued or pending work. In the installed interactive host, extension `ctx.abort()` clears steering/follow-up/compaction message queues, but calls only the low-level agent's `abort()`. It does **not** cancel the separate session retry timer. During retry backoff there is no active low-level agent run to abort. After backoff, Pi continues the previous assignment with `agent.continue()` even though the extension requested cancellation.
|
|
14
|
+
|
|
15
|
+
`ctx.shutdown()` does not repair this: interactive shutdown waits for session idle, so the automatic continuation can execute before shutdown. Rejecting new HTTP messages during stop/close also cannot cancel this already pending internal continuation.
|
|
16
|
+
|
|
17
|
+
This is narrower than saying Pi cannot clear queues: queue clearing **does work**, and the probe verifies it. The missing cancellation coverage is the blocker.
|
|
18
|
+
|
|
19
|
+
Installed source evidence (paths relative to the installed `pi-coding-agent` package):
|
|
20
|
+
|
|
21
|
+
- `dist/modes/interactive/interactive-mode.js:1451–1456`: extension `abortHandler` calls `restoreQueuedMessagesToEditor({ abort: true })`.
|
|
22
|
+
- Same file, `:3568–3618`: clears queued messages into the editor, then calls `this.agent.abort()`.
|
|
23
|
+
- `node_modules/@earendil-works/pi-agent-core/dist/agent.js:202–204`: abort affects only `activeRun?.abortController`.
|
|
24
|
+
- `dist/core/agent-session.js:2279–2322`: `_prepareRetry()` sleeps using a separate `_retryAbortController`, then returns `true` unless that controller was cancelled.
|
|
25
|
+
- Same file, `:772–795`: post-run loop calls `agent.continue()` following a successful retry wait.
|
|
26
|
+
- `dist/modes/interactive/interactive-mode.js:1507–1512`: shutdown is deferred while the session is busy.
|
|
27
|
+
- Same file, `:2762–2764` and `:2820–2822`: interactive Escape uses separate session cancellation APIs for compaction and retry. Those APIs are not exposed on `ExtensionContext`.
|
|
28
|
+
|
|
29
|
+
Compaction cancellation also needs validation with an upstream fix; this run reproduced the retry problem specifically, not every compaction case.
|
|
30
|
+
|
|
31
|
+
## Decision needed
|
|
32
|
+
|
|
33
|
+
**Recommended:** require a Pi version with a supported extension cancellation operation that cancels active tools/model work, retry backoff, compaction continuations, and queued work as one lifecycle operation. Have that fixed/verified in Pi separately, then resume this implementation. This task does not authorize changing the installed host or an unrelated Pi source project.
|
|
34
|
+
|
|
35
|
+
Alternatively, explicitly revise the V1 stop/close guarantees to permit best-effort cancellation and possible continuation. That is a semantic change and has not been implemented or assumed approved.
|
|
36
|
+
|
|
37
|
+
No private-runtime access, repeated abort polling, synthetic Escape input, settings changes disabling retries/compaction, forced shutdown, or hidden recovery has been introduced as a workaround. Intercom communication must remain independent of launcher choice.
|
|
38
|
+
|
|
39
|
+
## Validation
|
|
40
|
+
|
|
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
|
+
|
|
43
|
+
Git Bash invocation:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
PI_INTERCOM_PI_ROOT='C:/Users/martin/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent' \
|
|
47
|
+
node --test tests/pi-cancellation-probe.test.mjs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Actual result: **3 tests passed, 0 failed**:
|
|
51
|
+
|
|
52
|
+
1. Installed interactive extension abort delegates to the queue-restoration path.
|
|
53
|
+
2. That path clears queued work into the editor and aborts an active low-level run.
|
|
54
|
+
3. During retry backoff, the same path leaves the retry signal un-aborted and `_prepareRetry()` returns `true`, enabling continuation.
|
|
55
|
+
|
|
56
|
+
The third passing test confirms the **undesired installed behavior**, not V1 compliance. This is a source-backed mocked compatibility probe, not a live end-to-end test. Source-layout changes intentionally fail the probe rather than silently testing different code.
|
|
57
|
+
|
|
58
|
+
No typecheck/build/package checks are applicable yet: implementation stopped before creating the package. No live Pi worker, Herdr tab, terminal, service, or messaging bridge was started. Herdr commands were help/discovery only.
|
|
59
|
+
|
|
60
|
+
## Preserved decisions and remaining scope
|
|
61
|
+
|
|
62
|
+
The authoritative `v1-specification.md` remains unchanged. In particular, the historical review's pending-registration-map suggestion is superseded: registration must deliver ID/port/directory to the coordinator agent; configure takes those explicit values and only writes configuration; reload stays separate. Never-used-session resume failure is already allowed by the authoritative spec and is not reopened here.
|
|
63
|
+
|
|
64
|
+
Other implementation gaps (explicit registration-repeat operation and anonymous permissions) remain as listed in the authoritative spec. No additional tool contract or permissions decision was silently made while blocked.
|
|
65
|
+
|
|
66
|
+
`D:/Source/PiIntercom` had no `.git` directory at inspection; `git status` returned “not a git repository”. No repository was initialized and no commit was made.
|
|
67
|
+
|
|
68
|
+
## Changed paths
|
|
69
|
+
|
|
70
|
+
- `tests/pi-cancellation-probe.test.mjs` — new compatibility probe.
|
|
71
|
+
- `references/implementation-blocker.md` — this report.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# PiIntercom V1 Specification
|
|
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.
|
|
4
|
+
|
|
5
|
+
## 1. Scope and principles
|
|
6
|
+
|
|
7
|
+
- One Pi extension codebase runs in coordinator and worker sessions.
|
|
8
|
+
- Pi session ID is the underlying identity; do not introduce instance IDs.
|
|
9
|
+
- Names provide human-friendly addressing. Lookup and uniqueness are case-insensitive; preserve capitalization for display.
|
|
10
|
+
- Intercom is a communication extension, not an orchestration engine. The coordinator Pi agent decides assignments, sequencing, and recovery. The extension provides communication and explicit tool operations; it does not choose the workflow.
|
|
11
|
+
- Coordinator manages configuration and delegates work. Workers may communicate directly with peers.
|
|
12
|
+
- Tools perform explicit documented operations. Failure reports do not trigger hidden recovery, replacement, removal, or launcher fallback.
|
|
13
|
+
- Windows only for V1. Launchers: Herdr (default) and `none` (separate visible terminals).
|
|
14
|
+
- No bulk tools in V1. Use existing tools for individual workers.
|
|
15
|
+
|
|
16
|
+
## 2. Shared configuration
|
|
17
|
+
|
|
18
|
+
Location: `<coordinator-root>/.pi-intercom/config.json`.
|
|
19
|
+
|
|
20
|
+
- Discover by searching upward from the Pi working directory.
|
|
21
|
+
- When genuinely absent, become coordinator and create it under the current working directory, which becomes the coordinator root. Do not infer a Git or solution root.
|
|
22
|
+
- Initial creation is atomic: only one session targeting the same path becomes coordinator. Other contenders wait for usable config and register as workers.
|
|
23
|
+
- Invalid or unreadable config is an error, not absence. Leave it untouched.
|
|
24
|
+
- The configuration is checked into source control. It includes session IDs and ports; do not split these into a separate runtime configuration.
|
|
25
|
+
- Only coordinator extension code may write it. Workers read only. No attempt to restrict ordinary Pi shell/file tools.
|
|
26
|
+
- Worker directories must be the coordinator root or descendants, stored relative to that root.
|
|
27
|
+
- Entries survive process closure. Saved Pi session paths are not stored; resume uses session ID and project directory.
|
|
28
|
+
|
|
29
|
+
Required configuration information:
|
|
30
|
+
|
|
31
|
+
| Scope | Fields |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Solution | Multiplexer (`herdr` or `none`) |
|
|
34
|
+
| Agent | Pi session ID, name, coordinator flag, responsibility description, port |
|
|
35
|
+
| Worker | Project directory relative to coordinator root |
|
|
36
|
+
|
|
37
|
+
Exact schema and versioning remain implementation gaps. Do not treat a saved endpoint or session ID as proof that its process exists.
|
|
38
|
+
|
|
39
|
+
## 3. Startup and registration
|
|
40
|
+
|
|
41
|
+
### Coordinator
|
|
42
|
+
|
|
43
|
+
1. Resolve current session ID and configuration.
|
|
44
|
+
2. With no config, atomically initialize coordinator configuration.
|
|
45
|
+
3. Use name `Coordinator` and default responsibility: `Coordinate workers, delegate work, and manage shared configuration.`
|
|
46
|
+
4. Bind configured port; if unavailable or absent, bind port 0 and use the OS-selected port.
|
|
47
|
+
5. Record its actual port directly in config.
|
|
48
|
+
6. Fix Pi session name and, when hosted in Herdr, tab name to `Coordinator`.
|
|
49
|
+
7. Publish readiness without triggering an agent turn. Wait for user input.
|
|
50
|
+
|
|
51
|
+
A known session restores its configured role. Any unknown session ID with existing configuration follows worker registration, including forks. No implicit coordinator takeover.
|
|
52
|
+
|
|
53
|
+
### Anonymous worker
|
|
54
|
+
|
|
55
|
+
1. Coordinator launches a new Pi session in its project directory with this extension loaded.
|
|
56
|
+
2. Worker discovers shared config and starts a localhost endpoint using an OS-selected port.
|
|
57
|
+
3. Worker reports session ID, port, and project directory to the configured coordinator.
|
|
58
|
+
4. Coordinator extension notifies its agent using normal prompt delivery if idle, steering if busy.
|
|
59
|
+
5. Worker remains anonymous and waits. Registration does not create a placeholder config entry or authorize work.
|
|
60
|
+
6. Coordinator agent calls `intercom_configure_worker` to create a named entry with responsibility and reported connection details.
|
|
61
|
+
7. Coordinator agent separately calls `intercom_reload_worker`.
|
|
62
|
+
8. Worker reads its responsibility, synchronizes Pi session and Herdr tab names, and waits for a work prompt.
|
|
63
|
+
|
|
64
|
+
No worker-specific config path, callback address, instance ID, or preassigned responsibility is passed at launch. No sequential-launch restriction is required for normal creation.
|
|
65
|
+
|
|
66
|
+
### Existing worker startup/resume
|
|
67
|
+
|
|
68
|
+
- Read its entry by Pi session ID and restore responsibility.
|
|
69
|
+
- Try configured port; use OS allocation if unavailable.
|
|
70
|
+
- Report actual endpoint to coordinator. Existing-entry port updates are automatic bookkeeping.
|
|
71
|
+
- Fix Pi session and applicable Herdr tab names to configured name.
|
|
72
|
+
- Wait for new instructions, even if previous work was unfinished.
|
|
73
|
+
- Coordinator connection failure is reported but does not prevent the worker's own endpoint from opening.
|
|
74
|
+
|
|
75
|
+
## 4. Responsibilities and work
|
|
76
|
+
|
|
77
|
+
- Responsibility describes an agent's remit, not its current task.
|
|
78
|
+
- Loading or reloading responsibility does not start a turn or authorize implementation.
|
|
79
|
+
- Work is assigned by explicit agent messages.
|
|
80
|
+
- Findings may be used within an existing assignment, but do not independently authorize unrelated implementation.
|
|
81
|
+
- After work, report as instructed and wait.
|
|
82
|
+
- Workers continue assigned work and peer communication while coordinator is unreachable.
|
|
83
|
+
- Workers do not autonomously exit upon completion or coordinator shutdown.
|
|
84
|
+
|
|
85
|
+
## 5. Transport and delivery
|
|
86
|
+
|
|
87
|
+
- Localhost HTTP, one receiving message endpoint per extension.
|
|
88
|
+
- Bind loopback only. No authentication in V1; local processes are trusted.
|
|
89
|
+
- All application-level messages are asynchronous and one-way. A message may lead to zero, one, or many later messages.
|
|
90
|
+
- HTTP acknowledgment confirms receipt/acceptance or reports transport/validation failure, not agent completion.
|
|
91
|
+
- Reports/results travel as independent messages, never as synchronous agent replies in HTTP responses.
|
|
92
|
+
- Every send rereads config to resolve the current destination endpoint.
|
|
93
|
+
- Agent-facing recipient arguments use names. Transport includes the expected destination session ID, verified by the receiver.
|
|
94
|
+
- Include sender identity and label agent messages with sender and purpose.
|
|
95
|
+
- Incoming coordinator-only controls check sender session ID against current config. This is role validation, not authentication.
|
|
96
|
+
- 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.
|
|
98
|
+
|
|
99
|
+
### Agent delivery
|
|
100
|
+
|
|
101
|
+
- Idle recipient: start normal agent turn.
|
|
102
|
+
- Busy recipient: use Pi steering at supported boundaries, not a hard interrupt of executing tools.
|
|
103
|
+
- Determine delivery behavior at arrival; busy is only a snapshot.
|
|
104
|
+
- Progress questions do not cancel existing assignments. Report and continue unless explicitly redirected.
|
|
105
|
+
- Findings use the same delivery path, not a separate context-only inbox.
|
|
106
|
+
|
|
107
|
+
### Control delivery
|
|
108
|
+
|
|
109
|
+
- Registration, status reporting, config reload, stop, and close run extension code.
|
|
110
|
+
- Worker's status report contains session ID, bound port, and busy flag; generation needs no worker model turn.
|
|
111
|
+
- One shared worker reporting function is invoked by either its report tool or a coordinator request. Reject use on a coordinator host.
|
|
112
|
+
- Coordinator receiving status updates an existing entry's changed port, then notifies its agent (normal/steering).
|
|
113
|
+
- Unknown/removed session status notifies but does not create config entries. Registration is separate.
|
|
114
|
+
- Routine monitoring should use extension status. Detailed progress uses an agent prompt; checking status first is optional.
|
|
115
|
+
|
|
116
|
+
## 6. Tools
|
|
117
|
+
|
|
118
|
+
Every invocation reads the current Pi session ID and shared config, determines role, and checks permission. Unknown sessions never receive coordinator-only privileges.
|
|
119
|
+
|
|
120
|
+
| Tool | Role | Inputs | Operation |
|
|
121
|
+
|---|---|---|---|
|
|
122
|
+
| `intercom_create_worker` | Coordinator | `projectDirectory?` (default `.`) | Launch new anonymous worker with extension loaded; report launch success/failure without awaiting registration |
|
|
123
|
+
| `intercom_configure_worker` | Coordinator | `sessionId`, `port`, `projectDirectory`, `name`, `description` | Write the explicitly supplied worker configuration; no notification |
|
|
124
|
+
| `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_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 |
|
|
131
|
+
| `intercom_resume_worker` | Coordinator | `to` (name) | Launch saved session using configured directory and launcher; failures leave config unchanged |
|
|
132
|
+
| `intercom_remove_worker` | Coordinator | `to` (name) | Remove config entry only; no process stop or session-file deletion |
|
|
133
|
+
| `intercom_set_multiplexer` | Coordinator | `multiplexer` | Set `herdr` or `none` for future launches, including resume |
|
|
134
|
+
|
|
135
|
+
### Tool details
|
|
136
|
+
|
|
137
|
+
- Configure rejects duplicate names, including `Coordinator`, case-insensitively. Keeping a session's own name is allowed.
|
|
138
|
+
- 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
|
+
- 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.
|
|
141
|
+
- Close success at transport level is not proof that process exit has completed.
|
|
142
|
+
- Remove a running worker only after explicitly closing it; removal itself never closes it.
|
|
143
|
+
- Resume failure must be surfaced; no implicit removal, replacement, or reassignment.
|
|
144
|
+
- Pi may not persist a never-used session until its first assistant response. This is an implementation caveat covered by the existing resume-failure rule, not a change to registration or a separate design decision. Do not force a model turn or fabricate history to persist it.
|
|
145
|
+
- Existing workers are not moved or restarted when launcher configuration changes.
|
|
146
|
+
|
|
147
|
+
## 7. Launchers
|
|
148
|
+
|
|
149
|
+
- Default Herdr: one new tab per worker, no splits.
|
|
150
|
+
- `none`: separate visible terminal per worker, not background-only execution.
|
|
151
|
+
- Missing configured multiplexer means a clear launch failure, not fallback.
|
|
152
|
+
- Keep communication independent of launcher choice.
|
|
153
|
+
- Keep launcher interface extensible for future multiplexers/platforms.
|
|
154
|
+
- Session names and tabs track configured agent names; coordinator uses `Coordinator`.
|
|
155
|
+
|
|
156
|
+
## 8. Explicitly deferred
|
|
157
|
+
|
|
158
|
+
- Other operating systems and multiplexers.
|
|
159
|
+
- Durable message logging, including storage/retention policy.
|
|
160
|
+
- Duplicate Pi-session activation detection or session-ID locks.
|
|
161
|
+
- Automatic retry queues and recovery orchestration are not V1 behavior.
|
|
162
|
+
- Bulk tools: use individual operations instead.
|
|
163
|
+
|
|
164
|
+
## 9. Remaining implementation gaps — not new approved behavior
|
|
165
|
+
|
|
166
|
+
Consolidate these before coding; avoid reopening settled user flows.
|
|
167
|
+
|
|
168
|
+
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
|
+
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.
|
|
171
|
+
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
|
+
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.
|
|
174
|
+
7. **Missing coordinator session:** workers report connection failure and remain reachable. Explicit coordinator takeover is not specified or approved; do not invent it.
|
|
175
|
+
8. **Unconfigured worker permissions:** explicitly define allowed worker-side tools before its entry exists, while retaining registration/reporting ability and forbidding coordinator operations.
|
|
176
|
+
9. **Protocol control checks:** distinguish initial registration from configured-sender validation. Reload must reach the anonymous worker after the coordinator has created its entry.
|
|
177
|
+
|
|
178
|
+
## 10. Implementation documentation and verification
|
|
179
|
+
|
|
180
|
+
- Read installed Pi documentation and relevant Herdr skills before implementation.
|
|
181
|
+
- Document tool permissions, startup/registration flow, passive responsibility loading, separate configure/reload calls, and failure semantics.
|
|
182
|
+
- 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.
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, rename, unlink, open, link, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export interface Agent {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
coordinator: boolean;
|
|
9
|
+
description: string;
|
|
10
|
+
port: number;
|
|
11
|
+
projectDirectory: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Config { version: 1; multiplexer: 'herdr' | 'none'; agents: Agent[] }
|
|
14
|
+
export const DEFAULT_DESCRIPTION = 'Coordinate workers, delegate work, and manage shared configuration.';
|
|
15
|
+
export const key = (name: string) => name.toLowerCase();
|
|
16
|
+
export function fail(message: string): never { throw new Error(`PiIntercom: ${message}`); }
|
|
17
|
+
export function text(value: unknown, field: string, max = 4096): asserts value is string {
|
|
18
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || value.includes('\0')) fail(`invalid ${field}`);
|
|
19
|
+
}
|
|
20
|
+
export function port(value: unknown): asserts value is number {
|
|
21
|
+
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535) fail('invalid port');
|
|
22
|
+
}
|
|
23
|
+
export function relativeDirectory(value: unknown): asserts value is string {
|
|
24
|
+
text(value, 'projectDirectory');
|
|
25
|
+
if (path.isAbsolute(value) || path.win32.isAbsolute(value) || value.split(/[\\/]/).includes('..')) fail('projectDirectory must be root-relative, without ..');
|
|
26
|
+
}
|
|
27
|
+
export function validateConfig(value: unknown): Config {
|
|
28
|
+
const c = value as Config;
|
|
29
|
+
if (!c || c.version !== 1 || !['herdr', 'none'].includes(c.multiplexer) || !Array.isArray(c.agents)) fail('invalid config schema/version');
|
|
30
|
+
const names = new Set<string>(), ids = new Set<string>();
|
|
31
|
+
for (const a of c.agents) {
|
|
32
|
+
if (!a || typeof a.coordinator !== 'boolean') fail('invalid agent');
|
|
33
|
+
text(a.sessionId, 'sessionId', 256); text(a.name, 'name', 128); text(a.description, 'description');
|
|
34
|
+
if (a.name !== a.name.trim() || /[\r\n\x00-\x1f]/.test(a.name)) fail('invalid name');
|
|
35
|
+
port(a.port); relativeDirectory(a.projectDirectory);
|
|
36
|
+
if (names.has(key(a.name)) || ids.has(a.sessionId)) fail('duplicate name or session ID');
|
|
37
|
+
names.add(key(a.name)); ids.add(a.sessionId);
|
|
38
|
+
if (a.coordinator ? (a.name !== 'Coordinator' || a.projectDirectory !== '.') : key(a.name) === 'coordinator') fail('reserved Coordinator identity');
|
|
39
|
+
}
|
|
40
|
+
if (c.agents.filter(a => a.coordinator).length !== 1) fail('config requires exactly one coordinator');
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
export const coordinator = (c: Config) => c.agents.find(a => a.coordinator)!;
|
|
44
|
+
export function named(c: Config, name: string): Agent {
|
|
45
|
+
text(name, 'to', 128);
|
|
46
|
+
return c.agents.find(a => key(a.name) === key(name)) ?? fail(`unknown recipient ${name}`);
|
|
47
|
+
}
|
|
48
|
+
export function requireCoordinator(c: Config, id: string): Agent {
|
|
49
|
+
const a = c.agents.find(a => a.sessionId === id);
|
|
50
|
+
return a?.coordinator ? a : fail('coordinator permission required');
|
|
51
|
+
}
|
|
52
|
+
export async function directory(root: string, requested: string): Promise<string> {
|
|
53
|
+
text(requested, 'projectDirectory');
|
|
54
|
+
const base = await realpath(root), target = await realpath(path.resolve(root, requested));
|
|
55
|
+
const rel = path.relative(base, target);
|
|
56
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) fail('project directory must be coordinator root or descendant (including symlinks)');
|
|
57
|
+
if (!(await stat(target)).isDirectory()) fail('projectDirectory is not a directory');
|
|
58
|
+
return rel.split(path.sep).join('/') || '.';
|
|
59
|
+
}
|
|
60
|
+
export class ConfigStore {
|
|
61
|
+
readonly file: string;
|
|
62
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
63
|
+
constructor(readonly root: string) { this.file = path.join(root, '.pi-intercom', 'config.json'); }
|
|
64
|
+
async read(): Promise<Config> {
|
|
65
|
+
// Missing, malformed and unreadable files propagate; never overwrite them.
|
|
66
|
+
return validateConfig(JSON.parse(await readFile(this.file, 'utf8')));
|
|
67
|
+
}
|
|
68
|
+
static async discover(cwd: string): Promise<ConfigStore | undefined> {
|
|
69
|
+
let at = path.resolve(cwd);
|
|
70
|
+
for (;;) {
|
|
71
|
+
const store = new ConfigStore(at);
|
|
72
|
+
try { await store.read(); return store; }
|
|
73
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
74
|
+
const parent = path.dirname(at); if (parent === at) return undefined; at = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
private async temporary(config: Config): Promise<string> {
|
|
78
|
+
validateConfig(config);
|
|
79
|
+
await mkdir(path.dirname(this.file), { recursive: true });
|
|
80
|
+
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;
|
|
85
|
+
}
|
|
86
|
+
async initialize(id: string, boundPort: number): Promise<boolean> {
|
|
87
|
+
// Publish an already complete file using an atomic, no-replace hard link.
|
|
88
|
+
// Losers only ever see complete JSON; unsupported filesystems fail explicitly.
|
|
89
|
+
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); }
|
|
93
|
+
}
|
|
94
|
+
async update(id: string, mutate: (config: Config) => void | Promise<void>): Promise<Config> {
|
|
95
|
+
const operation = this.tail.then(async () => {
|
|
96
|
+
const c = await this.read(); requireCoordinator(c, id);
|
|
97
|
+
await mutate(c); validateConfig(c);
|
|
98
|
+
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; }); }
|
|
101
|
+
return c;
|
|
102
|
+
});
|
|
103
|
+
this.tail = operation.catch(() => {}); return operation;
|
|
104
|
+
}
|
|
105
|
+
async configure(id: string, values: Omit<Agent, 'coordinator'>): Promise<Config> {
|
|
106
|
+
return this.update(id, async c => {
|
|
107
|
+
if (values.sessionId === coordinator(c).sessionId) fail('cannot configure coordinator as worker');
|
|
108
|
+
const projectDirectory = await directory(this.root, values.projectDirectory);
|
|
109
|
+
const a: Agent = { sessionId: values.sessionId, name: values.name, description: values.description, port: values.port, projectDirectory, coordinator: false };
|
|
110
|
+
const index = c.agents.findIndex(old => old.sessionId === a.sessionId);
|
|
111
|
+
if (index < 0) c.agents.push(a); else c.agents[index] = a;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { Type, type TSchema } from 'typebox';
|
|
3
|
+
import { SessionManager, truncateHead, withFileMutationQueue, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
import { ConfigStore } from './config.js';
|
|
5
|
+
import { Intercom, UNSUPPORTED_CANCELLATION } from './runtime.js';
|
|
6
|
+
import { launchers } from './launcher.js';
|
|
7
|
+
|
|
8
|
+
const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
|
|
9
|
+
const tools: [string, string, TSchema][] = [
|
|
10
|
+
['create_worker', 'Coordinator only. Launch one anonymous worker in projectDirectory (default root). No assignment, configuration, or registration wait.', Type.Object({ projectDirectory: Type.Optional(Type.String()) })],
|
|
11
|
+
['configure_worker', 'Coordinator only. Write explicit reported connection details and responsibility to config. Does not notify, reload, or start work. Use reload_worker separately.', Type.Object({ sessionId: Type.String(), port: Type.Integer({ minimum: 1, maximum: 65535 }), projectDirectory: Type.String(), name: Type.String({ minLength: 1, maxLength: 128 }), description: Type.String({ minLength: 1, maxLength: 4096 }) })],
|
|
12
|
+
['reload_worker', 'Coordinator only. Ask worker to reread responsibility and synchronize names. NOT Pi extension reload, cancellation, restart, or work assignment.', to],
|
|
13
|
+
['send', 'Configured sessions only. Send an explicit work/progress/findings message by recipient name. Idle delivery starts a turn; busy delivery steers at supported boundaries. Receipt is not an agent reply. Progress questions do not cancel assignments.', Type.Object({ to: Type.String(), message: Type.String({ minLength: 1, maxLength: 48000 }) })],
|
|
14
|
+
['list', 'Configured sessions only. Read current saved names, responsibilities, session IDs, roles and ports. No live probing. Output limited to 50 KiB/2000 lines; full data remains in config.json.', Type.Object({})],
|
|
15
|
+
['request_status', 'Coordinator only. Ask worker extension to send an independent sessionId/port/busy report without a model turn on worker. No synchronous status reply.', to],
|
|
16
|
+
['report_status', 'Worker only (including anonymous). Send runtime sessionId/port/busy report to coordinator. This is NOT registration and creates no config entry.', Type.Object({})],
|
|
17
|
+
['stop_worker', UNSUPPORTED_CANCELLATION, to],
|
|
18
|
+
['close_worker', UNSUPPORTED_CANCELLATION, to],
|
|
19
|
+
['resume_worker', 'Coordinator only. Launch saved session by ID in configured directory. No replacement/removal on failure; no old assignment is automatically started.', to],
|
|
20
|
+
['remove_worker', 'Coordinator only. Remove config entry only; never stop process or delete session files. A running worker must be explicitly closed before removal. Stop/close are disabled on current host: arrange explicit user closure first.', to],
|
|
21
|
+
['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
|
+
|
|
24
|
+
// Participate in Pi's own per-file mutation queue as well as Intercom serialization.
|
|
25
|
+
class PiConfigStore extends ConfigStore {
|
|
26
|
+
override update(id: string, mutate: Parameters<ConfigStore['update']>[1]) {
|
|
27
|
+
return withFileMutationQueue(this.file, () => super.update(id, mutate));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export default function intercomExtension(pi: ExtensionAPI): void {
|
|
31
|
+
let runtime: Intercom | undefined;
|
|
32
|
+
let context: ExtensionContext | undefined;
|
|
33
|
+
const launcher = launchers({
|
|
34
|
+
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),
|
|
36
|
+
});
|
|
37
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
38
|
+
await runtime?.close(); context = ctx;
|
|
39
|
+
if (process.platform !== 'win32' || ctx.mode !== 'tui') throw new Error('PiIntercom V1 requires Windows interactive Pi. No resources started.');
|
|
40
|
+
if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
|
|
41
|
+
runtime = new Intercom({
|
|
42
|
+
cwd: ctx.cwd,
|
|
43
|
+
sessionId: () => context!.sessionManager.getSessionId(),
|
|
44
|
+
busy: () => !context!.isIdle(),
|
|
45
|
+
deliver: (content, busy) => pi.sendUserMessage(content, busy ? { deliverAs: 'steer' } : undefined),
|
|
46
|
+
setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
|
|
47
|
+
notify: text => ctx.ui.notify(text, 'info'),
|
|
48
|
+
}, { launch: launcher.launch, store: root => new PiConfigStore(root) });
|
|
49
|
+
try { await runtime.start(); }
|
|
50
|
+
catch (e) { runtime = undefined; ctx.ui.notify(String(e), 'error'); }
|
|
51
|
+
});
|
|
52
|
+
pi.on('session_shutdown', async () => { await runtime?.close(); runtime = undefined; context = undefined; });
|
|
53
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
54
|
+
context = ctx;
|
|
55
|
+
if (!runtime) return;
|
|
56
|
+
const { me } = await runtime.state();
|
|
57
|
+
const loaded = runtime.responsibility;
|
|
58
|
+
return { systemPrompt: event.systemPrompt + '\n\nPiIntercom: communicates; Pi decides orchestration. Responsibility is not a work assignment. Do not implement unrelated work from findings. After assigned work, report as instructed and wait; do not autonomously exit. Progress questions require reporting and continuing unless explicitly redirected. stop_worker/close_worker are disabled due to host cancellation blocker.\n' + (me && loaded ? `Identity: ${loaded.name} (${me.sessionId}). Responsibility: ${loaded.description}.` : 'Anonymous/unloaded worker: wait for coordinator configuration and separate reload; do not treat registration as authorization to work.') };
|
|
59
|
+
});
|
|
60
|
+
for (const [operation, description, parameters] of tools) {
|
|
61
|
+
pi.registerTool({
|
|
62
|
+
name: `intercom_${operation}`, label: `Intercom ${operation}`, description, parameters,
|
|
63
|
+
async execute(_id, args, signal, _update, ctx) {
|
|
64
|
+
signal?.throwIfAborted(); context = ctx;
|
|
65
|
+
if (!runtime) throw new Error('PiIntercom not initialized; inspect startup error.');
|
|
66
|
+
const result = await runtime.tool(operation, args as Record<string, unknown>);
|
|
67
|
+
const output = truncateHead(JSON.stringify(result, null, 2));
|
|
68
|
+
return { content: [{ type: 'text', text: output.content + (output.truncated ? `\n[Truncated; full shared configuration: ${runtime.store.file}]` : '') }], details: {} };
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/launcher.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { fail } from './config.js';
|
|
5
|
+
import type { LaunchRequest } from './runtime.js';
|
|
6
|
+
|
|
7
|
+
export type Run = (file: string, args: string[]) => Promise<string>;
|
|
8
|
+
export const run: Run = async (file, args) => {
|
|
9
|
+
const { stdout } = await promisify(execFile)(file, args, { windowsHide: true, timeout: 40000, maxBuffer: 1024 * 1024 });
|
|
10
|
+
return stdout;
|
|
11
|
+
};
|
|
12
|
+
export const psQuote = (value: string) => `'${value.replaceAll("'", "''")}'`;
|
|
13
|
+
export const encoded = (script: string) => Buffer.from(script, 'utf16le').toString('base64');
|
|
14
|
+
export interface LauncherOptions {
|
|
15
|
+
extension: string;
|
|
16
|
+
platform?: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
run?: Run;
|
|
19
|
+
sessionExists(cwd: string, id: string): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
export function launchers(options: LauncherOptions) {
|
|
22
|
+
const exec = options.run ?? run, env = options.env ?? process.env;
|
|
23
|
+
async function launch(request: LaunchRequest): Promise<unknown> {
|
|
24
|
+
if ((options.platform ?? process.platform) !== 'win32') fail('V1 launchers support Windows only');
|
|
25
|
+
if (request.sessionId && !await options.sessionExists(request.cwd, request.sessionId)) fail('saved Pi session not found in project directory; never-used sessions may not be persisted. Config unchanged; no replacement launched');
|
|
26
|
+
const args = ['-e', options.extension, ...(request.sessionId ? ['--session', request.sessionId] : [])];
|
|
27
|
+
if (request.multiplexer === 'herdr') {
|
|
28
|
+
if (env.HERDR_ENV !== '1' || !env.HERDR_WORKSPACE_ID) fail('Herdr launcher requires this Pi to run inside a Herdr-managed workspace; no fallback');
|
|
29
|
+
let created: { result?: { root_pane?: { pane_id?: string }; tab?: { tab_id?: string } } };
|
|
30
|
+
try { created = JSON.parse(await exec('herdr', ['tab', 'create', '--workspace', env.HERDR_WORKSPACE_ID, '--cwd', request.cwd, '--label', 'Intercom (anonymous)', '--no-focus'])); }
|
|
31
|
+
catch (e) { fail(`Herdr tab launch failed: ${String(e)}; no fallback or cleanup`); }
|
|
32
|
+
const pane = created.result?.root_pane?.pane_id, tab = created.result?.tab?.tab_id;
|
|
33
|
+
if (!pane || !tab) fail('Herdr creation response missing returned pane/tab IDs; no guessed targeting or cleanup');
|
|
34
|
+
// Herdr requires a unique lowercase launcher alias. This is not Pi identity,
|
|
35
|
+
// is never stored in Intercom config/wire, and assigns no responsibility.
|
|
36
|
+
const alias = `intercom-${randomBytes(6).toString('hex')}`;
|
|
37
|
+
try { await exec('herdr', ['agent', 'start', alias, '--kind', 'pi', '--pane', pane, '--timeout', '30000', '--', ...args]); }
|
|
38
|
+
catch (e) { fail(`Herdr agent launch failed in tab ${tab}, pane ${pane}: ${String(e)}. Tab/process may remain; no automatic cleanup/retry/replacement`); }
|
|
39
|
+
return { launched: true, tab, pane, registrationAwaited: false };
|
|
40
|
+
}
|
|
41
|
+
if (request.multiplexer !== 'none') fail('unsupported multiplexer');
|
|
42
|
+
// Use encoded PowerShell rather than constructing cmd.exe command strings.
|
|
43
|
+
// The outer process reports only successful visible terminal creation, not Pi readiness.
|
|
44
|
+
const child = `$ErrorActionPreference='Stop'; Get-ChildItem Env: | Where-Object { $_.Name -like 'HERDR_*' -or $_.Name -like 'PI_SESSION_*' } | ForEach-Object { Remove-Item ('Env:' + $_.Name) }; Set-Location -LiteralPath ${psQuote(request.cwd)}; & (Get-Command pi.cmd -ErrorAction Stop).Source ${args.map(psQuote).join(' ')}; if ($LASTEXITCODE -ne 0) { Write-Error ('Pi exited with code ' + $LASTEXITCODE) }`;
|
|
45
|
+
const outer = `$ErrorActionPreference='Stop'; $null=Get-Command pi.cmd -ErrorAction Stop; $p=Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') -WorkingDirectory ${psQuote(request.cwd)} -ArgumentList @('-NoProfile','-NoExit','-EncodedCommand',${psQuote(encoded(child))}) -WindowStyle Normal -PassThru; $p.Id`;
|
|
46
|
+
const result = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded(outer)]);
|
|
47
|
+
const pid = Number(result.trim());
|
|
48
|
+
if (!Number.isInteger(pid) || pid <= 0) fail('visible terminal launch returned no process ID; outcome unknown');
|
|
49
|
+
return { terminalLaunched: true, pid, piReadiness: 'not observed; inspect visible terminal for startup failures', registrationAwaited: false };
|
|
50
|
+
}
|
|
51
|
+
async function syncName(name: string): Promise<void> {
|
|
52
|
+
if (env.HERDR_ENV !== '1') return;
|
|
53
|
+
// Resolve inherited pane context, including panes moved since launch.
|
|
54
|
+
if (!env.HERDR_PANE_ID) fail('Herdr tab name sync requires caller pane identity');
|
|
55
|
+
const data = JSON.parse(await exec('herdr', ['pane', 'current', '--current']));
|
|
56
|
+
const tab = data.result?.pane?.tab_id;
|
|
57
|
+
if (typeof tab !== 'string') fail('Herdr current-pane response missing tab_id; tab name not synchronized');
|
|
58
|
+
await exec('herdr', ['tab', 'rename', tab, name]);
|
|
59
|
+
}
|
|
60
|
+
return { launch, syncName };
|
|
61
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ConfigStore, coordinator, directory, fail, named, requireCoordinator, type Agent, type Config } from './config.js';
|
|
3
|
+
import { listen, send, type Endpoint, type Envelope, type Kind } from './transport.js';
|
|
4
|
+
|
|
5
|
+
export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and close_worker are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. See references/implementation-blocker.md. A verified supported host API is required (no version-only override).';
|
|
6
|
+
export interface Host {
|
|
7
|
+
sessionId(): string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
busy(): boolean;
|
|
10
|
+
deliver(text: string, busy: boolean): void;
|
|
11
|
+
setName(name: string): Promise<void>;
|
|
12
|
+
notify(text: string): void;
|
|
13
|
+
}
|
|
14
|
+
export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; sessionId?: string }
|
|
15
|
+
export interface RuntimeOptions {
|
|
16
|
+
launch(request: LaunchRequest): Promise<unknown>;
|
|
17
|
+
listen?: typeof listen;
|
|
18
|
+
send?: typeof send;
|
|
19
|
+
store?: (root: string) => ConfigStore;
|
|
20
|
+
}
|
|
21
|
+
export class Intercom {
|
|
22
|
+
store!: ConfigStore;
|
|
23
|
+
endpoint?: Endpoint;
|
|
24
|
+
private active = false;
|
|
25
|
+
private initialId = '';
|
|
26
|
+
responsibility?: Agent;
|
|
27
|
+
constructor(readonly host: Host, readonly options: RuntimeOptions) {}
|
|
28
|
+
private id(): string {
|
|
29
|
+
const id = this.host.sessionId();
|
|
30
|
+
if (!this.active || id !== this.initialId) fail('session is inactive or replaced');
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
async start(): Promise<void> {
|
|
34
|
+
this.initialId = this.host.sessionId(); this.active = true;
|
|
35
|
+
try {
|
|
36
|
+
const discovered = await ConfigStore.discover(this.host.cwd);
|
|
37
|
+
this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
|
|
38
|
+
const previous = discovered ? await this.store.read() : undefined;
|
|
39
|
+
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);
|
|
42
|
+
const config = await this.store.read();
|
|
43
|
+
const me = config.agents.find(a => a.sessionId === this.id());
|
|
44
|
+
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)}`); }
|
|
47
|
+
this.host.notify(`Intercom Coordinator ready at 127.0.0.1:${this.endpoint.port}; waiting for user input.`);
|
|
48
|
+
} else {
|
|
49
|
+
if (me) { try { await this.reload(); } catch (e) { this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); } }
|
|
50
|
+
try {
|
|
51
|
+
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.`); }
|
|
54
|
+
this.host.notify(`Intercom worker ready at 127.0.0.1:${this.endpoint.port}; waiting for explicit work.`);
|
|
55
|
+
}
|
|
56
|
+
} catch (e) { await this.close(); throw e; }
|
|
57
|
+
}
|
|
58
|
+
async close(): Promise<void> {
|
|
59
|
+
this.active = false;
|
|
60
|
+
const endpoint = this.endpoint; this.endpoint = undefined;
|
|
61
|
+
if (endpoint) await endpoint.close();
|
|
62
|
+
this.responsibility = undefined;
|
|
63
|
+
}
|
|
64
|
+
async state(): Promise<{ id: string; config: Config; me?: Agent }> {
|
|
65
|
+
const id = this.id(), config = await this.store.read();
|
|
66
|
+
this.id();
|
|
67
|
+
return { id, config, me: config.agents.find(a => a.sessionId === id) };
|
|
68
|
+
}
|
|
69
|
+
async reload(): Promise<void> {
|
|
70
|
+
const { me } = await this.state();
|
|
71
|
+
if (!me) fail('worker not configured; configure_worker must run before reload_worker');
|
|
72
|
+
this.responsibility = { ...me };
|
|
73
|
+
await this.host.setName(me.name);
|
|
74
|
+
this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
|
|
75
|
+
}
|
|
76
|
+
async transmit(to: string, kind: Kind, payload: Record<string, unknown> = {}): Promise<void> {
|
|
77
|
+
const { id, config } = await this.state();
|
|
78
|
+
const target = named(config, to);
|
|
79
|
+
await (this.options.send ?? send)(target.port, { version: 1, from: id, to: target.sessionId, kind, payload });
|
|
80
|
+
}
|
|
81
|
+
async report(): Promise<void> {
|
|
82
|
+
const { config, me } = await this.state();
|
|
83
|
+
if (me?.coordinator) fail('report_status is worker-only');
|
|
84
|
+
if (!this.endpoint) fail('endpoint unavailable');
|
|
85
|
+
await this.transmit(coordinator(config).name, 'status', { port: this.endpoint.port, busy: this.host.busy() });
|
|
86
|
+
}
|
|
87
|
+
async receive(message: Envelope): Promise<void> {
|
|
88
|
+
const { id, config, me } = await this.state();
|
|
89
|
+
if (message.to !== id) fail('recipient session ID mismatch (stale endpoint)');
|
|
90
|
+
const sender = config.agents.find(a => a.sessionId === message.from);
|
|
91
|
+
const busy = this.host.busy();
|
|
92
|
+
const notify = (purpose: string, content: string) => {
|
|
93
|
+
this.id();
|
|
94
|
+
this.host.deliver(`[Intercom ${purpose} from ${sender?.name ?? 'unconfigured worker'} (${message.from})]\n${content}`, busy);
|
|
95
|
+
};
|
|
96
|
+
if (message.kind === 'registration' || message.kind === 'status') {
|
|
97
|
+
requireCoordinator(config, id);
|
|
98
|
+
if (sender?.coordinator) fail('coordinator cannot report as worker');
|
|
99
|
+
if (message.kind === 'registration') {
|
|
100
|
+
const projectDirectory = await directory(this.store.root, message.payload.projectDirectory as string);
|
|
101
|
+
// No pending map, placeholder or configuration mutation.
|
|
102
|
+
notify('registration', JSON.stringify({ sessionId: message.from, port: message.payload.port, projectDirectory }) + '\nThis is connection information, not a work assignment. Configure explicitly, then reload separately.');
|
|
103
|
+
} else {
|
|
104
|
+
if (sender) await this.store.update(id, c => {
|
|
105
|
+
const entry = c.agents.find(a => a.sessionId === message.from && !a.coordinator);
|
|
106
|
+
if (entry) entry.port = message.payload.port as number;
|
|
107
|
+
});
|
|
108
|
+
notify('status', JSON.stringify({ sessionId: message.from, port: message.payload.port, busy: message.payload.busy, configured: !!sender }));
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (message.kind === 'message') {
|
|
113
|
+
if (!sender || !me) fail('agent messaging requires configured sender and recipient; anonymous permissions TODO');
|
|
114
|
+
if (!this.responsibility) fail('worker must load responsibility before receiving work');
|
|
115
|
+
notify('agent message', message.payload.message as string); return;
|
|
116
|
+
}
|
|
117
|
+
if (!sender?.coordinator) fail('control requires current coordinator sender session ID');
|
|
118
|
+
if (me?.coordinator) fail('worker control cannot target coordinator');
|
|
119
|
+
if (message.kind === 'stop' || message.kind === 'close') fail(UNSUPPORTED_CANCELLATION);
|
|
120
|
+
if (message.kind === 'reload') { await this.reload(); return; }
|
|
121
|
+
if (message.kind === 'request_status') {
|
|
122
|
+
// 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)}`)); });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
fail('unsupported control');
|
|
127
|
+
}
|
|
128
|
+
async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
|
|
129
|
+
const { id, config, me } = await this.state();
|
|
130
|
+
if (operation === 'list') {
|
|
131
|
+
if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
|
|
132
|
+
return config;
|
|
133
|
+
}
|
|
134
|
+
if (operation === 'report_status') { await this.report(); return { accepted: true }; }
|
|
135
|
+
if (operation === 'send') {
|
|
136
|
+
if (!me || !this.responsibility) fail('anonymous/unloaded worker cannot send agent messages (permissions TODO)');
|
|
137
|
+
await this.transmit(args.to as string, 'message', { message: args.message }); return { accepted: true, completion: 'not awaited' };
|
|
138
|
+
}
|
|
139
|
+
requireCoordinator(config, id);
|
|
140
|
+
if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
|
|
141
|
+
if (operation === 'configure_worker') {
|
|
142
|
+
await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>);
|
|
143
|
+
return { written: true, reloaded: false };
|
|
144
|
+
}
|
|
145
|
+
if (operation === 'set_multiplexer') {
|
|
146
|
+
await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }); return { written: true };
|
|
147
|
+
}
|
|
148
|
+
if (operation === 'create_worker') {
|
|
149
|
+
const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
|
|
150
|
+
return this.options.launch({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
|
|
151
|
+
}
|
|
152
|
+
const target = named(config, args.to as string);
|
|
153
|
+
if (target.coordinator) fail('operation requires a worker target');
|
|
154
|
+
if (operation === 'reload_worker' || operation === 'request_status') {
|
|
155
|
+
await this.transmit(target.name, operation === 'reload_worker' ? 'reload' : 'request_status');
|
|
156
|
+
return { accepted: true, completion: 'not awaited' };
|
|
157
|
+
}
|
|
158
|
+
if (operation === 'resume_worker') {
|
|
159
|
+
const relative = await directory(this.store.root, target.projectDirectory);
|
|
160
|
+
return this.options.launch({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
|
|
161
|
+
}
|
|
162
|
+
if (operation === 'remove_worker') {
|
|
163
|
+
// 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); });
|
|
165
|
+
return { removed: true, processStopped: false, sessionDeleted: false };
|
|
166
|
+
}
|
|
167
|
+
fail(`unknown operation ${operation}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { fail, port, text } from './config.js';
|
|
3
|
+
|
|
4
|
+
export const BODY_LIMIT = 64 * 1024;
|
|
5
|
+
export const RECEIPT_TIMEOUT = 5000;
|
|
6
|
+
export type Kind = 'message' | 'registration' | 'status' | 'request_status' | 'reload' | 'stop' | 'close';
|
|
7
|
+
export interface Envelope {
|
|
8
|
+
version: 1;
|
|
9
|
+
kind: Kind;
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
payload: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export function envelope(value: unknown): Envelope {
|
|
15
|
+
const m = value as Envelope;
|
|
16
|
+
if (!m || m.version !== 1 || !['message', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'].includes(m.kind)) fail('invalid wire schema/version/kind');
|
|
17
|
+
text(m.from, 'sender sessionId', 256); text(m.to, 'recipient sessionId', 256);
|
|
18
|
+
if (!m.payload || Array.isArray(m.payload) || typeof m.payload !== 'object') fail('invalid payload');
|
|
19
|
+
if (m.kind === 'message') text(m.payload.message, 'message', 48000);
|
|
20
|
+
if (m.kind === 'registration' || m.kind === 'status') port(m.payload.port);
|
|
21
|
+
if (m.kind === 'registration') text(m.payload.projectDirectory, 'projectDirectory');
|
|
22
|
+
if (m.kind === 'status' && typeof m.payload.busy !== 'boolean') fail('invalid busy flag');
|
|
23
|
+
return m;
|
|
24
|
+
}
|
|
25
|
+
export interface Endpoint { port: number; close(): Promise<void> }
|
|
26
|
+
export async function listen(preferred: number | undefined, accept: (message: Envelope) => Promise<void>): Promise<Endpoint> {
|
|
27
|
+
const server = http.createServer(async (req, res) => {
|
|
28
|
+
const reply = (status: number, data: unknown) => { if (!res.destroyed) { res.writeHead(status, { 'content-type': 'application/json', connection: 'close' }); res.end(JSON.stringify(data)); } };
|
|
29
|
+
if (req.method !== 'POST' || req.url !== '/intercom') { reply(404, { error: 'POST /intercom required' }); req.resume(); return; }
|
|
30
|
+
let bytes = 0;
|
|
31
|
+
const chunks: Buffer[] = [];
|
|
32
|
+
try {
|
|
33
|
+
for await (const chunk of req) {
|
|
34
|
+
bytes += chunk.length;
|
|
35
|
+
if (bytes > BODY_LIMIT) { reply(413, { error: 'body exceeds 64 KiB' }); return; }
|
|
36
|
+
chunks.push(Buffer.from(chunk));
|
|
37
|
+
}
|
|
38
|
+
await accept(envelope(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
|
|
39
|
+
reply(202, { accepted: true });
|
|
40
|
+
} catch (error) { reply(400, { error: String(error) }); }
|
|
41
|
+
});
|
|
42
|
+
server.requestTimeout = RECEIPT_TIMEOUT;
|
|
43
|
+
server.headersTimeout = RECEIPT_TIMEOUT;
|
|
44
|
+
server.setTimeout(RECEIPT_TIMEOUT, socket => socket.destroy());
|
|
45
|
+
const bind = (p: number) => new Promise<void>((resolve, reject) => {
|
|
46
|
+
const error = (e: Error) => { server.off('listening', ready); reject(e); };
|
|
47
|
+
const ready = () => { server.off('error', error); resolve(); };
|
|
48
|
+
server.once('error', error); server.once('listening', ready); server.listen(p, '127.0.0.1');
|
|
49
|
+
});
|
|
50
|
+
try { await bind(preferred ?? 0); }
|
|
51
|
+
catch (e) { if (preferred && (e as NodeJS.ErrnoException).code === 'EADDRINUSE') await bind(0); else throw e; }
|
|
52
|
+
const address = server.address();
|
|
53
|
+
if (!address || typeof address === 'string') fail('listener has no port');
|
|
54
|
+
return { port: address.port, close: () => new Promise((resolve, reject) => {
|
|
55
|
+
server.close(e => e ? reject(e) : resolve()); server.closeAllConnections();
|
|
56
|
+
}) };
|
|
57
|
+
}
|
|
58
|
+
export async function send(destinationPort: number, message: Envelope, timeout = RECEIPT_TIMEOUT): Promise<void> {
|
|
59
|
+
port(destinationPort); envelope(message);
|
|
60
|
+
const body = JSON.stringify(message);
|
|
61
|
+
if (Buffer.byteLength(body) > BODY_LIMIT) fail('message exceeds 64 KiB');
|
|
62
|
+
// Node HTTP bypasses proxy environment variables; never redirects or retries.
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
const req = http.request({ hostname: '127.0.0.1', port: destinationPort, path: '/intercom', method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }, res => {
|
|
65
|
+
let result = '';
|
|
66
|
+
res.on('data', chunk => { result += chunk; if (result.length > BODY_LIMIT) req.destroy(new Error('oversized acknowledgment')); });
|
|
67
|
+
res.on('error', reject);
|
|
68
|
+
res.on('end', () => {
|
|
69
|
+
try {
|
|
70
|
+
if (res.statusCode !== 202 || JSON.parse(result).accepted !== true) fail(`receipt rejected (${res.statusCode}): ${result}`);
|
|
71
|
+
resolve();
|
|
72
|
+
} catch (e) { reject(e); }
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
const timer = setTimeout(() => req.destroy(new Error('PiIntercom: receipt timeout; outcome unknown; no automatic retry')), timeout);
|
|
76
|
+
req.on('close', () => clearTimeout(timer)); req.on('error', reject); req.end(body);
|
|
77
|
+
});
|
|
78
|
+
}
|