@comput/pi-intercom 0.6.1 → 0.9.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.
Files changed (47) hide show
  1. package/README.md +113 -8
  2. package/dist/config.d.ts +12 -0
  3. package/dist/config.js +94 -22
  4. package/dist/config.js.map +1 -1
  5. package/dist/core-monitor.d.ts +30 -0
  6. package/dist/core-monitor.js +528 -0
  7. package/dist/core-monitor.js.map +1 -0
  8. package/dist/core-pane-close.d.ts +34 -0
  9. package/dist/core-pane-close.js +335 -0
  10. package/dist/core-pane-close.js.map +1 -0
  11. package/dist/core-worker-layout.d.ts +50 -0
  12. package/dist/core-worker-layout.js +236 -0
  13. package/dist/core-worker-layout.js.map +1 -0
  14. package/dist/herdr-core.d.ts +53 -0
  15. package/dist/herdr-core.js +380 -0
  16. package/dist/herdr-core.js.map +1 -0
  17. package/dist/index.js +60 -14
  18. package/dist/index.js.map +1 -1
  19. package/dist/launcher.d.ts +2 -0
  20. package/dist/launcher.js +59 -3
  21. package/dist/launcher.js.map +1 -1
  22. package/dist/monitor-launcher.d.ts +8 -0
  23. package/dist/monitor-launcher.js +111 -25
  24. package/dist/monitor-launcher.js.map +1 -1
  25. package/dist/pane-close.js +3 -0
  26. package/dist/pane-close.js.map +1 -1
  27. package/dist/reminders.d.ts +28 -0
  28. package/dist/reminders.js +81 -0
  29. package/dist/reminders.js.map +1 -0
  30. package/dist/runtime.d.ts +28 -2
  31. package/dist/runtime.js +139 -11
  32. package/dist/runtime.js.map +1 -1
  33. package/package.json +14 -6
  34. package/references/herdr-core-migration.md +44 -0
  35. package/references/releasing.md +90 -0
  36. package/references/v1-specification.md +11 -8
  37. package/src/config.ts +69 -17
  38. package/src/core-monitor.ts +410 -0
  39. package/src/core-pane-close.ts +328 -0
  40. package/src/core-worker-layout.ts +240 -0
  41. package/src/herdr-core.ts +287 -0
  42. package/src/index.ts +55 -15
  43. package/src/launcher.ts +55 -3
  44. package/src/monitor-launcher.ts +95 -21
  45. package/src/pane-close.ts +2 -0
  46. package/src/reminders.ts +69 -0
  47. package/src/runtime.ts +122 -12
package/package.json CHANGED
@@ -6,13 +6,13 @@
6
6
  },
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/comput-sh/pi-intercom.git"
9
+ "url": "git+https://github.com/mbundgaard/pi-intercom.git"
10
10
  },
11
- "homepage": "https://github.com/comput-sh/pi-intercom#readme",
11
+ "homepage": "https://github.com/mbundgaard/pi-intercom#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/comput-sh/pi-intercom/issues"
13
+ "url": "https://github.com/mbundgaard/pi-intercom/issues"
14
14
  },
15
- "version": "0.6.1",
15
+ "version": "0.9.0",
16
16
  "description": "A coordinator-led workspace for Pi. Delegate to workers with separate contexts, evaluate their reports, and keep the main conversation focused—with the team visible alongside it.",
17
17
  "type": "module",
18
18
  "keywords": [
@@ -28,6 +28,8 @@
28
28
  "dist",
29
29
  "README.md",
30
30
  "references/observability.md",
31
+ "references/releasing.md",
32
+ "references/herdr-core-migration.md",
31
33
  "references/roadmap.md",
32
34
  "references/v1-specification.md",
33
35
  "references/implementation-blocker.md"
@@ -40,7 +42,7 @@
40
42
  "typebox": "*"
41
43
  },
