@comput/pi-intercom 0.6.1 → 0.8.1
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 +65 -6
- package/dist/config.d.ts +5 -0
- package/dist/config.js +84 -22
- package/dist/config.js.map +1 -1
- package/dist/index.js +18 -4
- package/dist/index.js.map +1 -1
- package/dist/launcher.js +11 -1
- package/dist/launcher.js.map +1 -1
- package/dist/reminders.d.ts +28 -0
- package/dist/reminders.js +81 -0
- package/dist/reminders.js.map +1 -0
- package/dist/runtime.d.ts +11 -1
- package/dist/runtime.js +89 -11
- package/dist/runtime.js.map +1 -1
- package/package.json +12 -6
- package/references/releasing.md +90 -0
- package/references/v1-specification.md +4 -4
- package/src/config.ts +59 -16
- package/src/index.ts +17 -4
- package/src/launcher.ts +9 -1
- package/src/reminders.ts +69 -0
- package/src/runtime.ts +76 -12
|
@@ -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.
|
|
@@ -19,13 +19,13 @@ This is the consolidated **target design**, authoritative over conflicting wordi
|
|
|
19
19
|
|
|
20
20
|
Location: `<coordinator-root>/.pi-intercom/config.json`.
|
|
21
21
|
|
|
22
|
-
-
|
|
23
|
-
-
|
|
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
|
-
-
|
|
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:
|
|
@@ -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
|
|
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.
|
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';
|
|
@@ -80,24 +80,67 @@ const RENAME_BUDGET_MS = 250;
|
|
|
80
80
|
export class ConfigStore {
|
|
81
81
|
readonly file: string;
|
|
82
82
|
private tail: Promise<unknown> = Promise.resolve();
|
|
83
|
+
private canonicalRoot?: string;
|
|
83
84
|
constructor(readonly root: string, private readonly options: ConfigStoreOptions = {}) { this.file = path.join(root, '.pi-intercom', 'config.json'); }
|
|
85
|
+
private async assertLocalPaths(): Promise<void> {
|
|
86
|
+
const root = await realpath(this.root);
|
|
87
|
+
if (this.canonicalRoot === undefined) this.canonicalRoot = root;
|
|
88
|
+
else if (root !== this.canonicalRoot) fail('config root canonical location changed');
|
|
89
|
+
// Reject existing escaping links/junctions before following them for any
|
|
90
|
+
// config access. These path checks are best effort, not TOCTOU protection.
|
|
91
|
+
for (const [location, directory] of [[path.dirname(this.file), true], [this.file, false]] as const) {
|
|
92
|
+
try { await lstat(location); }
|
|
93
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; }
|
|
94
|
+
let resolved: string;
|
|
95
|
+
try { resolved = await realpath(location); }
|
|
96
|
+
catch { fail('local config path cannot be resolved safely'); }
|
|
97
|
+
const relative = path.relative(root, resolved);
|
|
98
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail('local config path escapes session root');
|
|
99
|
+
const metadata = await stat(resolved);
|
|
100
|
+
if (directory ? !metadata.isDirectory() : !metadata.isFile()) fail('invalid local config path type');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async validateRoot(expectedCanonicalRoot: string): Promise<void> {
|
|
104
|
+
await this.assertLocalPaths();
|
|
105
|
+
if (this.canonicalRoot !== expectedCanonicalRoot) fail('config root canonical location changed');
|
|
106
|
+
}
|
|
84
107
|
async read(): Promise<Config> {
|
|
85
|
-
// Missing, malformed and unreadable files propagate;
|
|
108
|
+
// Missing, malformed and unreadable local files propagate; no fallback.
|
|
109
|
+
await this.assertLocalPaths();
|
|
86
110
|
return validateConfig(JSON.parse(await readFile(this.file, 'utf8')));
|
|
87
111
|
}
|
|
88
112
|
static async discover(cwd: string): Promise<ConfigStore | undefined> {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
const store = new ConfigStore(await realpath(cwd));
|
|
114
|
+
try { await store.read(); return store; }
|
|
115
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; return undefined; }
|
|
116
|
+
}
|
|
117
|
+
static async workspace(cwd: string, workspaceRoot: string): Promise<ConfigStore> {
|
|
118
|
+
text(workspaceRoot, 'workspaceRoot');
|
|
119
|
+
if (!path.isAbsolute(workspaceRoot)) fail('workspaceRoot must be absolute');
|
|
120
|
+
const root = await realpath(workspaceRoot);
|
|
121
|
+
if (!(await stat(root)).isDirectory()) fail('workspaceRoot is not a directory');
|
|
122
|
+
const current = await realpath(cwd);
|
|
123
|
+
if (path.relative(root, current) !== '') fail('workspaceRoot must equal the session working directory; parent configs are forbidden');
|
|
124
|
+
const store = new ConfigStore(root);
|
|
125
|
+
await store.read(); // Explicit worker mode must never create a replacement coordinator.
|
|
126
|
+
return store;
|
|
96
127
|
}
|
|
97
128
|
protected openTemporary(file: string) { return open(file, 'wx'); }
|
|
129
|
+
private async cleanupTemporary(temp: string): Promise<void> {
|
|
130
|
+
// A moved root may leave an unpublished temp behind. Never follow a
|
|
131
|
+
// replacement parent outside the pinned root just to clean up. This guard
|
|
132
|
+
// is independent of lifecycle/deadline expiry so ordinary failures clean up.
|
|
133
|
+
await this.assertLocalPaths();
|
|
134
|
+
const resolved = await realpath(temp);
|
|
135
|
+
const relative = path.relative(this.canonicalRoot!, resolved);
|
|
136
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail('temporary config path escapes session root');
|
|
137
|
+
await unlink(temp);
|
|
138
|
+
}
|
|
98
139
|
private async temporary(config: Config): Promise<string> {
|
|
99
140
|
validateConfig(config);
|
|
141
|
+
await this.assertLocalPaths();
|
|
100
142
|
await mkdir(path.dirname(this.file), { recursive: true });
|
|
143
|
+
await this.assertLocalPaths();
|
|
101
144
|
const temp = `${this.file}.${randomUUID()}.tmp`;
|
|
102
145
|
const handle = await this.openTemporary(temp);
|
|
103
146
|
try {
|
|
@@ -108,7 +151,7 @@ export class ConfigStore {
|
|
|
108
151
|
} catch (error) {
|
|
109
152
|
// Cleanup failures must not replace the original write/sync/close error.
|
|
110
153
|
await handle.close().catch(() => {});
|
|
111
|
-
await
|
|
154
|
+
await this.cleanupTemporary(temp).catch(() => {});
|
|
112
155
|
throw error;
|
|
113
156
|
}
|
|
114
157
|
}
|
|
@@ -118,14 +161,14 @@ export class ConfigStore {
|
|
|
118
161
|
assertValid();
|
|
119
162
|
const temp = await this.temporary({ version: 1, multiplexer: 'herdr', agents: [{ sessionId: id, name: 'Coordinator', coordinator: true, description: DEFAULT_DESCRIPTION, port: boundPort, projectDirectory: '.' }] });
|
|
120
163
|
try {
|
|
121
|
-
assertValid();
|
|
164
|
+
await this.assertLocalPaths(); assertValid();
|
|
122
165
|
try { await link(temp, this.file); }
|
|
123
166
|
catch (e) {
|
|
124
167
|
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
|
|
125
|
-
await this.read(); await
|
|
168
|
+
await this.read(); await this.cleanupTemporary(temp); return false;
|
|
126
169
|
}
|
|
127
|
-
await
|
|
128
|
-
} catch (error) { await
|
|
170
|
+
await this.cleanupTemporary(temp); return true;
|
|
171
|
+
} catch (error) { await this.cleanupTemporary(temp).catch(() => {}); throw error; }
|
|
129
172
|
}
|
|
130
173
|
private async publish(temp: string, assertValid: () => void): Promise<void> {
|
|
131
174
|
const replace = this.options.rename ?? rename;
|
|
@@ -138,7 +181,7 @@ export class ConfigStore {
|
|
|
138
181
|
// of this already synced/closed temp, never the mutation or any OS action
|
|
139
182
|
// outside config persistence. The elapsed budget bounds retry initiation,
|
|
140
183
|
// not the duration of an already-submitted OS rename.
|
|
141
|
-
assertValid();
|
|
184
|
+
await this.assertLocalPaths(); assertValid();
|
|
142
185
|
if (attempt > 0 && now() - started >= RENAME_BUDGET_MS) throw originalError;
|
|
143
186
|
try { await replace(temp, this.file); return; }
|
|
144
187
|
catch (error) {
|
|
@@ -160,7 +203,7 @@ export class ConfigStore {
|
|
|
160
203
|
// Every publication attempt checks the lifecycle/deadline guard; an
|
|
161
204
|
// already submitted OS rename still cannot be undone.
|
|
162
205
|
await this.publish(temp, assertValid);
|
|
163
|
-
} catch (error) { await
|
|
206
|
+
} catch (error) { await this.cleanupTemporary(temp).catch(() => {}); throw error; }
|
|
164
207
|
return c;
|
|
165
208
|
});
|
|
166
209
|
this.tail = operation.catch(() => {}); return operation;
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { createCloseProvider } from './pane-close.js';
|
|
|
11
11
|
|
|
12
12
|
const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
|
|
13
13
|
const tools: [string, string, TSchema][] = [
|
|
14
|
-
['create_worker', 'Coordinator only. Launch one anonymous worker
|
|
14
|
+
['create_worker', 'Coordinator only. Launch one anonymous worker at the coordinator root. projectDirectory defaults to root and must resolve to that same directory; subfolder launches cannot use a parent config and are refused. No assignment, configuration, or registration wait.', Type.Object({ projectDirectory: Type.Optional(Type.String()) })],
|
|
15
15
|
['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 }) })],
|
|
16
16
|
['reload_worker', 'Coordinator only. Ask worker to reread responsibility and synchronize names. NOT Pi extension reload, cancellation, restart, or work assignment.', to],
|
|
17
17
|
['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 }) })],
|
|
@@ -19,11 +19,12 @@ const tools: [string, string, TSchema][] = [
|
|
|
19
19
|
['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({})],
|
|
20
20
|
['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],
|
|
21
21
|
['report_work', 'Configured, responsibility-loaded workers only. Store one public report (blocked, needs_decision, ready_for_review) and notify the coordinator; clear retires it. Summary required except clear (omit or empty). Never include private reasoning, credentials, raw tool payloads, or secrets. Receipt is not approval, verified completion, or permission for follow-up work.', Type.Object({ status: Type.String({ enum: ['blocked', 'needs_decision', 'ready_for_review', 'clear'] }), summary: Type.Optional(Type.String({ maxLength: 2000 })) })],
|
|
22
|
+
['stop_reminders', 'Coordinator only. Disable the five-minute orchestration reminders until the next worker-directed call. Use when there is genuinely nothing to follow up on. Stops only reminders, not workers, close jobs or model work. Reminder state is session-local and clears on shutdown/reload.', Type.Object({})],
|
|
22
23
|
['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({})],
|
|
23
24
|
['stop_worker', UNSUPPORTED_CANCELLATION, to],
|
|
24
25
|
['close_worker', 'Coordinator only. Begin a background save-handoff-and-close job for a configured worker in Linux Herdr. Requests a public summary, saves it in config, waits for successful tool persistence and final turn settlement, then attempts one verified pane close. Returns job acceptance, not completion. Registration/session history are retained. Check worker_status/list for closeJob; failure/timeout/uncertainty never authorizes retry. Not graceful abort or an all-descendants termination guarantee; Windows/non-Herdr unsupported.', to],
|
|
25
26
|
['report_handoff', 'Configured worker only, in response to its pending close request. Submit the matching jobId and concise public resume handoff (current work, completed work, unfinished items, blockers, next step). Never include secrets, private reasoning or raw tool payloads. Saved receipt is not closure: finish the response and wait; the extension requires final turn settlement before pane closure.', Type.Object({ jobId: Type.String({ minLength: 1, maxLength: 128 }), summary: Type.String({ minLength: 1, maxLength: 4000 }) })],
|
|
26
|
-
['resume_worker', 'Coordinator only. Resume saved session/name/responsibility after the user explicitly confirms the old session was closed: set confirmClosed:true only for that confirmation, never infer it from disconnected status. A matching live health endpoint blocks launch. In-flight/uncertain attempts are fenced until worker status arrives; otherwise inspect process and reload coordinator before considering retry. No cross-process uniqueness guarantee, automatic assignment, replacement or removal.', Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }), confirmClosed: Type.Boolean() })],
|
|
27
|
+
['resume_worker', 'Coordinator only. Resume saved session/name/responsibility after the user explicitly confirms the old session was closed: set confirmClosed:true only for that confirmation, never infer it from disconnected status. A matching live health endpoint blocks launch. In-flight/uncertain attempts are fenced until worker status arrives; otherwise inspect process and reload coordinator before considering retry. Saved subfolder launches are refused rather than relocated, because a worker cannot use a parent config. No cross-process uniqueness guarantee, automatic assignment, replacement or removal.', Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }), confirmClosed: Type.Boolean() })],
|
|
27
28
|
['remove_worker', 'Coordinator only. Remove config entry only; never stop process or delete session files. A running worker must be explicitly closed before removal. Retain registration to resume it later; remove only to forget it. Pending or uncertain close jobs block removal.', to],
|
|
28
29
|
['set_multiplexer', 'Coordinator only. Set herdr (default, Windows/Linux) or none (separate visible Windows terminals; unsupported on Linux) for future launches. Never move/restart existing workers or fall back.', Type.Object({ multiplexer: Type.String({ enum: ['herdr', 'none'] }) })],
|
|
29
30
|
];
|
|
@@ -46,6 +47,10 @@ class PiConfigStore extends ConfigStore {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
export default function intercomExtension(pi: ExtensionAPI): void {
|
|
50
|
+
pi.registerFlag('intercom-root', {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Explicit Intercom root must equal this session cwd; parent configuration is never allowed, even with this flag.',
|
|
53
|
+
});
|
|
49
54
|
let runtime: Intercom | undefined;
|
|
50
55
|
let context: ExtensionContext | undefined;
|
|
51
56
|
let generation = 0;
|
|
@@ -96,6 +101,10 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
96
101
|
context = ctx;
|
|
97
102
|
if (!['win32', 'linux'].includes(process.platform) || ctx.mode !== 'tui') throw new Error('PiIntercom requires Windows or Linux interactive Pi. No resources started.');
|
|
98
103
|
if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
|
|
104
|
+
// Flags become available at session_start, not extension factory time. Never
|
|
105
|
+
// infer workspace authority from ancestors or inherited environment values.
|
|
106
|
+
const workspaceRoot = pi.getFlag('intercom-root');
|
|
107
|
+
if (workspaceRoot !== undefined && typeof workspaceRoot !== 'string') throw new Error('PiIntercom: intercom-root must be an explicit absolute path.');
|
|
99
108
|
runtime = new Intercom({
|
|
100
109
|
cwd: ctx.cwd,
|
|
101
110
|
sessionId: () => context!.sessionManager.getSessionId(),
|
|
@@ -106,7 +115,7 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
106
115
|
setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
|
|
107
116
|
notify: text => ctx.ui.notify(text, 'info'),
|
|
108
117
|
}, {
|
|
109
|
-
launch: launcher.launch, store: root => new PiConfigStore(root),
|
|
118
|
+
launch: launcher.launch, store: root => new PiConfigStore(root), workspaceRoot,
|
|
110
119
|
closeProvider: createCloseProvider({
|
|
111
120
|
sessionId: () => context!.sessionManager.getSessionId(),
|
|
112
121
|
sessionFile: () => context!.sessionManager.getSessionFile(),
|
|
@@ -185,11 +194,15 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
185
194
|
for (const [operation, description, parameters] of tools) {
|
|
186
195
|
pi.registerTool({
|
|
187
196
|
name: `intercom_${operation}`, label: `Intercom ${operation}`, description, parameters,
|
|
197
|
+
// Handoff readiness requires this call's successful transcript tool result.
|
|
198
|
+
// Pi1.x enforces model-only for nested calls;0.84 ignores this optional field
|
|
199
|
+
// and has no nested-tool API. Never substitute a parent result as proof.
|
|
200
|
+
exposure: operation === 'report_handoff' ? 'model-only' : 'direct',
|
|
188
201
|
async execute(_id, args, signal, _update, ctx) {
|
|
189
202
|
signal?.throwIfAborted(); context = ctx;
|
|
190
203
|
if (!runtime) throw new Error('PiIntercom not initialized; inspect startup error.');
|
|
191
204
|
const current = runtime, started = generation;
|
|
192
|
-
const result = await current.tool(operation, args as Record<string, unknown>, _id);
|
|
205
|
+
const result = await current.tool(operation, args as Record<string, unknown>, _id, signal);
|
|
193
206
|
if (operation === 'configure_worker') await ensureCoordinatorMonitor(current, ctx, started);
|
|
194
207
|
const output = truncateHead(JSON.stringify(result, null, 2));
|
|
195
208
|
return { content: [{ type: 'text', text: output.content + (output.truncated ? `\n[Truncated; full shared configuration: ${current.store.file}]` : '') }], details: {} };
|
package/src/launcher.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { fail } from './config.js';
|
|
4
5
|
import type { LaunchRequest } from './runtime.js';
|
|
5
6
|
|
|
@@ -23,9 +24,16 @@ export function launchers(options: LauncherOptions) {
|
|
|
23
24
|
async function launch(request: LaunchRequest): Promise<unknown> {
|
|
24
25
|
const platform = options.platform ?? process.platform;
|
|
25
26
|
if (platform !== 'win32' && platform !== 'linux') fail('launchers support Windows and Linux only');
|
|
27
|
+
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
28
|
+
if (typeof request.workspaceRoot !== 'string' || !request.workspaceRoot.trim() || request.workspaceRoot.includes('\0') || !paths.isAbsolute(request.workspaceRoot)) fail('worker launch requires an explicit absolute workspace root');
|
|
29
|
+
// Runtime performs authoritative realpath equality before reading config.
|
|
30
|
+
// Defend direct launcher calls too, without filesystem access or cwd rewrites.
|
|
31
|
+
const samePath = (value: string) => platform === 'win32' ? paths.resolve(value).toLowerCase() : paths.resolve(value);
|
|
32
|
+
if (typeof request.cwd !== 'string' || !paths.isAbsolute(request.cwd) || samePath(request.workspaceRoot) !== samePath(request.cwd)) fail('worker workspace root must equal its working directory; parent workspace access is not allowed');
|
|
26
33
|
if (platform === 'linux' && request.multiplexer === 'none') fail('Linux worker launching requires Herdr; none is Windows-only. No terminal fallback');
|
|
27
34
|
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');
|
|
28
|
-
|
|
35
|
+
// Explicit per-child CLI scope cannot widen cwd or leak from a parent shell environment.
|
|
36
|
+
const args = ['-e', options.extension, '--intercom-root', request.workspaceRoot, ...(request.sessionId ? ['--session', request.sessionId] : [])];
|
|
29
37
|
if (request.multiplexer === 'herdr') {
|
|
30
38
|
if (env.HERDR_ENV !== '1' || !env.HERDR_WORKSPACE_ID) fail('Herdr launcher requires this Pi to run inside a Herdr-managed workspace; no fallback');
|
|
31
39
|
let created: { result?: { root_pane?: { pane_id?: string }; tab?: { tab_id?: string } } };
|
package/src/reminders.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const REMINDER_INTERVAL_MS = 5 * 60 * 1000;
|
|
2
|
+
export const REMINDER_TEXT = 'Intercom reminder: Review outstanding delegated work and worker reports. If follow-up is needed, take the next appropriate action. Do not assume workers have finished or stopped.\n\nIf there is genuinely nothing to follow up on, call intercom_stop_reminders. Otherwise, reminders continue every five minutes; a new worker call resets the timer.\n\nThis reminder does not authorize new work, retries, worker closure, or publication.';
|
|
3
|
+
|
|
4
|
+
export interface ReminderClock {
|
|
5
|
+
schedule(callback: () => void, milliseconds: number): () => void;
|
|
6
|
+
}
|
|
7
|
+
const clock: ReminderClock = {
|
|
8
|
+
schedule(callback, milliseconds) {
|
|
9
|
+
const timer = setTimeout(callback, milliseconds);
|
|
10
|
+
timer.unref();
|
|
11
|
+
return () => clearTimeout(timer);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
interface ReminderHost {
|
|
15
|
+
isCoordinator(): Promise<boolean>;
|
|
16
|
+
busy(): boolean;
|
|
17
|
+
deliver(text: string): void;
|
|
18
|
+
}
|
|
19
|
+
/** Ephemeral reminder only: never worker actions, network retries or a backlog. */
|
|
20
|
+
export class CoordinatorReminders {
|
|
21
|
+
private generation = 0;
|
|
22
|
+
private tick = 0;
|
|
23
|
+
private checking = false;
|
|
24
|
+
private disposed = false;
|
|
25
|
+
private cancel?: () => void;
|
|
26
|
+
private detachOwner?: () => void;
|
|
27
|
+
constructor(private readonly host: ReminderHost, private readonly timer: ReminderClock = clock) {}
|
|
28
|
+
arm(signal?: AbortSignal): void {
|
|
29
|
+
if (this.disposed || signal?.aborted) return;
|
|
30
|
+
this.stop();
|
|
31
|
+
const generation = this.generation;
|
|
32
|
+
if (signal) {
|
|
33
|
+
const abort = () => { if (this.generation === generation) this.stop(); };
|
|
34
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
35
|
+
this.detachOwner = () => signal.removeEventListener('abort', abort);
|
|
36
|
+
}
|
|
37
|
+
this.schedule(generation);
|
|
38
|
+
}
|
|
39
|
+
stop(): void {
|
|
40
|
+
this.generation++; this.tick++;
|
|
41
|
+
this.cancel?.(); this.cancel = undefined;
|
|
42
|
+
this.detachOwner?.(); this.detachOwner = undefined;
|
|
43
|
+
}
|
|
44
|
+
dispose(): void { this.stop(); this.disposed = true; }
|
|
45
|
+
private current(generation: number, tick: number): boolean {
|
|
46
|
+
return !this.disposed && this.generation === generation && this.tick === tick;
|
|
47
|
+
}
|
|
48
|
+
private schedule(generation: number): void {
|
|
49
|
+
this.cancel = this.timer.schedule(() => {
|
|
50
|
+
if (this.disposed || generation !== this.generation) return;
|
|
51
|
+
this.cancel = undefined;
|
|
52
|
+
const tick = ++this.tick;
|
|
53
|
+
this.schedule(generation);
|
|
54
|
+
// A slow role read never creates concurrent reads or a queued reminder.
|
|
55
|
+
// Advancing tick also invalidates any read from the preceding interval.
|
|
56
|
+
if (this.checking) return;
|
|
57
|
+
this.checking = true;
|
|
58
|
+
void (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const coordinator = await this.host.isCoordinator();
|
|
61
|
+
if (!this.current(generation, tick)) return;
|
|
62
|
+
if (!coordinator) { this.stop(); return; }
|
|
63
|
+
if (!this.host.busy()) this.host.deliver(REMINDER_TEXT);
|
|
64
|
+
} catch { /* Unknown role/read/delivery outcome: no blind immediate replay. */ }
|
|
65
|
+
finally { this.checking = false; }
|
|
66
|
+
})();
|
|
67
|
+
}, REMINDER_INTERVAL_MS);
|
|
68
|
+
}
|
|
69
|
+
}
|