42
44
  "devDependencies": {
43
- "@earendil-works/pi-coding-agent": "0.84.4",
45
+ "@earendil-works/pi-coding-agent": "1.4.0",
44
46
  "@types/node": "^22.0.0",
45
47
  "typebox": "1.3.7",
46
48
  "typescript": "^5.9.3"
@@ -49,12 +51,18 @@
49
51
  "build": "node -e \"require('node:fs').rmSync('dist', {recursive:true, force:true})\" && tsc",
50
52
  "prepack": "npm run build",
51
53
  "typecheck": "tsc --noEmit",
52
- "test": "npm run build && node --test tests/core.test.mjs tests/integration.test.mjs tests/launcher.test.mjs tests/linux-launcher.test.mjs tests/activity.test.mjs tests/reports.test.mjs tests/worker-status.test.mjs tests/connections.test.mjs tests/handoff.test.mjs tests/pane-close.test.mjs tests/version.test.mjs tests/monitor.test.mjs tests/monitor-view.test.mjs tests/monitor-launcher.test.mjs tests/extension.test.mjs tests/runtime-http.test.mjs tests/resume-storage.test.mjs tests/observability.test.mjs tests/snapshot.test.mjs tests/snapshot-security.test.mjs tests/observability-integration.test.mjs",
54
+ "test": "npm run build && node --test tests/release.test.mjs tests/release-workflow.test.mjs tests/published-release.test.mjs tests/core.test.mjs tests/root-isolation.test.mjs tests/integration.test.mjs tests/launcher.test.mjs tests/linux-launcher.test.mjs tests/core-launcher.test.mjs tests/core-worker-layout.test.mjs tests/herdr-core.test.mjs tests/activity.test.mjs tests/reports.test.mjs tests/worker-status.test.mjs tests/connections.test.mjs tests/reminders.test.mjs tests/handoff.test.mjs tests/pane-close.test.mjs tests/core-pane-close.test.mjs tests/version.test.mjs tests/monitor.test.mjs tests/monitor-view.test.mjs tests/monitor-launcher.test.mjs tests/core-monitor.test.mjs tests/monitor-settings.test.mjs tests/extension.test.mjs tests/runtime-http.test.mjs tests/resume-storage.test.mjs tests/observability.test.mjs tests/snapshot.test.mjs tests/snapshot-security.test.mjs tests/observability-integration.test.mjs",
53
55
  "test:host": "node --test tests/pi-cancellation-probe.test.mjs",
54
56
  "test:package": "node --test tests/packaged-monitor.test.mjs",
55
57
  "check:package": "npm run build && npm pack --dry-run --ignore-scripts && npm run test:package"
56
58
  },
57
59
  "dependencies": {
58
60
  "pi-intercom-tui": "npm:@earendil-works/pi-tui@0.84.4"
61
+ },
62
+ "overrides": {
63
+ "@earendil-works/pi-coding-agent@1.4.0": {
64
+ "undici": "8.12.2",
65
+ "minimatch": "11.0.1"
66
+ }
59
67
  }
60
68
  }
@@ -0,0 +1,44 @@
1
+ # Herdr core/app compatibility and validation
2
+
3
+ ## Target topology and tested contracts
4
+
5
+ - Core: `hc` / `herdr-core` **0.1.18**, running on Linux or Windows.
6
+ - UI: Windows `herdr-app`, local or connected to a remote core.
7
+ - Examined app source: public tag **v0.1.13**, commit `93b097f6e29fcb2d90a30dea2f03ce4e5dee7981`.
8
+ - `HC_TERMINAL_ID` identifies a core terminal, not an app pane or tab. `herdr.space` and `herdr.tab` labels are placement IDs, not names.
9
+
10
+ ## Shared document adapter
11
+
12
+ The app's `src/mirror_sync.rs` reads/writes its machine-owned `herdr-app` document through core `doc.get`/`doc.set`, using an expected version. `src/mirror.rs` separates shared spaces/tabs/layout from screen-private focus. `src/window.rs` applies document updates and qualifies remote IDs.
13
+
14
+ Intercom uses this route to change the **actual tab name**, not merely a session title or label. Naming changes only the uniquely mapped worker tab. Launch preflight, terminal creation and dedicated-tab attachment share one bounded verified connection. Conflicts, disconnects and uncertain mutations are not replayed. The coordinator's tab name is preserved.
15
+
16
+ This is a **source-backed private-format adapter**, not an upstream promise of a stable versioned UI API. Core documents are opaque JSON; core's version does not establish the connected app's version. Rows/columns and terminal/mod blocks are supported; the naming helper also accepts mod-only floats. Monitor layout intentionally requires a narrower float-free subset. Stacks, terminal floats, dynamic content and ambiguous identities fail closed.
17
+
18
+ Unix uses an explicit `HC_SOCKET`, or the documented default endpoint only if no core config file could override it. This connects without spawning a core. An existing config requires explicit `HC_SOCKET`; its contents are not scraped for credentials. Windows can use an explicit endpoint, otherwise existing-core status is checked before `hc connect`; that preflight is not atomic protection against the core disappearing before connect.
19
+
20
+ ## Monitor and worker closure
21
+
22
+ Monitor ownership is persisted separately from project configuration, with core/session/caller identities, a token, original argv and terminal creation timestamp. Open supports a top-quarter panel or separate tab without changing private focus state. Old legacy records cannot grant new-core ownership.
23
+
24
+ Monitor close:
25
+ 1. Validate the owned terminal, pane, caller and complete descendant inventory.
26
+ 2. Refuse any observed live descendant, including one behind a finished intermediate parent.
27
+ 3. Persist `close_pending`, then recheck identity/layout and inventory.
28
+ 4. Submit one terminal stop and verify finished state.
29
+ 5. CAS-remove only the owned pane, preserving siblings and the coordinator layout.
30
+ 6. Verify absence and persist `closed`. Only confirmed closure permits a new open.
31
+
32
+ This is best-effort, not an atomic compare-and-stop primitive. Core completion may terminate `die_with_parent` children; clients must not concurrently register children against an owned monitor/closing worker. No all-descendants or atomic quiescence guarantee is made. Any uncertainty retains the fence; no automatic retry, cleanup or replacement is permitted.
33
+
34
+ Linux worker closure additionally checks canonical session headers and process start ticks, and retains the existing successful handoff-result persistence, final settlement and same-epoch commit protocol. Windows worker closure remains disabled because equivalent process-identity proof is not implemented. No graceful cancellation support was enabled.
35
+
36
+ ## Validation boundaries
37
+
38
+ Local validation covers transport framing/errors, core identity/version checks, CAS conflicts, no-replay behavior, missing/ambiguous layout refusal, quoting/root isolation, ownership, descendant refusal, handoff commit ordering, process-exit observation, monitor close/reopen and sibling preservation. Tests use mocked API connections and disposable files; no real workers are needed.
39
+
40
+ Adapter fixtures strip inherited `HC_*` and `HERDR_*` variables. A prior development test inherited the coordinator terminal and changed its tab to a fixture name; this was an isolation bug. The owner restored the title to **Main**. The test isolation fix prevents those mocked sessions from accessing the live connection again.
41
+
42
+ A read-only check verified that the real core's document maps this coordinator to its actual Windows-app tab. That does not establish complete native Windows execution or rendered UI behavior. End-to-end worker startup/reload and Windows rendering still require an authorized live acceptance test. No workers are to be created merely to satisfy this checklist without owner authorization.
43
+
44
+ No package release, installation or session reload is implied by a successful build.
@@ -0,0 +1,90 @@
1
+ # Tag-only npm releases
2
+
3
+ ## Authorization and scope
4
+
5
+ Obtain explicit authorization before committing, pushing, tagging or publishing. Preparing files or validating locally is not permission to release. Publishing does not install the extension or reload any Pi session; those require separate authorization.
6
+
7
+ This procedure applies to **new tags containing the tag-only workflow**. Historical tags retain their original workflow files. Never move an existing tag or rerun a legacy publisher to adopt this flow. Choose a new, unused version when the owner authorizes a release.
8
+
9
+ ## Automatic workflow
10
+
11
+ `.github/workflows/publish.yml` responds only to pushed `v*` tags. There are no GitHub Release or manual-dispatch triggers. Branch commits cannot publish; ordinary main/PR CI remains separate.
12
+
13
+ Both jobs check out the triggering `github.sha`, not latest main. The workflow requires an annotated tag and checks that its peeled commit, the event SHA's peeled commit and checked-out HEAD match. `scripts/verify-release.mjs` requires canonical stable `vX.Y.Z` with safe-integer components: no leading zeros, prereleases, build metadata or suffixes. The tag must match all three fields exactly:
14
+
15
+ - `package.json.version`
16
+ - `package-lock.json.version`
17
+ - `package-lock.json.packages[""].version`
18
+
19
+ Package names must also be `@comput/pi-intercom`. Missing or malformed fields fail closed, before dependency installation.
20
+
21
+ Windows and Linux run `npm ci`, typecheck, tests, **full-tree `npm audit --audit-level=low`**, and isolated package-content/install checks. Ordinary main/PR CI has the same audit gate so known findings can be caught before creating an immutable release tag. Any reported vulnerability blocks publication; do not omit development dependencies or lower the threshold to get a release through. Dependency remediation is separate reviewed work.
22
+
23
+ After successful validation, the Ubuntu publisher performs another clean install, audit and package check/build from the same commit. Tracked source modifications are rejected. It packs once into a runner-temporary directory, checks the pack metadata and SHA-512/SHA-1, retains that archive as a workflow artifact, then publishes **that exact archive**, with lifecycle scripts disabled and transport retries disabled for the publish command.
24
+
25
+ The workflow grants `contents: read`; only the publishing job also has `id-token: write`. It uses GitHub-hosted runners, Node 24 and npm 11 with Trusted Publishing and provenance. No stored npm token, token secret, token-bearing npmrc or token-based setup-node registry configuration is used.
26
+
27
+ ## npm Trusted Publisher
28
+
29
+ Configure/check this on the **`@comput/pi-intercom` package**, not just the npm account:
30
+
31
+ - Provider: GitHub Actions
32
+ - Organization/user: `mbundgaard`
33
+ - Repository: `pi-intercom`
34
+ - Workflow filename: `publish.yml` (not the full path)
35
+ - Environment: empty; this workflow does not declare one
36
+ - Allowed actions: direct `npm publish` must be permitted, not only staged publishing
37
+
38
+ The existing mapping may already match. Do not replace credentials, add a token fallback or change permissions without authorization. Provenance signing alone does not prove npm publishing authorization. Package repository metadata must match `https://github.com/mbundgaard/pi-intercom`.
39
+
40
+ ## Release procedure
41
+
42
+ The commands below are examples for a shell with Git/Node/npm. Substitute the reviewed version and exact commit. Do not execute the mutation steps without explicit owner authorization.
43
+
44
+ 1. Review the working tree, including new files, and the proposed release scope. Exclude `.pi-intercom/`, local credentials and machine state. Resolve security-audit blockers first.
45
+ 2. Choose a stable unused version. Successfully query npm's public version list and inspect previous release workflow outcomes; a network/authentication error is **not** evidence that a version is unused. Check both local tags and remote refs. If any earlier publication outcome is uncertain, stop and inspect it first; registry visibility may lag.
46
+ 3. Update `package.json` and both lockfile version fields together, for example `npm version X.Y.Z --no-git-tag-version`. Update the changelog in README's release-history section and relevant compatibility/upgrade notes. This does not create a tag.
47
+ 4. Run local validation:
48
+
49
+ ```sh
50
+ RELEASE_TAG=vX.Y.Z node scripts/verify-release.mjs
51
+ npm ci
52
+ npm run typecheck
53
+ npm test
54
+ npm audit --audit-level=low
55
+ npm run check:package
56
+ git diff --check
57
+ ```
58
+
59
+ On PowerShell, set `$env:RELEASE_TAG = 'vX.Y.Z'` before invoking the validator. Use the actual version, not literal placeholders.
60
+ 5. Confirm the proposed npm version and Git tag remain unused immediately before release. Example read-only checks:
61
+
62
+ ```sh
63
+ npm view @comput/pi-intercom versions --json --prefer-online
64
+ git tag --list vX.Y.Z
65
+ git ls-remote --tags origin refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}'
66
+ ```
67
+
68
+ Require successful network queries and inspect their output; do not interpret command failures as absence. Do not reuse an existing local or remote tag.
69
+ 6. With explicit authorization, stage only reviewed files, commit and push that release commit to main. Confirm main CI passes for **that exact SHA**, not an earlier green run. Do not publish yet if any required check fails.
70
+ 7. Create an annotated tag pointing to the reviewed release commit, then push only that tag:
71
+
72
+ ```sh
73
+ git tag -a vX.Y.Z <release-commit> -m "Release vX.Y.Z"
74
+ git push origin refs/tags/vX.Y.Z
75
+ ```
76
+
77
+ Never use a blanket `git push --tags`, move a release tag, or tag an unreviewed newer HEAD. The tag push itself starts publication. **Do not manually trigger a second run.** Complete one release before starting another; GitHub concurrency is not an unlimited release queue.
78
+ 8. Inspect the tag-triggered workflow, npm version metadata, downloaded artifact and provenance linkage as described below. A GitHub Release page can be added afterward for release notes; it is not the publishing trigger.
79
+
80
+ ## Verification and uncertainty
81
+
82
+ `scripts/verify-published.mjs` performs read-only verification after npm accepts the publish command. It compares the public archive byte-for-byte and by SHA-512 with the retained local archive built from the exact tag checkout, verifies registry integrity metadata (and SHA-1 when supplied), and checks decoded SLSA provenance linkage: archive subject digest, repository, source commit, workflow path, tag ref, and GitHub run/attempt URL.
83
+
84
+ The script accepts `--archive`, `--version`, `--commit`, `--repo`, `--workflow`, `--run-id` and `--run-attempt` for later verification against the retained artifact. The workflow supplies these values from its verified checkout and GitHub event. It does not run npm publish or any mutation command. Downloads are restricted to the expected HTTPS npm registry endpoints, without redirects or credentials, and have size/time limits.
85
+
86
+ Read-only polling for transient errors/visibility lag is bounded to 15 minutes and at most 31 attempts. A mismatch fails immediately. A timeout or missing verification evidence is **not proof that publication failed**. Preserve the workflow artifact and inspect the publish step, registry version and provenance before deciding what to do. Do not rerun a publishing job merely because its later verification step failed. Never blindly retry publication, move the tag or reuse a published version.
87
+
88
+ This is **artifact equality and decoded provenance-linkage verification**, not independent cryptographic verification of the DSSE signature, certificate chain or transparency-log inclusion. Do not describe it as such. The retained CI-built archive is the reference artifact; reproducing an archive locally with different npm/toolchain versions may yield different gzip bytes and is not an equivalent reference.
89
+
90
+ Report success only after workflow publication and the required artifact/linkage checks succeed. State any remaining verification limitation plainly. Installation and `/reload` remain separate actions.
@@ -2,7 +2,7 @@
2
2
 
3
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
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.
5
+ **Historical design versus current capability:** The original V1 targets below are not a current support matrix. `intercom_stop_worker` and graceful cancellation remain disabled; see the [cancellation blocker](implementation-blocker.md). `intercom_close_worker` now uses a separate Linux-owned-pane handoff/settlement/close workflow, not graceful shutdown. New-core compatibility and its non-atomic closure limits are documented in [Herdr migration](herdr-core-migration.md); current supported behavior is in README. 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.
6
6
 
7
7
  ## 1. Scope and principles
8
8
 
@@ -19,20 +19,20 @@ This is the consolidated **target design**, authoritative over conflicting wordi
19
19
 
20
20
  Location: `<coordinator-root>/.pi-intercom/config.json`.
21
21
 
22
- - Discover by searching upward from the Pi working directory.
23
- - 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
+ - Root-isolation revision (0.8.0): select only the config in the session working directory. Never search ancestors or infer a Git/home root. `--intercom-root` is a same-directory assertion: its canonical path must equal cwd before its config is read. It cannot authorize reading a parent config.
23
+ - Without an explicit workspace root, when the exact local config is genuinely absent, become coordinator and create it under the current working directory. Explicit workspace mode requires an existing valid config and never creates a replacement coordinator. Do not infer a Git or solution root.
24
24
  - Initial creation is atomic: only one session targeting the same path becomes coordinator. Other contenders wait for usable config and register as workers.
25
25
  - Invalid or unreadable config is an error, not absence. Leave it untouched.
26
26
  - The configuration is checked into source control. It includes session IDs and ports; do not split these into a separate runtime configuration.
27
27
  - Only coordinator extension code may write it. Workers read only. No attempt to restrict ordinary Pi shell/file tools.
28
- - Worker directories must be the coordinator root or descendants, stored relative to that root.
28
+ - Saved worker directories remain root-relative. Shared-roster worker create/resume requires the coordinator root itself; descendant launches are rejected before side effects. Old descendant entries/history are retained, not silently relocated.
29
29
  - Entries survive process closure. Saved Pi session paths are not stored; resume uses session ID and project directory.
30
30
 
31
31
  Required configuration information:
32
32
 
33
33
  | Scope | Fields |
34
34
  |---|---|
35
- | Solution | Multiplexer (`herdr` or `none`) |
35
+ | Solution | Multiplexer (`herdr` or `none`); optional monitor preferences `{autoOpen:boolean, placement:'coordinator-tab'|'new-tab'}` (defaults true/coordinator-tab) |
36
36
  | Agent | Pi session ID, name, coordinator flag, responsibility description, port |
37
37
  | Worker | Project directory relative to coordinator root |
38
38
 
@@ -55,7 +55,7 @@ A known session restores its configured role. Any unknown session ID with existi
55
55
  ### Anonymous worker
56
56
 
57
57
  1. Coordinator launches a new Pi session in its project directory with this extension loaded.
58
- 2. Worker discovers shared config and starts a localhost endpoint using an OS-selected port.
58
+ 2. Worker verifies its explicit root equals its working directory, validates only that directory's config, then starts a localhost endpoint using an OS-selected port.
59
59
  3. Worker reports session ID, port, and project directory to the configured coordinator.
60
60
  4. Coordinator extension notifies its agent using normal prompt delivery if idle, steering if busy.
61
61
  5. Worker remains anonymous and waits. Registration does not create a placeholder config entry or authorize work.
@@ -129,17 +129,20 @@ Every invocation reads the current Pi session ID and shared config, determines r
129
129
  | `intercom_request_status` | Coordinator | `to` (name) | Ask worker extension to send independent runtime status report |
130
130
  | `intercom_report_status` | Worker, including anonymous | None | Invoke shared reporting function; reject on coordinator |
131
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 |
132
+ | `intercom_close_worker` | Coordinator | `to` (name) | Start a Linux Herdr handoff/settlement/owned-pane close job; preserve registration/history. Not graceful cancellation; unsupported elsewhere |
133
133
  | `intercom_resume_worker` | Coordinator | `to` (name) | Launch saved session using configured directory and launcher; failures leave config unchanged |
134
134
  | `intercom_remove_worker` | Coordinator | `to` (name) | Remove config entry only; no process stop or session-file deletion |
135
135
  | `intercom_set_multiplexer` | Coordinator | `multiplexer` | Set `herdr` or `none` for future launches, including resume |
136
+ | `intercom_open_monitor` | Coordinator | Required `placement` | Open/preserve the monitor in Herdr; ask the user if placement is unknown, never infer from saved settings; explicit opening allows an empty roster |
137
+ | `intercom_close_monitor` | Coordinator | None | Close only the verified owned monitor pane, never an entire tab or a worker |
138
+ | `intercom_configure_monitor` | Coordinator | `autoOpen?`, `placement?` | Persist at least one preference; no immediate pane effects |
136
139
 
137
140
  ### Tool details
138
141
 
139
142
  - Configure rejects duplicate names, including `Coordinator`, case-insensitively. Keeping a session's own name is allowed.
140
143
  - 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.
141
144
  - Reload synchronizes Pi session and applicable Herdr tab names to config.
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.**
145
+ - Original target contract: stop/close use Pi cancellation and graceful shutdown. This target remains blocked. The implemented close tool is a separate explicit owned-pane termination workflow, without automatic source commits/rollback or an all-descendants guarantee; it does not enable stop/cancellation.
143
146
  - Close success at transport level is not proof that process exit has completed.
144
147
  - Remove a running worker only after explicitly closing it; removal itself never closes it.
145
148
  - Resume failure must be surfaced; no implicit removal, replacement, or reassignment.
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, realpath, rename, unlink, open, link, stat } from 'node:fs/promises';
1
+ import { mkdir, readFile, realpath, rename, unlink, open, link, stat, lstat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { fencedClose, validateHandoff, validateCloseJob, type Handoff, type CloseJob } from './handoff.js';
@@ -15,7 +15,15 @@ export interface Agent {
15
15
  handoff?: Handoff;
16
16
  closeJob?: CloseJob;
17
17
  }
18
- export interface Config { version: 1; multiplexer: 'herdr' | 'none'; agents: Agent[] }
18
+ export interface MonitorSettings { autoOpen: boolean; placement: 'coordinator-tab' | 'new-tab' }
19
+ export interface Config { version: 1; multiplexer: 'herdr' | 'none'; agents: Agent[]; monitor?: MonitorSettings }
20
+ export function monitorSettings(config: Config): MonitorSettings {
21
+ return config.monitor ? { ...config.monitor } : { autoOpen: true, placement: 'coordinator-tab' };
22
+ }
23
+ export function validateMonitorSettings(value: unknown): asserts value is MonitorSettings {
24
+ const settings = value as MonitorSettings;
25
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings) || typeof settings.autoOpen !== 'boolean' || !['coordinator-tab', 'new-tab'].includes(settings.placement) || Object.keys(settings).some(key => !['autoOpen', 'placement'].includes(key))) fail('invalid monitor settings');
26
+ }
19
27
  export const DEFAULT_DESCRIPTION = 'Coordinate workers, delegate work, and manage shared configuration.';
20
28
  export const key = (name: string) => name.toLowerCase();
21
29
  export function fail(message: string): never { throw new Error(`PiIntercom: ${message}`); }
@@ -32,6 +40,7 @@ export function relativeDirectory(value: unknown): asserts value is string {
32
40
  export function validateConfig(value: unknown): Config {
33
41
  const c = value as Config;
34
42
  if (!c || c.version !== 1 || !['herdr', 'none'].includes(c.multiplexer) || !Array.isArray(c.agents)) fail('invalid config schema/version');
43
+ if (c.monitor !== undefined) validateMonitorSettings(c.monitor);
35
44
  const names = new Set<string>(), ids = new Set<string>();
36
45
  for (const a of c.agents) {
37
46
  if (!a || typeof a.coordinator !== 'boolean') fail('invalid agent');
@@ -80,24 +89,67 @@ const RENAME_BUDGET_MS = 250;
80
89
  export class ConfigStore {
81
90
  readonly file: string;
82
91
  private tail: Promise<unknown> = Promise.resolve();
92
+ private canonicalRoot?: string;
83
93
  constructor(readonly root: string, private readonly options: ConfigStoreOptions = {}) { this.file = path.join(root, '.pi-intercom', 'config.json'); }
94
+ private async assertLocalPaths(): Promise<void> {
95
+ const root = await realpath(this.root);
96
+ if (this.canonicalRoot === undefined) this.canonicalRoot = root;
97
+ else if (root !== this.canonicalRoot) fail('config root canonical location changed');
98
+ // Reject existing escaping links/junctions before following them for any
99
+ // config access. These path checks are best effort, not TOCTOU protection.
100
+ for (const [location, directory] of [[path.dirname(this.file), true], [this.file, false]] as const) {
101
+ try { await lstat(location); }
102
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; }
103
+ let resolved: string;
104
+ try { resolved = await realpath(location); }
105
+ catch { fail('local config path cannot be resolved safely'); }
106
+ const relative = path.relative(root, resolved);
107
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail('local config path escapes session root');
108
+ const metadata = await stat(resolved);
109
+ if (directory ? !metadata.isDirectory() : !metadata.isFile()) fail('invalid local config path type');
110
+ }
111
+ }
112
+ async validateRoot(expectedCanonicalRoot: string): Promise<void> {
113
+ await this.assertLocalPaths();
114
+ if (this.canonicalRoot !== expectedCanonicalRoot) fail('config root canonical location changed');
115
+ }
84
116
  async read(): Promise<Config> {
85
- // Missing, malformed and unreadable files propagate; never overwrite them.
117
+ // Missing, malformed and unreadable local files propagate; no fallback.
118
+ await this.assertLocalPaths();
86
119
  return validateConfig(JSON.parse(await readFile(this.file, 'utf8')));
87
120
  }
88
121
  static async discover(cwd: string): Promise<ConfigStore | undefined> {
89
- let at = path.resolve(cwd);
90
- for (;;) {
91
- const store = new ConfigStore(at);
92
- try { await store.read(); return store; }
93
- catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
94
- const parent = path.dirname(at); if (parent === at) return undefined; at = parent;
95
- }
122
+ const store = new ConfigStore(await realpath(cwd));
123
+ try { await store.read(); return store; }
124
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; return undefined; }
125
+ }
126
+ static async workspace(cwd: string, workspaceRoot: string): Promise<ConfigStore> {
127
+ text(workspaceRoot, 'workspaceRoot');
128
+ if (!path.isAbsolute(workspaceRoot)) fail('workspaceRoot must be absolute');
129
+ const root = await realpath(workspaceRoot);
130
+ if (!(await stat(root)).isDirectory()) fail('workspaceRoot is not a directory');
131
+ const current = await realpath(cwd);
132
+ if (path.relative(root, current) !== '') fail('workspaceRoot must equal the session working directory; parent configs are forbidden');
133
+ const store = new ConfigStore(root);
134
+ await store.read(); // Explicit worker mode must never create a replacement coordinator.
135
+ return store;
96
136
  }
97
137
  protected openTemporary(file: string) { return open(file, 'wx'); }
138
+ private async cleanupTemporary(temp: string): Promise<void> {
139
+ // A moved root may leave an unpublished temp behind. Never follow a
140
+ // replacement parent outside the pinned root just to clean up. This guard
141
+ // is independent of lifecycle/deadline expiry so ordinary failures clean up.
142
+ await this.assertLocalPaths();
143
+ const resolved = await realpath(temp);
144
+ const relative = path.relative(this.canonicalRoot!, resolved);
145
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail('temporary config path escapes session root');
146
+ await unlink(temp);
147
+ }
98
148
  private async temporary(config: Config): Promise<string> {
99
149
  validateConfig(config);
150
+ await this.assertLocalPaths();
100
151
  await mkdir(path.dirname(this.file), { recursive: true });
152
+ await this.assertLocalPaths();
101
153
  const temp = `${this.file}.${randomUUID()}.tmp`;
102
154
  const handle = await this.openTemporary(temp);
103
155
  try {
@@ -108,7 +160,7 @@ export class ConfigStore {
108
160
  } catch (error) {
109
161
  // Cleanup failures must not replace the original write/sync/close error.
110
162
  await handle.close().catch(() => {});
111
- await unlink(temp).catch(() => {});
163
+ await this.cleanupTemporary(temp).catch(() => {});
112
164
  throw error;
113
165
  }
114
166
  }
@@ -118,14 +170,14 @@ export class ConfigStore {
118
170
  assertValid();
119
171
  const temp = await this.temporary({ version: 1, multiplexer: 'herdr', agents: [{ sessionId: id, name: 'Coordinator', coordinator: true, description: DEFAULT_DESCRIPTION, port: boundPort, projectDirectory: '.' }] });
120
172
  try {
121
- assertValid();
173
+ await this.assertLocalPaths(); assertValid();
122
174
  try { await link(temp, this.file); }
123
175
  catch (e) {
124
176
  if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
125
- await this.read(); await unlink(temp); return false;
177
+ await this.read(); await this.cleanupTemporary(temp); return false;
126
178
  }
127
- await unlink(temp); return true;
128
- } catch (error) { await unlink(temp).catch(() => {}); throw error; }
179
+ await this.cleanupTemporary(temp); return true;
180
+ } catch (error) { await this.cleanupTemporary(temp).catch(() => {}); throw error; }
129
181
  }
130
182
  private async publish(temp: string, assertValid: () => void): Promise<void> {
131
183
  const replace = this.options.rename ?? rename;
@@ -138,7 +190,7 @@ export class ConfigStore {
138
190
  // of this already synced/closed temp, never the mutation or any OS action
139
191
  // outside config persistence. The elapsed budget bounds retry initiation,
140
192
  // not the duration of an already-submitted OS rename.
141
- assertValid();
193
+ await this.assertLocalPaths(); assertValid();
142
194
  if (attempt > 0 && now() - started >= RENAME_BUDGET_MS) throw originalError;
143
195
  try { await replace(temp, this.file); return; }
144
196
  catch (error) {
@@ -160,7 +212,7 @@ export class ConfigStore {
160
212
  // Every publication attempt checks the lifecycle/deadline guard; an
161
213
  // already submitted OS rename still cannot be undone.
162
214
  await this.publish(temp, assertValid);
163
- } catch (error) { await unlink(temp).catch(() => {}); throw error; }
215
+ } catch (error) { await this.cleanupTemporary(temp).catch(() => {}); throw error; }
164
216
  return c;
165
217
  });
166
218
  this.tail = operation.catch(() => {}); return operation;