mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-16 12:37:10 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732182bd07 | ||
|
|
3eca22bd83 | ||
|
|
e162769117 | ||
|
|
cd26b3cde8 | ||
|
|
3c5393c7c7 | ||
|
|
de292ae612 | ||
|
|
695381de3a | ||
|
|
8ca35bce33 | ||
|
|
ed050658e3 | ||
|
|
4599b5ad9f | ||
|
|
08e8af604f | ||
|
|
55507d3170 | ||
|
|
080021ff3a | ||
|
|
71e844f114 | ||
|
|
3636953eda |
@@ -2,7 +2,7 @@
|
||||
|
||||
## Design
|
||||
|
||||
The main chat discusses the plan with the user, then supervises an interactive `goals-worker` in Herdr. Use stock pi-subagents, pi-intercom and pi-schedule-prompt; do not build another transport, scheduler or worker runtime.
|
||||
The main chat discusses the plan with the user, then supervises an interactive `goals-worker` in Herdr. Use stock pi-subagents, pi-intercom and @jl1990/pi-scheduler; do not build another transport, scheduler or worker runtime.
|
||||
|
||||
> the hope is we can have a smart supervisor like you, with judgment and context. But it doesn't use many tokens as it checks in and sees an overview.
|
||||
>
|
||||
@@ -33,11 +33,11 @@ The supervisor should:
|
||||
|
||||
Run `npm test`, `npm run typecheck` and `npm run lint` before committing.
|
||||
|
||||
`test/goals.test.ts` exercises current state, file updates and role restrictions with a Pi API mock. `test/rpc-review.test.ts` starts real Pi with a deterministic local model and schema-only worker tools: it checks automatic proposal, editor/discussion and Ready role transition without credits or launching workers. It does not prove Herdr rendering, live message delivery or model judgment.
|
||||
`test/rpc-review.test.ts` runs a deterministic parent/worker story using real Pi, saved sessions and stock Intercom: planning/Ready, failure after progress, offline recovery, sourced review, delivery retry, same-worker correction, reload, intentional interruption and busy Clear. The RPC fixture seeds the launch binding rather than calling OpenGoalWorker. It does not prove native pane allocation/rendering or model judgment. `test/goals.test.ts` retains focused file-mutation, ownership and lifecycle checks that are cheaper to exercise at the Pi API boundary. Run targeted tests through `npm test -- <file>` so ignored investigations stay outside discovery. <!-- Pi/OpenAI -->
|
||||
|
||||
For functional acceptance, read `herdr --skill`, confirm `HERDR_ENV=1`, and use `scripts/prepare-trial.mjs` to create an isolated project/profile. Open only new no-focus test panes. Observe the actual planning dialogue and Ready selection, worker attachment, Intercom report, independent artifact inspection and CompleteGoal. Record interventions separately from autonomous success. Preserve nonempty byte/test evidence. Never reload or operate active user research panes. Close test panes when finished.
|
||||
|
||||
Known stock limits: stop workers before supervisor reload (later worker exit can crash its stale context); disabled scheduler jobs are deleted on reload/shutdown. Test saved-session/solo recovery without repeating completed work; do not claim these package bugs are fixed here.
|
||||
Stop workers before reloading legacy supervisor/test sessions: later worker exit can crash their stale context. Legacy pi-schedule-prompt sessions may delete disabled jobs on reload/shutdown; the bundled @jl1990/pi-scheduler 0.5.0 retains disabled tasks. Test saved-session/solo recovery without repeating completed work; do not claim legacy package bugs are fixed here.
|
||||
|
||||
Keep temporary plans, audits and captures under ignored `.local/`. Git history retains the removed historical material. Do not add root handovers or duplicate READMEs. Never touch human-named files or credentials.
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ The worker like it! The supervisors seem very focused.
|
||||
|
||||
## Plan.md
|
||||
|
||||
Goal status is held only in the plan: `[ ]` open, `[/]` active, `[x]` reported done, `[✓]` reviewed by `CompleteGoal`, and `[-]` cancelled. Requirements changes wake the supervisor to inspect and reopen goals if needed; they do not automatically rewrite status. Review evidence stays in Log. Old `[x]` goals are not automatically certified. <!-- Pi/OpenAI -->
|
||||
|
||||
The plan file looks like this:
|
||||
|
||||
```md
|
||||
@@ -122,7 +124,7 @@ resync-after-compaction from [tmonk/pi-goal-x](https://github.com/tmonk/pi-goal-
|
||||
|
||||
## Install
|
||||
|
||||
Requires Herdr. Includes [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) 0.66.0, pi-intercom and pi-schedule-prompt.
|
||||
Requires Herdr. Includes [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) 0.66.0, pi-intercom and @jl1990/pi-scheduler 0.5.0, pinned to its official registry archive and lockfile integrity.
|
||||
|
||||
```bash
|
||||
pi install git:github.com/wassname/pi-goals
|
||||
@@ -130,7 +132,7 @@ pi install git:github.com/wassname/pi-goals
|
||||
|
||||
Start a fresh Pi session. No worker agent file is needed: `OpenGoalWorker` uses Nico's public `project.open` surface, then the peer explicitly attaches with `AttachGoalPlan`. Use one pi-goals installation and disable separately installed copies of its bundled companions; duplicate scheduler instances send duplicate prompts.
|
||||
|
||||
The bundled pi-schedule-prompt 0.4.1 reads project schedules even when Pi project trust is declined. Until that upstream issue is fixed, use this bundle only in repositories you trust.
|
||||
The scheduler stores tasks under `~/.pi/agent/state/scheduler/tasks.json`, or `PI_SCHEDULER_STATE_FILE` when set. A separate Pi profile alone does not isolate this store. Goal check-ins use session scope; shared cwd/global tasks are not owned by pi-goals. Existing legacy check-ins need explicit ownership and prompt-byte review before migration; custom multiline prompts are not silently flattened.
|
||||
|
||||
For development, register the checkout so workers also discover its extensions:
|
||||
|
||||
@@ -147,17 +149,17 @@ pi
|
||||
/goals
|
||||
```
|
||||
|
||||
`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Discuss returns to chat and waits for your input. Menu New asks for optional instructions before creating a plan; submit blank to use the conversation, or cancel to leave things unchanged. Typed `/goals new <instructions>` still starts directly. Quit (`exit` or `clear`) leaves the original plan unchanged, removes this session's goal check-in, and clears goal state without a model call. Matching check-in names with missing or different session bindings are left unchanged with a warning. Worker processes are unchanged; inspect their native panes and use their exact Intercom identities for steering. New creates a separate draft without overwriting earlier plans, named `.pi/plan/<last-six-session-characters>-vN.md` using the next version after existing files. The title stays inside the plan; old files are not renamed. The widget shows a plain `✓` and the relative plan path for inside-project plans. External plans use the filename with an `(external)` marker; `/goals status` keeps the full location. These are plain labels, not terminal links.
|
||||
`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Discuss returns to chat and waits for your input. Menu New asks for optional instructions before creating a plan; submit blank to use the conversation, or cancel to leave things unchanged. Typed `/goals new <instructions>` still starts directly. Quit (`exit` or `clear`) leaves the original plan unchanged, clears goal state and requests removal of this session's goal check-in without a model call. Clear uses verified public scheduler commands; missing commands or an unobservable result leave removal unconfirmed. Inspect `/schedules all` for the result. Matching check-in names with missing or different session scope are left unchanged with a warning. Worker processes are unchanged; inspect their native panes and use their exact Intercom identities for steering. New creates a separate draft without overwriting earlier plans, named `.pi/plan/<last-six-session-characters>-vN.md` using the next version after existing files. The title stays inside the plan; old files are not renamed. The widget shows a plain `✓` and the relative plan path for inside-project plans. External plans use the filename with an `(external)` marker; `/goals status` keeps the full location. These are plain labels, not terminal links.
|
||||
|
||||
### Native worker lifecycle and limits
|
||||
|
||||
The parent and worker keep separate native conversations. Worker attachment and stop notices use stock Intercom extension channels; assignments, reports and corrections remain visible Pi messages. A pane-open receipt, idle state or delivery receipt does not approve a goal.
|
||||
The parent and worker keep separate native conversations. Worker attachment and stop notices use stock Intercom extension channels; assignments, reports and corrections remain visible Pi messages. Attachment metadata is saved and displayed without requesting a model acknowledgement (at a safe turn boundary when busy); blocked, done and error reports still request supervisor review. A pane-open receipt, idle state or delivery receipt does not approve a goal.
|
||||
|
||||
`OpenGoalWorker` opens a blank peer and waits for verified Intercom capability before sending work. For independent work after review, use `action: "fresh"` with the exact inspected `reviewedThrough` entry ID. This uses Pi's new session in the same pane; the previous conversation stays in saved history. Revisions still use the same Intercom session. Drafts, pending input, changed history and a local worker pause block replacement.
|
||||
`OpenGoalWorker` supplies startup only to a newly created stock Pi context. An existing live binding receives no message, so opening it does not replace its conversation or editor draft. The new worker calls `AttachGoalPlan`, reports its exact Intercom identity/model/saved-session path, and waits for a direct parent assignment. Revisions use that same session. A model preference is an instruction for agent-led configuration and verification, not a CLI override; later human changes take precedence.
|
||||
|
||||
For recovery, use `action: "recover"`, `writersStopped: true` and the owned saved session after inspecting other writers. Recovery restores context without replaying a task or changing its model. A prospective session path is not durable history. A live binding without a responsive, capable Pi peer remains unconfirmed; no shell restart or second backend is invented.
|
||||
There is no custom fresh/recover operation. Inspect stopped workers' saved history and partial results, preserve drafts/queued input, and confirm the exact writer stopped before stock `project.close`/`project.open`. Stock close checks ownership and idle state, but cannot establish editor-draft safety. If uncertain, retain the pane and inspect it. Preserve history and completed work. Automatic ownership transfer remains unresolved. — Pi/OpenAI
|
||||
|
||||
Without an explicit model preference, a new context uses the current Pi profile's normal defaults. A session-local human choice remains in that earlier session's history; recovery retains it. Requested-model automation currently fails closed: Pi's asynchronous public setter lacks a guard against overwriting a concurrent human selection. No fallback task is launched and no stale preference is reapplied. This model-selection requirement remains unfinished. — Pi/OpenAI
|
||||
`/goals attach` now rejects a plan that is not already current in this context, including `attach <path> solo` and reattachment after Clear. The public roster cannot establish its supervisor's ownership; a checkbox or missing roster row is not proof. The command leaves current authority unchanged and provides read-only inspection controls. Keep the original supervisor context when available rather than clearing it to reconnect. Same-current-plan refresh and its separate explicit stopped-writer confirmation for solo recovery remain available. This guard does not solve generic adoption or cross-parent transfer. — Pi/OpenAI
|
||||
|
||||
## Context delivery
|
||||
|
||||
@@ -165,7 +167,7 @@ New injected `[pi-goals]` prompts display as a compact notice; `Ctrl+O` expands
|
||||
|
||||
Startup, attachment/resume, session restore, successful compaction and changed requirements restore the active plan above Log at the next ordinary prompt. This includes current preferences and User voice, but leaves historical Log on disk. Routine context and requested reviews quote unfinished or unreviewed goal lines. After eight unchanged turns, the next ordinary prompt carries an upkeep reminder with its reason, those goal lines and the plan path. It omits preferences, role prose and rotating quotations. Reviewed, cancelled and paused work receives no periodic upkeep; manual ticks remain unreviewed. A fresh plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule a refresh. Missing plans are retried. Compaction still uses Pi's configured threshold.
|
||||
|
||||
Plan-change notices direct the agent to read the current file, including changed constraints or a final cancellation. Only our own pending notice is coalesced; unrelated queued input does not suppress it. The editable hourly `schedule_prompt` check-in remains separate.
|
||||
Plan-change notices direct the agent to read the current file, including changed constraints or a final cancellation. Only our own pending notice is coalesced; unrelated queued input does not suppress it. The editable hourly `schedule_task` check-in remains separate. It uses an explicit prompt action, session scope and a short one-line wake that reads the current attached plan. Inspect recurrence with `/schedules all`; change the interval through `manage_scheduled_task` without resending the prompt. Pause disables the owned check-in and retains its prompt/interval; resume may enable only the unchanged job recorded by that pause. Disabled jobs survive reload.
|
||||
|
||||
The first request to complete the final non-cancelled goal queues a review without recording sign-off. The reviewer must read the complete plan file and actual evidence, then call `CompleteGoal` again in that review run. The review survives intervening inspection tool rounds and same-run queued delivery, but a plan edit invalidates it. Routine messages do not paste the archive. <!-- Pi/OpenAI -->
|
||||
|
||||
@@ -193,6 +195,8 @@ node scripts/session-usage.mjs <supervisor.jsonl> <worker.jsonl>
|
||||
|
||||
This separates output, uncached input and repeated cached input. It excludes subprocess API calls. [Isolated Herdr test setup](scripts/prepare-trial.mjs).
|
||||
|
||||
For deterministic fixtures, run `node scripts/prepare-trial.mjs INSTALLED_PI_ROOT --offline LOOPBACK_MODEL_URL` with an existing HTTP loopback model server. This route loads only the candidate's required packages and the existing offline model fixture, without reading your profile or copying credentials. Without `--offline`, the helper retains your installed packages and model settings. Both routes isolate scheduler storage and only prepare files; neither launches Pi. <!-- Pi/OpenAI -->
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
Generated
+83
-57
@@ -10,12 +10,12 @@
|
||||
"bundleDependencies": [
|
||||
"pi-subagents",
|
||||
"pi-intercom",
|
||||
"pi-schedule-prompt"
|
||||
"@jl1990/pi-scheduler"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jl1990/pi-scheduler": "https://registry.npmjs.org/@jl1990/pi-scheduler/-/pi-scheduler-0.5.0.tgz",
|
||||
"pi-intercom": "0.13.0",
|
||||
"pi-schedule-prompt": "0.4.1",
|
||||
"pi-subagents": "0.66.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4191,6 +4191,87 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jl1990/pi-scheduler": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jl1990/pi-scheduler/-/pi-scheduler-0.5.0.tgz",
|
||||
"integrity": "sha512-YWbTnLr6dxhAXwz+w1ZuRQDgOKpBQx6tL7JGTsQDBZ/8MNoy3viuDJS0dXWIkWBDwp2TEKylfQvrIjaUG2icRg==",
|
||||
"bundleDependencies": [
|
||||
"croner",
|
||||
"proper-lockfile"
|
||||
],
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"croner": "10.0.1",
|
||||
"proper-lockfile": "4.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "*",
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-ai": {
|
||||
"optional": true
|
||||
},
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"@earendil-works/pi-tui": {
|
||||
"optional": true
|
||||
},
|
||||
"typebox": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jl1990/pi-scheduler/node_modules/croner": {
|
||||
"version": "10.0.1",
|
||||
"funding": [
|
||||
{
|
||||
"type": "other",
|
||||
"url": "https://paypal.me/hexagonpp"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/hexagon"
|
||||
}
|
||||
],
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jl1990/pi-scheduler/node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"inBundle": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@jl1990/pi-scheduler/node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jl1990/pi-scheduler/node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@jl1990/pi-scheduler/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"inBundle": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -4960,26 +5041,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/croner": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz",
|
||||
"integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "other",
|
||||
"url": "https://paypal.me/hexagonpp"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/hexagon"
|
||||
}
|
||||
],
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
@@ -5791,41 +5852,6 @@
|
||||
"typebox": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/pi-schedule-prompt": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pi-schedule-prompt/-/pi-schedule-prompt-0.4.1.tgz",
|
||||
"integrity": "sha512-AcmMcffvFo7ibPJn66ML+vqYab8fqdW3n344+8Lh9rfqcDziYrIQqqRnb4ntDGc2iZ0hsE5pN5DRf6rdWzUd3Q==",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"croner": "^10.0.1",
|
||||
"nanoid": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": ">=0.74.0",
|
||||
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
||||
"@earendil-works/pi-tui": ">=0.74.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pi-schedule-prompt/node_modules/nanoid": {
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pi-subagents": {
|
||||
"version": "0.66.0",
|
||||
"resolved": "https://registry.npmjs.org/pi-subagents/-/pi-subagents-0.66.0.tgz",
|
||||
|
||||
+5
-5
@@ -57,7 +57,7 @@
|
||||
"./src/index.ts",
|
||||
"./node_modules/pi-subagents/index.ts",
|
||||
"./node_modules/pi-intercom/index.ts",
|
||||
"./node_modules/pi-schedule-prompt/src/index.ts"
|
||||
"./node_modules/@jl1990/pi-scheduler/extensions/scheduler/index.ts"
|
||||
],
|
||||
"image": "https://github.com/user-attachments/assets/35feaa15-f022-4491-bcc2-fc31cb878a9f",
|
||||
"skills": [
|
||||
@@ -65,13 +65,13 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"pi-subagents": "0.66.0",
|
||||
"@jl1990/pi-scheduler": "https://registry.npmjs.org/@jl1990/pi-scheduler/-/pi-scheduler-0.5.0.tgz",
|
||||
"pi-intercom": "0.13.0",
|
||||
"pi-schedule-prompt": "0.4.1"
|
||||
"pi-subagents": "0.66.0"
|
||||
},
|
||||
"bundledDependencies": [
|
||||
"bundleDependencies": [
|
||||
"pi-subagents",
|
||||
"pi-intercom",
|
||||
"pi-schedule-prompt"
|
||||
"@jl1990/pi-scheduler"
|
||||
]
|
||||
}
|
||||
|
||||
+29
-14
@@ -5,28 +5,43 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sdkRoot = process.argv[2];
|
||||
if (!sdkRoot) throw new Error('Usage: node scripts/prepare-trial.mjs INSTALLED_PI_ROOT');
|
||||
const [sdkRoot, option, modelUrl, ...extra] = process.argv.slice(2);
|
||||
if (!sdkRoot || extra.length || (option !== undefined && option !== '--offline') || (option === '--offline' && !modelUrl)) throw new Error('Usage: node scripts/prepare-trial.mjs INSTALLED_PI_ROOT [--offline LOOPBACK_MODEL_URL]');
|
||||
const offline = option === '--offline';
|
||||
if (offline) {
|
||||
const url = new URL(modelUrl);
|
||||
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password) throw new Error('Offline fixtures require an HTTP loopback model URL without credentials.');
|
||||
}
|
||||
const revision = execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], {encoding:'utf8'}).trim();
|
||||
const root = mkdtempSync(join(tmpdir(), 'goals-nico-trial-'));
|
||||
const cwd = join(root, 'project'); const agentDir = join(root, 'agent');
|
||||
mkdirSync(cwd); mkdirSync(agentDir, {mode:0o700});
|
||||
const sourceAgent = process.env.PI_CODING_AGENT_DIR || join(homedir(),'.pi','agent');
|
||||
const sourceSettings = JSON.parse(readFileSync(join(sourceAgent,'settings.json'),'utf8'));
|
||||
const sdk = await import(pathToFileURL(join(sdkRoot,'dist/index.js')).href);
|
||||
const settings = sdk.SettingsManager.create(repo, sourceAgent, { projectTrusted:false });
|
||||
const manager = new sdk.DefaultPackageManager({cwd:repo, agentDir:sourceAgent, settingsManager:settings});
|
||||
const packages = manager.listConfiguredPackages().filter((p) => p.scope !== 'project' && !/^\/\//.test(p.source));
|
||||
const retained = packages.filter((p) => !/pi-subagents|pi-goals|pi-intercom|pi-schedule-prompt/.test(p.source));
|
||||
for (const p of retained) if (!p.installedPath) throw new Error(`Missing installed package: ${p.source}`);
|
||||
let sourceSettings = {}, packages = [], retained = [];
|
||||
if (offline) {
|
||||
// Allow only the candidate's required resources and the existing deterministic model fixture.
|
||||
// Do not read the real profile, discover its packages, or copy its credentials.
|
||||
sourceSettings = { extensions:[join(repo,'test/fixtures/offline-model.ts')], defaultProvider:'offline', defaultModel:'test', enabledModels:['offline/test'], compaction:{enabled:false} };
|
||||
writeFileSync(join(agentDir,'auth.json'), '{}\n');
|
||||
writeFileSync(join(agentDir,'models.json'), '{"providers":{}}\n');
|
||||
} else {
|
||||
const sourceAgent = process.env.PI_CODING_AGENT_DIR || join(homedir(),'.pi','agent');
|
||||
sourceSettings = JSON.parse(readFileSync(join(sourceAgent,'settings.json'),'utf8'));
|
||||
const sdk = await import(pathToFileURL(join(sdkRoot,'dist/index.js')).href);
|
||||
const settings = sdk.SettingsManager.create(repo, sourceAgent, { projectTrusted:false });
|
||||
const manager = new sdk.DefaultPackageManager({cwd:repo, agentDir:sourceAgent, settingsManager:settings});
|
||||
packages = manager.listConfiguredPackages().filter((p) => p.scope !== 'project' && !/^\/\//.test(p.source));
|
||||
retained = packages.filter((p) => !/pi-subagents|pi-goals|pi-intercom|pi-schedule-prompt|@jl1990\/pi-scheduler/.test(p.source));
|
||||
for (const p of retained) if (!p.installedPath) throw new Error(`Missing installed package: ${p.source}`);
|
||||
// Private copies, not symlinks: a trial OAuth refresh must not write the active auth file.
|
||||
for (const file of ['auth.json','models.json']) if (existsSync(join(sourceAgent,file))) copyFileSync(join(sourceAgent,file),join(agentDir,file));
|
||||
}
|
||||
writeFileSync(join(agentDir,'settings.json'), JSON.stringify({...sourceSettings, packages:[...retained.map((p)=>p.installedPath), repo]},null,2));
|
||||
// Private copies, not symlinks: a trial OAuth refresh must not write the active auth file.
|
||||
for (const file of ['auth.json','models.json']) if (existsSync(join(sourceAgent,file))) copyFileSync(join(sourceAgent,file),join(agentDir,file));
|
||||
execFileSync('git',['init','--quiet',cwd]);
|
||||
writeFileSync(join(cwd,'AGENTS.md'), 'Isolated functional trial. Work only in this project. Do not operate other Herdr panes, use live research sessions, or change global settings. Preserve evidence. The main chat supervises; the goals-worker implements.\n');
|
||||
writeFileSync(join(cwd,'.gitignore'), 'evidence/\n');
|
||||
const manifest={root,cwd,agentDir,repo,revision,retainedPackages:retained.map((p)=>p.source),replacedPackages:packages.filter((p)=>!retained.includes(p)).map((p)=>p.source)};
|
||||
const stateFile = join(root,'scheduler/tasks.json');
|
||||
const manifest={root,cwd,agentDir,repo,revision,profile:offline?'offline':'inherited',stateFile,retainedPackages:retained.map((p)=>p.source),replacedPackages:packages.filter((p)=>!retained.includes(p)).map((p)=>p.source)};
|
||||
writeFileSync(join(root,'manifest.json'),JSON.stringify(manifest,null,2));
|
||||
const quote=(s)=>`'${s.replaceAll("'", "'\\''")}'`;
|
||||
writeFileSync(join(root,'start.zsh'), `#!/usr/bin/env zsh\nset -e\ncd ${quote(cwd)}\nexport PI_CODING_AGENT_DIR=${quote(agentDir)}\nexec pi\n`,{mode:0o700});
|
||||
writeFileSync(join(root,'start.zsh'), `#!/usr/bin/env zsh\nset -e\ncd ${quote(cwd)}\nexport PI_CODING_AGENT_DIR=${quote(agentDir)}\nexport PI_SCHEDULER_STATE_FILE=${quote(stateFile)}\n${offline ? `export PI_OFFLINE=1\nexport PI_GOALS_OFFLINE_MODEL_URL=${quote(modelUrl)}\nexec pi --model offline/test` : 'exec pi'}\n`,{mode:0o700});
|
||||
console.log(JSON.stringify({root,cwd,agentDir,start:join(root,'start.zsh'),manifest:join(root,'manifest.json')},null,2));
|
||||
|
||||
+424
-241
@@ -1,18 +1,19 @@
|
||||
// Pi/OpenAI: Plan and supervise in the main chat; delegate implementation to a visible worker.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { type FSWatcher, mkdirSync, readdirSync, readFileSync, watch, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { type ExtensionAPI, type ExtensionContext, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, keyHint, SessionManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
import { Markdown, truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import { INTERCOM_EXTENSION_REGISTER_EVENT, type IntercomExtensionChannel, type IntercomExtensionRegistration } from "pi-intercom/extension-api.js";
|
||||
import { CronStorage } from "pi-schedule-prompt/src/storage.js";
|
||||
import { openProjectPane } from "pi-subagents/project-panes";
|
||||
import { Type } from "typebox";
|
||||
import { noticeDisplay } from "./notice-display.js";
|
||||
import { FOLD_LINE, foldPlan, GOAL_LINE, goalAcceptanceSignature } from "./plan.js";
|
||||
import { FOLD_LINE, foldPlan, GOAL_LINE, planRequirements as requirements } from "./plan.js";
|
||||
import { planViews } from "./plan-view.js";
|
||||
import {
|
||||
attachGoalPlanDescription,
|
||||
attachNotice,
|
||||
childPlanAttached,
|
||||
childPlanRole,
|
||||
completeGoalDescription,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
nativeMessages,
|
||||
pausedRole,
|
||||
pauseExitNotice,
|
||||
pendingReportReviews,
|
||||
planChangedReview,
|
||||
planContext,
|
||||
planDocument,
|
||||
@@ -38,64 +40,65 @@ import {
|
||||
planUnavailable,
|
||||
readyApproved,
|
||||
removeGoalSchedule,
|
||||
reportGoalEventDescription,
|
||||
reportReviewContent,
|
||||
reportReviewDescription,
|
||||
resumeNotice,
|
||||
scheduleCheckIn,
|
||||
schedulerMessages,
|
||||
soloNotice,
|
||||
soloRole,
|
||||
supervisor,
|
||||
upkeep,
|
||||
workerAssignment,
|
||||
workerAttachment,
|
||||
workerReview,
|
||||
workerStatus,
|
||||
} from "./prompts.js";
|
||||
|
||||
const STATE = "pi-goals-main-supervisor-v1";
|
||||
const WORKER = "goals-worker";
|
||||
const CONTROL = "goals-worker-control";
|
||||
const REPORT = "pi-goals-report", REVIEW = "pi-goals-report-review", REVIEW_DRAFT = "pi-goals-review-draft", REVIEW_REMINDER = "pi-goals-review-reminder";
|
||||
const RUN = "pi-goals-worker-run", STOP = "pi-goals-worker-stop", WORKER_EVENT = "pi-goals-worker-event";
|
||||
type GoalEventKind = "review_request" | "decision" | "blocker" | "completion" | "progress" | "running" | "waiting" | "receipt" | "no_change" | "aborted" | "unclassified";
|
||||
const REVIEWABLE_EVENTS = new Set<GoalEventKind>(["review_request", "decision", "blocker", "completion"]);
|
||||
interface WorkerStop { type: "stopped"; entryId: string; to: string; requestId: string; plan: string; text: string; identity: Peer; kind?: GoalEventKind; }
|
||||
interface Report { id: string; plan: string; session: string; sessionFile: string; requestId: string; task?: string; text: string; kind: GoalEventKind; supersedes?: string; }
|
||||
type WorkerEvent = Report;
|
||||
interface ReportReview { id: string; reportId?: string; report?: string; verdict: string; content: string; continuation: string; }
|
||||
const reviewedReportId = (review: ReportReview) => review.reportId ?? review.report;
|
||||
const WIDGET_GOAL_LIMIT = 3;
|
||||
type Mode = "chat" | "planning" | "supervising" | "paused" | "solo";
|
||||
type GoalStatus = "open" | "active" | "done" | "cancelled";
|
||||
type GoalStatus = "open" | "active" | "reported" | "done" | "cancelled";
|
||||
interface Peer {
|
||||
sessionId: string; sessionFile: string; leafId: string | null; paneId: string;
|
||||
durable: boolean; empty: boolean; started?: boolean; parentSession?: string; plan?: string; parentId?: string; requestId?: string; model?: string;
|
||||
}
|
||||
interface WorkerRequest {
|
||||
id: string; action: "start" | "fresh" | "recover"; task?: string; model?: string;
|
||||
reviewedThrough?: string; writersStopped?: boolean; sessionFile?: string; savedDigest?: string; savedId?: string; savedIntercom?: string;
|
||||
phase: "probe" | "control" | "switch"; previous?: Peer;
|
||||
sessionId: string; sessionFile: string; paneId: string; model?: string;
|
||||
}
|
||||
interface State {
|
||||
mode: Mode;
|
||||
plan?: string;
|
||||
worker?: { sessionFile?: string; intercomId?: string; paneId?: string; requestId?: string; parentId?: string; identity?: Peer; pending?: WorkerRequest };
|
||||
parent?: { intercomId: string; requestId: string; selfId?: string; started?: boolean };
|
||||
worker?: { sessionFile?: string; intercomId?: string; paneId?: string; requestId?: string; parentId?: string; task?: string; identity?: Peer };
|
||||
parent?: { intercomId: string; requestId: string };
|
||||
workerStopped?: boolean;
|
||||
pausedFrom?: "solo" | "supervising";
|
||||
signoffs: Record<string, { evidence: string[]; observation: string; signature: string }>;
|
||||
finalReview?: { planDigest: string };
|
||||
child?: boolean;
|
||||
lastControl?: string;
|
||||
pausedCheckIns?: Record<string, string>;
|
||||
}
|
||||
const initial = (): State => ({ mode: "chat", signoffs: {} });
|
||||
interface CheckInTask { id: string; name?: string; action?: string; type?: string; scope?: string; sessionFile?: string; prompt?: string; disabledAt?: string; }
|
||||
const SCHEDULER_SOURCE = fileURLToPath(import.meta.resolve("@jl1990/pi-scheduler/extensions/scheduler/index.ts"));
|
||||
const initial = (): State => ({ mode: "chat" });
|
||||
const digest = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
const key = (text: string) => text.trim().toLowerCase();
|
||||
// Read foreign history without SessionManager.open's on-disk legacy migration.
|
||||
const savedSession = (path: string) => SessionManager.inMemory(undefined, undefined, readFileSync(path, "utf8").trim().split("\n").map(line => JSON.parse(line)));
|
||||
function goals(text: string) {
|
||||
return foldPlan(text).split("\n").flatMap((line, index) => {
|
||||
const match = GOAL_LINE.exec(line);
|
||||
if (!match) return [];
|
||||
const box = match[1].toLowerCase();
|
||||
return [{ subject: match[2].trim(), status: (box === "x" ? "done" : box === "/" ? "active" : box === "-" ? "cancelled" : "open") as GoalStatus, index }];
|
||||
return [{ subject: match[2].trim(), status: (box === "✓" ? "done" : box === "x" ? "reported" : box === "/" ? "active" : box === "-" ? "cancelled" : "open") as GoalStatus, index }];
|
||||
});
|
||||
}
|
||||
const requirements = (text: string) => goals(text).map(g => goalAcceptanceSignature(text, g.subject)).join("\n");
|
||||
function savedWorker(path: string) {
|
||||
const text = readFileSync(path, "utf8");
|
||||
const entries = text.trim().split("\n").map(line => JSON.parse(line));
|
||||
const header = entries[0];
|
||||
if (header?.type !== "session" || typeof header.id !== "string" || !entries.some(entry => entry.message?.role === "assistant")) throw new Error(nativeMessages.notDurable);
|
||||
const state = entries.filter(entry => entry.type === "custom" && entry.customType === STATE).at(-1)?.data as State | undefined;
|
||||
if (!state?.child || !state.parent) throw new Error(nativeMessages.notOwned);
|
||||
return { header, state, digest: digest(text) };
|
||||
}
|
||||
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
|
||||
|
||||
export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
@@ -106,10 +109,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
let finalReviewTurnDigest: string | undefined;
|
||||
let opening = false;
|
||||
let channel: IntercomExtensionChannel | undefined;
|
||||
let ownIntercomId: string | undefined;
|
||||
let liveContext: ExtensionContext | undefined;
|
||||
let control: { from: string; request: WorkerRequest; plan: string; expected: Peer; cancelled?: boolean } | undefined;
|
||||
let replacing: "new" | "resume" | undefined;
|
||||
let notice = true;
|
||||
let fullPlanContextDue = true;
|
||||
let planWatcher: FSWatcher | undefined;
|
||||
@@ -141,12 +141,76 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
let lastWorkingSet = "";
|
||||
let pendingPlanNotice: string | undefined;
|
||||
let pendingUpkeep: { generation: number; workingSet: string } | undefined;
|
||||
const unfinishedGoals = (text: string) => foldPlan(text).split("\n").filter(line => {
|
||||
const match = GOAL_LINE.exec(line);
|
||||
return match && match[1] !== "-" && !(match[1].toLowerCase() === "x" && state.signoffs[key(match[2])] && state.signoffs[key(match[2])].signature === goalAcceptanceSignature(text, match[2]));
|
||||
}).join("\n");
|
||||
const checkIn = (ctx: ExtensionContext) => scheduleCheckIn(ctx.sessionManager.getSessionId(), state.plan ?? "");
|
||||
const hasScheduleTool = () => pi.getAllTools().some((tool) => tool.name === "schedule_prompt");
|
||||
const unfinishedGoals = (text: string) => {
|
||||
const lines = foldPlan(text).split("\n");
|
||||
return goals(text).filter(g => g.status !== "done" && g.status !== "cancelled").map(g => lines[g.index]).join("\n");
|
||||
};
|
||||
const checkIn = (ctx: ExtensionContext) => scheduleCheckIn(ctx.sessionManager.getSessionId(), state.plan ?? "", ctx.sessionManager.getSessionFile() ?? "", state.pausedCheckIns);
|
||||
const hasScheduleTool = () => pi.getAllTools().some((tool) => tool.name === "schedule_task");
|
||||
const ownsCheckIn = (task: CheckInTask, ctx: ExtensionContext) => task.name === `goals-${ctx.sessionManager.getSessionId()}` && task.action === "prompt" && task.scope === "session" && Boolean(task.sessionFile) && task.sessionFile === ctx.sessionManager.getSessionFile();
|
||||
let agentRunActive = false;
|
||||
let clearCheckIn: { generation: number; sessionFile: string; watcher?: FSWatcher; deadline?: ReturnType<typeof setTimeout>; startDeadline?: () => void } | undefined;
|
||||
function cancelCheckInRemoval() {
|
||||
clearCheckIn?.watcher?.close(); clearTimeout(clearCheckIn?.deadline); clearCheckIn = undefined;
|
||||
}
|
||||
let pauseCheckIn = false;
|
||||
function ownedCheckInIds(ctx: ExtensionContext) {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
const details = entry.type === "custom_message" && entry.customType === "scheduled-task" ? entry.details
|
||||
: entry.type === "message" && entry.message.role === "toolResult" && ["schedule_task", "list_scheduled_tasks", "manage_scheduled_task"].includes(entry.message.toolName) ? entry.message.details : undefined;
|
||||
const data = details as { task?: CheckInTask; tasks?: CheckInTask[] } | undefined;
|
||||
for (const task of data?.task ? [data.task] : data?.tasks ?? []) if (ownsCheckIn(task, ctx)) ids.add(task.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
const schedulerCommand = (name: string) => pi.getCommands().find(command => command.source === "extension" && command.sourceInfo?.path === SCHEDULER_SOURCE && (command.name === name || command.name.startsWith(name + ":")))?.name;
|
||||
function requestCheckInRemoval(ctx: ExtensionContext) {
|
||||
cancelCheckInRemoval();
|
||||
const list = schedulerCommand("schedules"), remove = schedulerCommand("schedule-remove");
|
||||
const sessionFile = ctx.sessionManager.getSessionFile();
|
||||
if (!list || !remove || !sessionFile) { ctx.ui.notify(schedulerMessages.unavailable, "warning"); return false; }
|
||||
const cursor = ctx.sessionManager.getLeafId();
|
||||
const pending: NonNullable<typeof clearCheckIn> = { generation, sessionFile };
|
||||
clearCheckIn = pending;
|
||||
const unconfirmed = () => { if (clearCheckIn === pending) { cancelCheckInRemoval(); ctx.ui.notify(schedulerMessages.unconfirmed, "warning"); } };
|
||||
try {
|
||||
// Passive custom messages are persisted without message_end. Observe only the notification;
|
||||
// public getBranch() supplies results, never the session file's bytes.
|
||||
pending.watcher = watch(sessionFile, { persistent: false }, (event) => {
|
||||
if (clearCheckIn !== pending) return;
|
||||
if (event === "rename" || pending.generation !== generation || sessionFile !== ctx.sessionManager.getSessionFile()) { unconfirmed(); return; }
|
||||
const branch = ctx.sessionManager.getBranch(), index = cursor === null ? -1 : branch.findIndex(entry => entry.id === cursor);
|
||||
if (cursor !== null && index < 0) { unconfirmed(); return; }
|
||||
for (const entry of branch.slice(index + 1)) if (entry.type === "custom_message") acceptScheduleList({ role: "custom", customType: entry.customType, details: entry.details }, ctx);
|
||||
});
|
||||
pending.watcher.on("error", unconfirmed);
|
||||
pending.startDeadline = () => {
|
||||
if (clearCheckIn !== pending || pending.deadline || agentRunActive) return;
|
||||
if (pending.generation !== generation || sessionFile !== ctx.sessionManager.getSessionFile()) { unconfirmed(); return; }
|
||||
pending.deadline = setTimeout(unconfirmed, 5_000); pending.deadline.unref();
|
||||
};
|
||||
// A passive result may wait for the busy turn's safe flush; do not expire it beforehand.
|
||||
if (!agentRunActive) pending.startDeadline();
|
||||
} catch { unconfirmed(); return false; }
|
||||
pi.sendUserMessage(`/${list} all`, { expandPromptTemplates: true, deliverAs: "followUp" });
|
||||
return true;
|
||||
}
|
||||
function acceptScheduleList(message: { role: string; customType?: string; details?: unknown }, ctx: ExtensionContext) {
|
||||
if (message.role !== "custom" || message.customType !== "scheduled-task" || !clearCheckIn) return;
|
||||
const details = message.details as { includeAll?: boolean; tasks?: CheckInTask[] } | undefined;
|
||||
if (!details?.includeAll || !Array.isArray(details.tasks)) return;
|
||||
const pending = clearCheckIn; cancelCheckInRemoval();
|
||||
if (pending.generation !== generation || pending.sessionFile !== ctx.sessionManager.getSessionFile()) return;
|
||||
const remove = schedulerCommand("schedule-remove");
|
||||
if (!remove) { ctx.ui.notify(schedulerMessages.unavailable, "warning"); return; }
|
||||
const matching = details.tasks.filter(task => task.name === `goals-${ctx.sessionManager.getSessionId()}`);
|
||||
if (matching.some(task => !ownsCheckIn(task, ctx))) ctx.ui.notify(schedulerMessages.foreign, "warning");
|
||||
for (const task of matching.filter(task => ownsCheckIn(task, ctx))) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(task.id) || details.tasks.some(other => other !== task && other.id.startsWith(task.id))) { ctx.ui.notify(schedulerMessages.invalid, "warning"); continue; }
|
||||
pi.sendUserMessage(`/${remove} ${task.id}`, { expandPromptTemplates: true, deliverAs: "followUp" });
|
||||
}
|
||||
}
|
||||
const notedPlanValue = (prefix: string) => {
|
||||
const snapshot = readPlan();
|
||||
if (snapshot.text === undefined) return null;
|
||||
@@ -162,30 +226,30 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
const items = goals(snapshot.text);
|
||||
// Pi/OpenAI: approval belongs to the reviewed requirements, not only the title.
|
||||
for (const subject of Object.keys(state.signoffs)) {
|
||||
const matches = items.filter((g) => key(g.subject) === subject);
|
||||
if (matches.length !== 1 || matches[0].status !== "done" || state.signoffs[subject].signature !== goalAcceptanceSignature(snapshot.text, subject)) { delete state.signoffs[subject]; save(); }
|
||||
}
|
||||
const accepted = items.filter((g) => g.status === "done" && state.signoffs[key(g.subject)]).length;
|
||||
const accepted = items.filter((g) => g.status === "done").length;
|
||||
ctx.ui.setStatus("goals", `👀 ${accepted}/${items.length} goals`);
|
||||
const mark = (status: GoalStatus) => status === "done" ? "✓" : status === "active" ? "◼" : status === "cancelled" ? "✗" : "◻";
|
||||
const priority: Record<GoalStatus, number> = { active: 0, open: 1, done: 2, cancelled: 3 };
|
||||
const mark = (status: GoalStatus) => status === "done" ? "✓" : status === "reported" ? "x" : status === "active" ? "◼" : status === "cancelled" ? "✗" : "◻";
|
||||
const priority: Record<GoalStatus, number> = { active: 0, reported: 1, open: 2, done: 3, cancelled: 4 };
|
||||
const sorted = [...items].sort((a, b) => priority[a.status] - priority[b.status]);
|
||||
const visible = sorted.slice(0, WIDGET_GOAL_LIMIT);
|
||||
const lines = visible.map((g) => `${mark(g.status)} G${items.indexOf(g) + 1}: ${g.subject}`);
|
||||
const hidden = sorted.slice(WIDGET_GOAL_LIMIT);
|
||||
let summary = "";
|
||||
if (hidden.length) {
|
||||
const counts = (["done", "active", "open", "cancelled"] as const).map(status => {
|
||||
const counts = (["done", "reported", "active", "open", "cancelled"] as const).map(status => {
|
||||
const count = hidden.filter(g => g.status === status).length;
|
||||
return count ? `${count} ${mark(status)}` : "";
|
||||
}).filter(Boolean);
|
||||
lines.push(`… ${counts.join(", ")}`);
|
||||
summary = `… ${counts.join(", ")}; `;
|
||||
}
|
||||
const planPath = relative(ctx.cwd, state.plan!);
|
||||
const external = isAbsolute(planPath) || planPath === ".." || planPath.startsWith(`..${sep}`);
|
||||
lines.unshift(external ? `${basename(state.plan!)} (external)` : planPath);
|
||||
ctx.ui.setWidget("goals", lines);
|
||||
lines.push(summary + (external ? `${basename(state.plan!)} (external)` : planPath));
|
||||
if (ctx.mode === "tui") ctx.ui.setWidget("goals", () => ({
|
||||
render: (width: number) => lines.map((line, index) => truncateToWidth(` ${line}`, width, external && index === lines.length - 1 ? "… (external)" : "…")),
|
||||
invalidate() {},
|
||||
}));
|
||||
else ctx.ui.setWidget("goals", lines);
|
||||
}
|
||||
function watchPlan(ctx: ExtensionContext) {
|
||||
pendingPlanNotice = undefined;
|
||||
@@ -198,7 +262,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (state.child || state.mode !== "supervising" || !state.plan) return;
|
||||
const stamp = generation;
|
||||
// Watch the directory so atomic plan replacement remains observable. This is an event hook:
|
||||
// plan-change reviews, not another scheduled loop (the hourly job is schedule_prompt's). A
|
||||
// plan-change reviews, not another scheduled loop (the hourly job is pi-scheduler's). A
|
||||
// short debounce coalesces bursts. The notification view excludes Log and worker identity;
|
||||
// requirement changes additionally request active-plan context.
|
||||
try {
|
||||
@@ -224,12 +288,14 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
} catch (error) { ctx.ui.notify(`Plan monitoring unavailable: ${String(error)}`, "error"); }
|
||||
}
|
||||
function restore(ctx: ExtensionContext) {
|
||||
notices.restore(ctx);
|
||||
cancelCheckInRemoval(); agentRunActive = false;
|
||||
notices.restore(ctx, [REVIEW]);
|
||||
generation++;
|
||||
state = initial();
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "custom" && entry.customType === STATE) state = structuredClone(entry.data as State);
|
||||
}
|
||||
delete (state as State & { signoffs?: unknown }).signoffs; // Retire old session metadata; never certify [x].
|
||||
notice = true;
|
||||
turnsStale = 0;
|
||||
lastWorkingSet = "";
|
||||
@@ -239,21 +305,28 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
refresh(ctx);
|
||||
watchPlan(ctx);
|
||||
}
|
||||
function send(content: string, triggerTurn = true) {
|
||||
function sendAttachment(plan: string, session: string, text: string) {
|
||||
pi.sendMessage({ customType: "pi-goals-supervision", content: workerAttachment(plan, session, text), display: true }, { triggerTurn: false });
|
||||
}
|
||||
function send(content: string, triggerTurn = true, collapse = false) {
|
||||
// sendMessage(triggerTurn:true) bypasses before_agent_start in Pi 0.85.1.
|
||||
// A normal saved prompt prepares the current role before starting the turn.
|
||||
if (triggerTurn) {
|
||||
const prompt = `[pi-goals]\n${content}`;
|
||||
notices.mirror(prompt);
|
||||
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
||||
} else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "nextTurn" });
|
||||
} else {
|
||||
if (collapse) notices.mirror(content);
|
||||
pi.sendMessage({ customType: "pi-goals-supervision", content, display: !collapse }, { deliverAs: "nextTurn" });
|
||||
}
|
||||
}
|
||||
async function confirmOwnership(ctx: ExtensionContext, target: string, text: string, solo = true): Promise<boolean> {
|
||||
if (opening || state.worker?.pending) { ctx.ui.notify("A worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; }
|
||||
async function confirmOwnership(ctx: ExtensionContext, target: string, text: string): Promise<boolean> {
|
||||
if (target !== state.plan || state.mode === "chat") { ctx.ui.notify(nativeMessages.externalOwnershipUnknown(target, state.worker), "warning"); return false; }
|
||||
if (opening) { ctx.ui.notify("A worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; }
|
||||
const stamp = generation;
|
||||
const revision = workerRevision;
|
||||
const confirmation = solo ? "Worker confirmed stopped" : "Previous supervisor confirmed stopped";
|
||||
const choice = await ctx.ui.select(solo ? "Confirm all other writers for the current and target plans are stopped (inspect Intercom and their native panes). A missing handle is not proof. Take over in this session?" : "Confirm no other supervisor owns this plan. Preserve any existing worker session and reconnect rather than starting another writer.", [confirmation, "Cancel"]);
|
||||
const confirmation = "Worker confirmed stopped";
|
||||
const choice = await ctx.ui.select("Confirm all other writers for the current and target plans are stopped (inspect Intercom and their native panes). A missing handle is not proof. Take over in this session?", [confirmation, "Cancel"]);
|
||||
if (stamp !== generation || revision !== workerRevision) return false;
|
||||
if (choice !== confirmation) return false;
|
||||
if (readFileSync(target, "utf8") !== text) { ctx.ui.notify("Plan changed during takeover; confirm again.", "warning"); return false; }
|
||||
@@ -264,7 +337,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${soloNotice(state.plan!)}`);
|
||||
}
|
||||
const help = "/goals new [initial idea] | edit | discuss | review | ready | status | stop | resume | solo | attach <plan.md> [solo] | model <model> | quit (exit/clear)\nOpenGoalWorker opens a native project pane; use Intercom to steer the verified worker session. Stop pauses work. Quit/exit/clear preserves the plan and clears goal state without a model call; worker processes are unchanged. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook.";
|
||||
const help = "/goals new [initial idea] | edit | discuss | review | ready | status | stop | resume | solo | attach <plan.md> [solo] | model <model> | quit (exit/clear)\nOpenGoalWorker opens a native project pane; use Intercom to steer the verified worker session. Stop pauses work. Quit/exit/clear preserves the plan and clears goal state without a model call; worker processes are unchanged. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_task check-in; plan-change reviews are the plan-watcher event hook.";
|
||||
async function ready(ctx: ExtensionContext, menu: boolean, edit = false) {
|
||||
if (state.mode !== "planning") { ctx.ui.notify("Ready applies to a draft; use status or resume.", "warning"); return; }
|
||||
const text = planText();
|
||||
@@ -289,147 +362,163 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
function identity(ctx: ExtensionContext): Peer {
|
||||
const sessionFile = ctx.sessionManager.getSessionFile() ?? "";
|
||||
let durable = false;
|
||||
try { durable = savedWorker(sessionFile).header.id === ctx.sessionManager.getSessionId(); } catch { /* A prospective path is not saved history. */ }
|
||||
return { sessionId: ctx.sessionManager.getSessionId(), sessionFile, leafId: ctx.sessionManager.getLeafId(), paneId: process.env.HERDR_PANE_ID ?? "", durable,
|
||||
empty: !ctx.sessionManager.getBranch().some(entry => entry.type === "message"), parentSession: ctx.sessionManager.getHeader()?.parentSession,
|
||||
plan: state.plan, parentId: state.parent?.intercomId, requestId: state.parent?.requestId, started: state.parent?.started, model: ctx.model ? ctx.model.provider + "/" + ctx.model.id : undefined };
|
||||
return { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile() ?? "",
|
||||
paneId: process.env.HERDR_PANE_ID ?? "", model: ctx.model ? ctx.model.provider + "/" + ctx.model.id : undefined };
|
||||
}
|
||||
const publish = (payload: unknown) => { channel?.publish(payload, { audience: "capable" }); };
|
||||
async function available() {
|
||||
const stamp = generation, ctx = liveContext, current = channel;
|
||||
if (!ctx || !current?.snapshot().connected || !current.snapshot().supported) return;
|
||||
const peers = await current.listSessions().catch(() => []);
|
||||
if (stamp !== generation || current !== channel) return;
|
||||
const self = peers.filter(peer => peer.pid === process.pid);
|
||||
if (self.length === 1 && pi.getCommands().some(command => command.name === CONTROL)) { ownIntercomId = self[0].id; publish({ type: "available", identity: identity(ctx) }); }
|
||||
const records = <T,>(ctx: ExtensionContext, type: string): T[] => ctx.sessionManager.getBranch().flatMap(entry => entry.type === "custom" && entry.customType === type ? [entry.data as T] : []);
|
||||
const pendingReports = (ctx: ExtensionContext) => {
|
||||
const reports = records<Report>(ctx, REPORT), reviews = records<ReportReview>(ctx, REVIEW);
|
||||
return reports.filter(report => !reviews.some(review => reviewedReportId(review) === report.id)
|
||||
&& !reports.some(newer => newer.session === report.session && newer.supersedes === report.id));
|
||||
};
|
||||
const reportLabel = (report: Report) => {
|
||||
const revision = report.id.split(":").at(-1)!.slice(0, 8);
|
||||
const plainTask = report.task?.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[*_`~<>]/g, "").trim().replace(/\s+/g, " ").slice(0, 100);
|
||||
return `revision ${revision}${plainTask ? ` — ${plainTask}` : ""} (reportId ${report.id})`;
|
||||
};
|
||||
function recordReport(ctx: ExtensionContext, report: Report, wake = true) {
|
||||
if (records<Report>(ctx, REPORT).some(saved => saved.id === report.id)) return;
|
||||
pi.appendEntry(REPORT, report);
|
||||
send(workerReview(report.plan, report.session, `${report.id}\n${report.text}`), false, true);
|
||||
if (wake && ctx.isIdle()) remindReports(ctx);
|
||||
}
|
||||
function probe() {
|
||||
if (state.mode === "supervising" && state.worker?.pending && state.worker.paneId) publish({ type: "inspect", paneId: state.worker.paneId, requestId: state.worker.pending.id });
|
||||
function recordWorkerEvent(ctx: ExtensionContext, event: WorkerEvent, wake = true) {
|
||||
if (REVIEWABLE_EVENTS.has(event.kind)) { recordReport(ctx, event, wake); return; }
|
||||
if (records<Report>(ctx, REPORT).some(saved => saved.id === event.id) || records<WorkerEvent>(ctx, WORKER_EVENT).some(saved => saved.id === event.id)) return;
|
||||
pi.appendEntry(WORKER_EVENT, event);
|
||||
send(workerStatus(event.plan, event.session, event.id, event.kind, event.text), false, true);
|
||||
}
|
||||
function cancelControl() {
|
||||
if (control) control.cancelled = true;
|
||||
if (state.worker?.pending) {
|
||||
try { publish({ type: "cancel", paneId: state.worker.paneId, requestId: state.worker.pending.id }); } catch { /* Local pause still takes effect when the peer is disconnected. */ }
|
||||
state.worker.requestId = state.worker.pending.previous?.requestId ?? state.worker.requestId;
|
||||
state.worker.pending = undefined;
|
||||
}
|
||||
function remindReports(ctx: ExtensionContext) {
|
||||
if (state.child || state.mode !== "supervising") return;
|
||||
const reports = pendingReports(ctx);
|
||||
const reportIds = reports.map(report => report.id);
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const sinceReminder = branch.slice(branch.map(entry => entry.type === "custom" ? entry.customType : "").lastIndexOf(REVIEW_REMINDER) + 1);
|
||||
if (!sinceReminder.some(entry => entry.type === "custom" && entry.customType === REPORT && reportIds.includes((entry.data as Report).id))) return;
|
||||
pi.appendEntry(REVIEW_REMINDER);
|
||||
send(pendingReportReviews(reports.map(reportLabel)));
|
||||
}
|
||||
pi.registerEntryRenderer(REVIEW, (entry, { expanded }, theme) => {
|
||||
const review = entry.data as ReportReview;
|
||||
if (expanded) return new Markdown(review.content, 0, 0, getMarkdownTheme());
|
||||
const revision = reviewedReportId(review)?.split(":").at(-1)?.slice(0, 8) ?? "unknown";
|
||||
return {
|
||||
render: (width) => [truncateToWidth(theme.fg("muted", `[pi-goals] Worker review: ${review.verdict} · revision ${revision} · ${keyHint("app.tools.expand", "expand")}`), width)],
|
||||
invalidate() {},
|
||||
};
|
||||
});
|
||||
function registerChannel(ctx: ExtensionContext) {
|
||||
liveContext = ctx;
|
||||
const registration: IntercomExtensionRegistration = {
|
||||
namespace: "pi-goals", ownerEligible: false,
|
||||
onReady: (value) => { channel = value; void available(); },
|
||||
onEvent: (event) => {
|
||||
if (event.type === "connection" && event.connected) { void available(); probe(); }
|
||||
if (event.type === "session_left" && event.sessionId === state.worker?.intercomId && state.mode === "supervising" && state.worker.pending?.phase !== "switch") send(workerReview(state.plan!, event.sessionId, nativeMessages.disconnected));
|
||||
onReady: value => { channel = value; },
|
||||
onEvent: event => {
|
||||
if (event.type === "session_left" && event.sessionId === state.worker?.intercomId && state.plan) {
|
||||
let entryId = "disconnected", text = nativeMessages.disconnected, kind: GoalEventKind = "unclassified";
|
||||
try {
|
||||
const branch = savedSession(state.worker.sessionFile!).getBranch();
|
||||
const run = branch.filter(entry => entry.type === "custom" && entry.customType === RUN).at(-1);
|
||||
const stop = branch.filter(entry => entry.type === "custom" && entry.customType === STOP).at(-1);
|
||||
const stopped = stop?.type === "custom" ? stop.data as WorkerStop : undefined;
|
||||
if (run && (!stopped || stopped.entryId !== run.id && !stopped.entryId.startsWith(`${run.id}:`))) { entryId = `${run.id}:disconnected`; kind = "blocker"; }
|
||||
else if (stopped) { entryId = stopped.entryId; text = stopped.text; kind = stopped.kind ?? "unclassified"; }
|
||||
else entryId = branch.filter(entry => entry.type === "message" && entry.message.role === "assistant").at(-1)?.id || entryId;
|
||||
} catch { /* Unknown history remains visible without inventing completion or review debt. */ }
|
||||
recordWorkerEvent(ctx, { id: `${event.sessionId}:${entryId}`, plan: state.plan, session: event.sessionId, sessionFile: state.worker.sessionFile || "", requestId: state.worker.requestId!, task: state.worker.task, text, kind });
|
||||
}
|
||||
if (event.type !== "message" || !event.payload || typeof event.payload !== "object") return;
|
||||
const data = event.payload as { type?: string; to?: string; requestId?: string; plan?: string; paneId?: string; sessionFile?: string; text?: string; identity?: Peer; request?: WorkerRequest; expected?: Peer };
|
||||
if (data.type === "inspect" && data.paneId && data.paneId === process.env.HERDR_PANE_ID && typeof data.requestId === "string") {
|
||||
publish({ type: "peer", to: event.fromSessionId, requestId: data.requestId, identity: identity(ctx) }); return;
|
||||
}
|
||||
if (data.type === "cancel" && data.paneId === process.env.HERDR_PANE_ID && control?.from === event.fromSessionId && data.requestId === control.request.id) { control.cancelled = true; return; }
|
||||
if (data.type === "control" && ownIntercomId && data.to === ownIntercomId && data.expected && data.expected.paneId === process.env.HERDR_PANE_ID && data.request && ["start", "fresh", "recover"].includes(data.request.action) && typeof data.request.id === "string" && typeof data.plan === "string") {
|
||||
if (control || !pi.getCommands().some(command => command.name === CONTROL)) return;
|
||||
control = { from: event.fromSessionId, request: data.request, plan: data.plan, expected: data.expected };
|
||||
pi.sendUserMessage("/" + CONTROL, { expandPromptTemplates: true, deliverAs: "followUp" }); return;
|
||||
}
|
||||
const worker = state.worker, pending = worker?.pending;
|
||||
if (state.child || !worker || !state.plan) return;
|
||||
if (data.type === "available" && data.identity?.paneId === worker.paneId && pending) { probe(); return; }
|
||||
if (data.type === "peer" && pending && data.identity && data.to === worker.parentId && data.requestId === pending.id && data.identity.paneId === worker.paneId && state.mode === "supervising") {
|
||||
const peer = data.identity;
|
||||
if (event.fromSessionId === worker.parentId) return;
|
||||
if (peer.parentId === worker.parentId && peer.requestId === pending.id && peer.started) { worker.intercomId = event.fromSessionId; worker.identity = peer; worker.sessionFile = peer.sessionFile; worker.requestId = pending.id; worker.pending = undefined; save(); send(workerReview(state.plan, event.fromSessionId, nativeMessages.actionApplied("observed without replay", peer))); return; }
|
||||
if (pending.phase === "control") return;
|
||||
if (pending.phase === "probe" && pending.action === "fresh" && (peer.sessionId !== pending.previous?.sessionId || peer.sessionFile !== pending.previous?.sessionFile)) { worker.pending = undefined; save(); send(workerReview(state.plan, event.fromSessionId, nativeMessages.controlChanged)); return; }
|
||||
if (pending.phase === "switch") {
|
||||
const arrived = pending.action === "fresh" ? peer.sessionId !== pending.previous?.sessionId && peer.parentSession === pending.previous?.sessionFile && peer.requestId === pending.id : peer.sessionId === pending.savedId && peer.sessionFile === pending.sessionFile;
|
||||
if (!arrived) return;
|
||||
const data = event.payload as { type?: string; to?: string; requestId?: string; plan?: string; sessionFile?: string; text?: string; identity?: Peer; entryId?: string; kind?: GoalEventKind; review?: ReportReview };
|
||||
if (data.type === "review" && state.child && state.parent && event.fromSessionId === state.parent.intercomId && records<State>(ctx, STATE).some(saved => saved.child && saved.parent?.intercomId === event.fromSessionId && saved.parent.requestId === data.requestId && saved.plan === data.plan) && data.sessionFile === ctx.sessionManager.getSessionFile() && data.review) {
|
||||
const prior = records<ReportReview>(ctx, REVIEW).find(saved => reviewedReportId(saved) === reviewedReportId(data.review!));
|
||||
const review = prior || data.review;
|
||||
if (!prior && review.verdict === "changes_requested" && (state.mode === "paused" || data.plan !== state.plan || data.requestId !== state.parent.requestId || !review.continuation.trim())) return;
|
||||
if (!prior) {
|
||||
pi.appendEntry(REVIEW, review);
|
||||
if (review.verdict === "changes_requested") {
|
||||
notices.hide(review.content);
|
||||
pi.sendUserMessage(review.content, { deliverAs: "followUp" });
|
||||
}
|
||||
}
|
||||
worker.intercomId = event.fromSessionId; worker.identity = peer; worker.sessionFile = peer.sessionFile;
|
||||
const phase = pending.phase; pending.phase = "control"; workerRevision++; save();
|
||||
publish({ type: "control", to: event.fromSessionId, plan: state.plan, expected: peer, request: { ...pending, action: phase === "switch" || pending.action === "recover" && peer.sessionId === pending.savedId ? "start" : pending.action } }); return;
|
||||
channel?.publish({ ...data, review, type: "review_saved", to: state.parent.intercomId }, { audience: "capable" });
|
||||
return;
|
||||
}
|
||||
if (!worker.parentId || !data.requestId || data.to !== worker.parentId || event.fromSessionId === data.to || (data.requestId !== worker.requestId && data.requestId !== pending?.id) || data.plan !== state.plan) return;
|
||||
if (data.type === "switching" && event.fromSessionId === worker.intercomId && pending && data.requestId === pending.id) { pending.phase = "switch"; save(); return; }
|
||||
if (data.type === "rejected" && event.fromSessionId === worker.intercomId && pending && data.requestId === pending.id) { worker.requestId = pending.previous?.requestId ?? worker.requestId; worker.pending = undefined; save(); send(workerReview(state.plan, event.fromSessionId, data.text ?? nativeMessages.controlRejected), state.mode === "supervising"); return; }
|
||||
if (data.type === "review_saved" && !state.child && data.review) {
|
||||
const draft = records<ReportReview>(ctx, REVIEW_DRAFT).find(review => review.id === data.review!.id);
|
||||
const report = records<Report>(ctx, REPORT).find(report => report.id === (draft && reviewedReportId(draft)));
|
||||
if (!draft || !report || event.fromSessionId !== report.session || data.requestId !== report.requestId || !records<State>(ctx, STATE).some(saved => saved.worker && saved.worker.parentId === data.to && saved.worker.requestId === report.requestId && saved.worker.intercomId === report.session) || data.plan !== report.plan) return;
|
||||
try {
|
||||
const saved = savedSession(report.sessionFile).getBranch().some(entry => entry.type === "custom" && entry.customType === REVIEW && JSON.stringify(entry.data) === JSON.stringify(draft));
|
||||
if (saved && !records<ReportReview>(ctx, REVIEW).some(review => review.id === draft.id)) pi.appendEntry(REVIEW, draft);
|
||||
} catch { ctx.ui.notify("Worker review delivery remains unverified; inspect its saved session and retry review_subagent.", "warning"); }
|
||||
return;
|
||||
}
|
||||
const worker = state.worker;
|
||||
if (state.child || !worker || !state.plan || !worker.parentId || !data.requestId || data.to !== worker.parentId || event.fromSessionId === data.to || data.requestId !== worker.requestId || data.plan !== state.plan) return;
|
||||
if (data.type === "attached" && typeof data.sessionFile === "string" && isAbsolute(data.sessionFile) && (!worker.intercomId || worker.intercomId === event.fromSessionId)) {
|
||||
if (worker.pending && data.requestId !== worker.pending.id) { send(workerReview(state.plan, event.fromSessionId, nativeMessages.attached(data.sessionFile)), false); return; }
|
||||
const action = worker.pending?.action;
|
||||
worker.requestId = data.requestId;
|
||||
worker.intercomId = event.fromSessionId; worker.sessionFile = data.sessionFile; if (data.identity) worker.identity = data.identity; worker.pending = undefined; workerRevision++; save();
|
||||
send(workerReview(state.plan, event.fromSessionId, action ? nativeMessages.actionApplied(action, data.identity) : nativeMessages.attached(data.sessionFile)), Boolean(action) && state.mode === "supervising");
|
||||
worker.intercomId = event.fromSessionId; worker.sessionFile = data.sessionFile;
|
||||
if (data.identity) worker.identity = data.identity;
|
||||
workerRevision++; save();
|
||||
sendAttachment(state.plan, event.fromSessionId, nativeMessages.attached(data.sessionFile));
|
||||
reconcileReports(ctx);
|
||||
}
|
||||
if (data.type === "stopped" && event.fromSessionId === worker.intercomId && typeof data.text === "string") {
|
||||
if (data.type === "stopped" && event.fromSessionId === worker.intercomId && typeof data.entryId === "string" && data.entryId && typeof data.text === "string") {
|
||||
const id = `${event.fromSessionId}:${data.entryId}`;
|
||||
if (records<Report>(ctx, REPORT).some(report => report.id === id) || !REVIEWABLE_EVENTS.has(data.kind ?? "unclassified") && records<WorkerEvent>(ctx, WORKER_EVENT).some(saved => saved.id === id)) return;
|
||||
if (data.identity) { worker.identity = data.identity; worker.sessionFile = data.identity.sessionFile; save(); }
|
||||
send(workerReview(state.plan, event.fromSessionId, data.text), state.mode === "supervising");
|
||||
const report: Report = { id, plan: state.plan, session: event.fromSessionId, sessionFile: worker.sessionFile!, requestId: data.requestId, task: worker.task, text: data.text, kind: data.kind ?? "unclassified" };
|
||||
recordWorkerEvent(ctx, report);
|
||||
}
|
||||
},
|
||||
};
|
||||
pi.events.emit(INTERCOM_EXTENSION_REGISTER_EVENT, registration);
|
||||
}
|
||||
pi.registerCommand(CONTROL, {
|
||||
description: nativeMessages.controlDescription,
|
||||
handler: async (_args, ctx) => {
|
||||
const operation = control;
|
||||
if (!operation) return; // Never execute arbitrary slash-command payloads.
|
||||
const { request, expected, from, plan } = operation;
|
||||
const controlKey = request.id + ":" + request.action;
|
||||
if (state.lastControl === controlKey) { control = undefined; return; }
|
||||
let authorized = false;
|
||||
const reject = (text: string) => publish({ type: "rejected", to: from, requestId: request.id, plan, text });
|
||||
function reconcileReports(ctx: ExtensionContext) {
|
||||
if (state.child) return;
|
||||
const owners = records<State>(ctx, STATE).filter(saved => saved.worker?.intercomId && saved.worker.sessionFile && saved.worker.requestId && saved.worker.parentId);
|
||||
for (const owner of new Map(owners.map(saved => [saved.worker!.requestId, saved])).values()) {
|
||||
const worker = owner.worker!;
|
||||
try {
|
||||
await ctx.waitForIdle();
|
||||
const current = identity(ctx);
|
||||
if (operation.cancelled || state.mode === "paused" && !(request.action === "start" && request.savedId === current.sessionId) || ctx.hasPendingMessages() || ctx.ui.getEditorText().length || current.sessionId !== expected.sessionId || current.leafId !== expected.leafId || current.sessionFile !== expected.sessionFile) throw new Error(nativeMessages.controlChanged);
|
||||
if (!isAbsolute(plan) || !goals(readFileSync(plan, "utf8")).length) throw new Error(messages.invalidAttachment);
|
||||
const peers = await channel?.listSessions();
|
||||
const self = peers?.find(peer => peer.pid === process.pid);
|
||||
if (!self || from === self.id || !peers?.some(peer => peer.id === from)) throw new Error(nativeMessages.parentUnavailable);
|
||||
if (operation.cancelled || ctx.hasPendingMessages() || ctx.ui.getEditorText().length || identity(ctx).leafId !== expected.leafId) throw new Error(nativeMessages.controlChanged);
|
||||
if (state.child && state.parent?.intercomId !== from || !state.child && (state.mode !== "chat" || !current.empty)) throw new Error(nativeMessages.notOwned);
|
||||
authorized = true;
|
||||
if (request.action === "start") {
|
||||
const recovering = request.savedId === current.sessionId;
|
||||
if (!recovering && (!current.empty || state.parent?.started)) throw new Error(nativeMessages.controlChanged);
|
||||
if (request.model) throw new Error(nativeMessages.modelRaceBoundary);
|
||||
state = { ...(recovering ? state : initial()), mode: recovering ? state.mode : "solo", child: true, plan, lastControl: controlKey, parent: { intercomId: from, requestId: request.id, selfId: self.id, started: true } };
|
||||
generation++; notice = true; fullPlanContextDue = true; save();
|
||||
publish({ type: "attached", to: from, requestId: request.id, plan, sessionFile: ctx.sessionManager.getSessionFile(), identity: identity(ctx) });
|
||||
if (!recovering && request.task) pi.sendUserMessage(workerAssignment(plan, from, request.id, request.task));
|
||||
} else {
|
||||
if (!current.empty && (!current.durable || request.reviewedThrough !== current.leafId)) throw new Error(nativeMessages.reviewRequired);
|
||||
if (request.action === "fresh" && (!state.child || !current.durable)) throw new Error(nativeMessages.notDurable);
|
||||
if (request.action === "recover") {
|
||||
if (!request.writersStopped || !request.sessionFile || !request.savedIntercom) throw new Error(nativeMessages.stopRequired);
|
||||
const saved = savedWorker(request.sessionFile);
|
||||
if (saved.digest !== request.savedDigest || saved.header.id !== request.savedId || saved.header.cwd !== ctx.cwd || saved.state.parent?.intercomId !== from || peers.some(peer => peer.id === request.savedIntercom && peer.id !== self.id)) throw new Error(nativeMessages.controlChanged);
|
||||
}
|
||||
publish({ type: "switching", to: from, requestId: request.id, plan });
|
||||
replacing = request.action === "fresh" ? "new" : "resume";
|
||||
const switched = request.action === "fresh"
|
||||
? await ctx.newSession({ parentSession: current.sessionFile, setup: async manager => { manager.appendCustomEntry(STATE, { ...initial(), mode: "solo", child: true, plan, parent: { intercomId: from, requestId: request.id } }); } })
|
||||
: await ctx.switchSession(request.sessionFile!);
|
||||
if (switched.cancelled) { replacing = undefined; state.lastControl = controlKey; save(); reject(nativeMessages.controlCancelled); }
|
||||
for (const entry of savedSession(worker.sessionFile!).getBranch()) {
|
||||
if (entry.type !== "custom" || ![STOP, WORKER_EVENT].includes(entry.customType)) continue;
|
||||
const stopped = entry.data as WorkerStop;
|
||||
if (stopped.to !== worker.parentId || stopped.requestId !== worker.requestId || stopped.plan !== owner.plan) continue;
|
||||
recordWorkerEvent(ctx, { id: `${worker.intercomId}:${stopped.entryId}`, plan: stopped.plan, session: worker.intercomId!, sessionFile: worker.sessionFile!, requestId: stopped.requestId, task: worker.task, text: stopped.text, kind: stopped.kind ?? "unclassified" }, false);
|
||||
}
|
||||
} catch (error) { if (authorized) { state.lastControl = controlKey; save(); } reject(String(error)); }
|
||||
finally { replacing = undefined; control = undefined; }
|
||||
},
|
||||
});
|
||||
const reportStop = (text: string) => {
|
||||
if (!state.child || !state.parent) return;
|
||||
} catch { /* Unavailable saved history is not evidence of a stopped worker. */ }
|
||||
}
|
||||
if (ctx.isIdle()) remindReports(ctx);
|
||||
}
|
||||
const reportStop = (text: string, kind: GoalEventKind, automatic = false) => {
|
||||
if (!state.child || !state.parent || !state.plan || !liveContext) return;
|
||||
const branch = liveContext.sessionManager.getBranch();
|
||||
const runId = branch.filter(entry => entry.type === "custom" && entry.customType === RUN).at(-1)?.id
|
||||
|| branch.filter(entry => entry.type === "message" && entry.message.role === "assistant").at(-1)?.id;
|
||||
if (!runId) return;
|
||||
const inRun = (saved: WorkerStop) => saved.requestId === state.parent?.requestId && saved.plan === state.plan && (saved.entryId === runId || saved.entryId?.startsWith(`${runId}:`));
|
||||
if (automatic && kind === "unclassified") {
|
||||
const ended = records<WorkerStop>(liveContext, STOP).filter(inRun).at(-1);
|
||||
if (ended) return ended;
|
||||
const status = records<WorkerStop>(liveContext, WORKER_EVENT).filter(inRun).at(-1);
|
||||
if (status) { pi.appendEntry(STOP, status); return status; } // Finish this run without another status or wake.
|
||||
}
|
||||
const type = automatic || REVIEWABLE_EVENTS.has(kind) ? STOP : WORKER_EVENT;
|
||||
const entryId = `${runId}:${digest(`${state.parent.requestId}:${state.plan}:${kind}:${text}`)}`;
|
||||
let stopped = records<WorkerStop>(liveContext, type).find(saved => saved.entryId === entryId);
|
||||
if (!stopped) {
|
||||
stopped = { type: "stopped", to: state.parent.intercomId, requestId: state.parent.requestId, plan: state.plan, text: Buffer.from(text).subarray(0, 6000).toString("utf8"), entryId, identity: identity(liveContext), kind };
|
||||
pi.appendEntry(type, stopped);
|
||||
}
|
||||
try {
|
||||
if (!channel?.snapshot().connected) throw new Error("disconnected");
|
||||
channel.publish({ type: "stopped", to: state.parent.intercomId, requestId: state.parent.requestId, plan: state.plan, text, identity: liveContext ? identity(liveContext) : undefined }, { audience: "capable" });
|
||||
channel.publish(stopped, { audience: "capable" });
|
||||
} catch { send(nativeMessages.reportUnavailable, false); }
|
||||
return stopped;
|
||||
};
|
||||
pi.on("session_start", (_e, ctx) => {
|
||||
restore(ctx); registerChannel(ctx);
|
||||
restore(ctx); registerChannel(ctx); reconcileReports(ctx);
|
||||
});
|
||||
pi.on("session_tree", (_e, ctx) => restore(ctx));
|
||||
pi.on("session_shutdown", (event) => { if (!replacing || event?.reason !== replacing) reportStop(nativeMessages.shuttingDown); channel = undefined; liveContext = undefined; ownIntercomId = undefined; generation++; finalReviewTurnDigest = undefined; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; });
|
||||
pi.on("session_shutdown", () => { reportStop(nativeMessages.shuttingDown, "unclassified", true); cancelCheckInRemoval(); agentRunActive = false; pauseCheckIn = false; channel = undefined; liveContext = undefined; generation++; finalReviewTurnDigest = undefined; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; });
|
||||
// Only successful compaction needs resync; failed/cancelled attempts leave pending context alone.
|
||||
// Defer to prompt preparation: same-run continuation retains Pi's current role/context.
|
||||
pi.on("session_compact", () => { notice = true; fullPlanContextDue = true; });
|
||||
@@ -449,7 +538,8 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
}
|
||||
});
|
||||
// Queued follow-ups can be consumed inside the same run, without before_agent_start.
|
||||
pi.on("message_end", (event) => {
|
||||
pi.on("message_end", (event, ctx) => {
|
||||
if (event.message.role === "custom" && event.message.customType === "intercom_message") reconcileReports(ctx);
|
||||
if (event.message.role !== "user") return;
|
||||
const content = typeof event.message.content === "string" ? event.message.content : event.message.content.filter(part => part.type === "text").map(part => part.text).join("\n");
|
||||
if (pendingPlanNotice && content === `[pi-goals]\n${pendingPlanNotice}`) pendingPlanNotice = undefined;
|
||||
@@ -460,11 +550,21 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
});
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
const last = event.messages.filter(message => message.role === "assistant").at(-1);
|
||||
reportStop(last?.role === "assistant" ? last.errorMessage || last.content.filter(part => part.type === "text").map(part => part.text).join("\n") || last.stopReason : nativeMessages.noAssistant);
|
||||
const text = last?.role === "assistant" ? last.errorMessage || last.content.filter(part => part.type === "text").map(part => part.text).join("\n") || last.stopReason : nativeMessages.noAssistant;
|
||||
reportStop(text, last?.role === "assistant" && last.stopReason === "aborted" ? "aborted" : last?.role === "assistant" && (last.stopReason === "error" || last.errorMessage) ? "blocker" : "unclassified", true);
|
||||
finalReviewTurnDigest = undefined; refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); });
|
||||
let proposedDraft = "";
|
||||
let proposing = false;
|
||||
pi.on("agent_start", (_event, ctx) => {
|
||||
agentRunActive = true;
|
||||
if (state.child && state.parent) pi.appendEntry(RUN, { plan: state.plan, parent: state.parent, session: identity(ctx) });
|
||||
if (clearCheckIn) { clearTimeout(clearCheckIn.deadline); clearCheckIn.deadline = undefined; }
|
||||
});
|
||||
pi.on("agent_settled", async (_e, ctx) => {
|
||||
agentRunActive = false;
|
||||
clearCheckIn?.startDeadline?.();
|
||||
reconcileReports(ctx);
|
||||
remindReports(ctx);
|
||||
if (state.child || state.mode !== "planning" || !ctx.hasUI || proposing) return;
|
||||
const text = planText();
|
||||
const version = `${state.plan}:${digest(text)}`;
|
||||
@@ -506,9 +606,36 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
fullPlanContextDue = false;
|
||||
lastWorkingSet = foldPlan(snapshot.text);
|
||||
pendingUpkeep = undefined;
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(message ? { message } : {}) };
|
||||
const pending = !state.child && state.mode === "supervising" ? pendingReports(ctx) : [];
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${role}${pending.length ? `\n${pendingReportReviews(pending.map(reportLabel))}` : ""}`, ...(message ? { message } : {}) };
|
||||
});
|
||||
pi.on("tool_call", (event) => {
|
||||
pi.on("input", (event, ctx) => {
|
||||
if (event.source !== "extension") { pauseCheckIn = false; return; }
|
||||
const wake = /^\[Scheduled task ([a-zA-Z0-9_-]+) fired\]\nName: ([^\n]+)\nAction: prompt\n/.exec(event.text);
|
||||
if (!wake || wake[2] !== `goals-${ctx.sessionManager.getSessionId()}` || !ownedCheckInIds(ctx).has(wake[1])) return;
|
||||
const snapshot = readPlan();
|
||||
if (state.mode !== "supervising" || snapshot.text !== undefined && !unfinishedGoals(snapshot.text)) return { action: "handled" as const };
|
||||
});
|
||||
pi.on("tool_result", (event, ctx) => {
|
||||
if (event.toolName !== "manage_scheduled_task" || event.isError) return;
|
||||
const task = (event.details as { task?: CheckInTask } | undefined)?.task;
|
||||
if (!task || !ownsCheckIn(task, ctx)) return;
|
||||
if (pauseCheckIn && state.mode === "paused" && event.input.action === "disable" && task.disabledAt) {
|
||||
state.pausedCheckIns = { ...state.pausedCheckIns, [task.id]: task.disabledAt }; pauseCheckIn = false; save();
|
||||
} else if (["enable", "remove"].includes(String(event.input.action)) && state.pausedCheckIns?.[task.id]) {
|
||||
delete state.pausedCheckIns[task.id]; save();
|
||||
}
|
||||
});
|
||||
pi.on("tool_call", (event, ctx) => {
|
||||
if (["schedule_task", "manage_scheduled_task"].includes(event.toolName)) {
|
||||
const input = event.input as Record<string, unknown>;
|
||||
const ids = ownedCheckInIds(ctx);
|
||||
const ownedPrefix = typeof input.id === "string" && [...ids].some(id => id.startsWith(input.id as string));
|
||||
if (ownedPrefix && !ids.has(input.id as string)) return { block: true, reason: schedulerMessages.invalid };
|
||||
const owned = input.name === `goals-${ctx.sessionManager.getSessionId()}` || typeof input.id === "string" && ids.has(input.id);
|
||||
if (event.toolName === "schedule_task" && owned && (state.child || state.mode !== "supervising" || !unfinishedGoals(planText()) || input.action !== "prompt" || input.type !== "interval" || input.scope !== "session")) return { block: true, reason: schedulerMessages.creation };
|
||||
if (owned && typeof input.prompt === "string" && input.prompt !== input.prompt.trim().replace(/\s+/g, " ")) return { block: true, reason: schedulerMessages.format };
|
||||
}
|
||||
if (event.toolName !== "subagent" && event.toolName !== "OpenGoalWorker") return;
|
||||
if (state.child || ["planning", "paused", "solo"].includes(state.mode)) return { block: true, reason: goalToolBlocked(state.child ? "worker" : state.mode) };
|
||||
});
|
||||
@@ -519,7 +646,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
if (state.child) {
|
||||
if (["stop", "resume"].includes(args.trim())) { cancelControl(); state.mode = args.trim() === "stop" ? "paused" : "solo"; generation++; save(); ctx.ui.notify(nativeMessages.workerPause(state.mode === "paused"), "info"); return; }
|
||||
if (["stop", "resume"].includes(args.trim())) { cancelCheckInRemoval(); state.mode = args.trim() === "stop" ? "paused" : "solo"; generation++; save(); ctx.ui.notify(nativeMessages.workerPause(state.mode === "paused"), "info"); return; }
|
||||
ctx.ui.notify("This is the delegated worker. Goal approval belongs to its parent.", "info"); return;
|
||||
}
|
||||
let command = args.trim();
|
||||
@@ -557,11 +684,14 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
ctx.ui.notify([
|
||||
`Mode: ${state.mode}`,
|
||||
`Plan: ${state.plan ?? "none"}`,
|
||||
`Preferred worker model (plan): ${notedPlanValue("preferred worker model") ?? "not stated; use /goals model <model>"}`,
|
||||
`Preferred worker model (plan, not configuration): ${notedPlanValue("preferred worker model") ?? "inherit"}`,
|
||||
`Last observed worker model: ${state.worker?.identity?.model ?? "unconfirmed"}; verify current choice before claiming configuration.`,
|
||||
`Pending worker revision reviews: ${pendingReports(ctx).map(reportLabel).join("; ") || "none"}`,
|
||||
`Latest worker status event: ${records<WorkerEvent>(ctx, WORKER_EVENT).at(-1)?.kind ?? "none"}`,
|
||||
`Recorded worker session: ${state.worker?.sessionFile ?? "not recorded"}`,
|
||||
`Worker Intercom: ${state.worker?.intercomId ?? "unconfirmed"}; native pane: ${state.worker?.paneId ?? "unconfirmed"}`,
|
||||
`Worker Intercom: ${state.worker?.intercomId ?? "unconfirmed"}; native pane: ${state.worker?.identity?.paneId || state.worker?.paneId || "unconfirmed"}`,
|
||||
notedPlanValue("worker session") ? `Worker session noted in plan: ${notedPlanValue("worker session")}` : "",
|
||||
`Hourly check-in: schedule_prompt job ${JSON.stringify(`goals-${ctx.sessionManager.getSessionId()}`)} (list/remove via schedule_prompt; plan-change reviews are the plan-watcher event hook)`,
|
||||
`Check-in: session-scoped pi-scheduler task ${JSON.stringify(`goals-${ctx.sessionManager.getSessionId()}`)} (default 1h; /schedules all shows current recurrence; manage_scheduled_task updates it)`,
|
||||
"Inspect the exact Intercom session and native pane; a binding or idle status is not completion.",
|
||||
].filter(Boolean).join("\n"), "info");
|
||||
return;
|
||||
@@ -577,14 +707,15 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
const ref = command.slice("model".length).trim();
|
||||
if (!ref) { ctx.ui.notify("Use /goals model <provider/model>; no preference changed.", "info"); return; }
|
||||
const lines = planText().split("\n");
|
||||
const pref = `- preferred worker model: ${ref || "(none specified)"}`;
|
||||
const pref = `- preferred worker model: ${ref}`;
|
||||
const found = lines.findIndex((line) => /^-\s*preferred worker model:/i.test(line));
|
||||
if (found >= 0) lines[found] = pref;
|
||||
else { const title = lines.findIndex((line) => /^#\s/.test(line)); lines.splice(title >= 0 ? title + 1 : 0, 0, pref); }
|
||||
writeFileSync(state.plan, lines.join("\n"));
|
||||
planHash = digest(planViews(planText()).notify);
|
||||
refresh(ctx);
|
||||
ctx.ui.notify(ref ? `Preferred worker model set to ${ref} in plan preferences. project.open has no model override; choose /model in the native worker pane and verify its resolved model.` : "Preferred worker model cleared.", "info");
|
||||
notice = true; fullPlanContextDue = true;
|
||||
ctx.ui.notify(`Preferred worker model recorded as ${ref}; not yet configured. Pass it to the agent in its assignment or live steering for configuration through supported controls, then verify its actual model.`, "info");
|
||||
return;
|
||||
}
|
||||
if (command === "attach" || command.startsWith("attach ")) {
|
||||
@@ -597,43 +728,35 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
let text: string;
|
||||
try { text = readFileSync(target, "utf8"); } catch { ctx.ui.notify(`Cannot read plan at ${target}.`, "error"); return; }
|
||||
if (!goals(text).length || goals(text).some(g => !g.subject)) { ctx.ui.notify(`${target} has no '- [ ] goal:' lines with valid subjects; attach a judgeable plan.`, "warning"); return; }
|
||||
if (!solo && ((state.worker && !state.workerStopped) || state.mode === "supervising")) { ctx.ui.notify("Exit and resolve the existing worker before replacing the plan. The current plan is preserved.", "warning"); return; }
|
||||
if (!solo && state.plan === target && state.mode !== "chat") {
|
||||
notice = true; fullPlanContextDue = true; refresh(ctx);
|
||||
ctx.ui.notify(nativeMessages.samePlanRestored, "info"); return;
|
||||
}
|
||||
const noted = /^-\s*worker session:\s*(\S+)/im.exec(foldPlan(text))?.[1];
|
||||
if (!(await confirmOwnership(ctx, target, text, solo))) return;
|
||||
const retained = target === state.plan ? state.signoffs : {};
|
||||
const worker = noted ? { sessionFile: resolve(ctx.cwd, noted) } : state.workerStopped ? state.worker : undefined;
|
||||
state = { mode: solo ? "solo" : "planning", plan: target, signoffs: retained, worker, workerStopped: solo || (!noted && state.workerStopped) };
|
||||
generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
if (solo) enterSolo(ctx);
|
||||
else send(attachNotice(target, false, noted));
|
||||
if (!(await confirmOwnership(ctx, target, text))) return;
|
||||
if (noted && !state.worker) state.worker = { sessionFile: resolve(ctx.cwd, noted) };
|
||||
enterSolo(ctx);
|
||||
return;
|
||||
}
|
||||
if (command === "exit") {
|
||||
cancelControl();
|
||||
const storage = new CronStorage(ctx.cwd);
|
||||
const session = ctx.sessionManager.getSessionId();
|
||||
const matching = storage.getAllJobs().filter(j => j.name === `goals-${session}`);
|
||||
const skipped = matching.filter(j => j.session !== session);
|
||||
if (skipped.length) ctx.ui.notify(`Goal check-ins left unchanged (session binding missing or different): ${skipped.map(j => j.id).join(", ")}. Inspect /schedule-prompt.`, "warning");
|
||||
for (const job of matching.filter(j => j.session === session)) {
|
||||
storage.removeJob(job.id); // Scheduler re-reads storage before firing; removed jobs cannot prompt.
|
||||
pi.events.emit("cron:change", { type: "remove", jobId: job.id });
|
||||
}
|
||||
cancelCheckInRemoval();
|
||||
state = initial(); generation++; workerRevision++; pendingUpkeep = undefined; notice = true;
|
||||
save(); refresh(ctx); watchPlan(ctx);
|
||||
ctx.ui.notify("Goals cleared; original plan file unchanged.", "info");
|
||||
const queued = requestCheckInRemoval(ctx);
|
||||
ctx.ui.notify(queued ? schedulerMessages.queued : schedulerMessages.cleared, queued ? "info" : "warning");
|
||||
return;
|
||||
}
|
||||
if (command === "stop") {
|
||||
if (state.mode === "planning") { ctx.ui.notify("A draft cannot pause; use /goals quit to clear goal state and preserve the draft.", "warning"); return; }
|
||||
if (state.mode !== "solo" && state.mode !== "supervising") return;
|
||||
cancelControl();
|
||||
cancelCheckInRemoval();
|
||||
state.pausedFrom = state.mode;
|
||||
state.mode = "paused"; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
pauseCheckIn = true;
|
||||
const pause = pauseExitNotice(state.worker, false);
|
||||
const requestCleanup = Boolean(state.worker) || hasScheduleTool();
|
||||
if (!requestCleanup) ctx.ui.notify(pause, "info"); // Visible now; passive model context waits for a prompt.
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pause}`, requestCleanup);
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId(), true)}\n\n${pause}`, requestCleanup);
|
||||
return;
|
||||
}
|
||||
if (command === "resume") {
|
||||
@@ -665,46 +788,33 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
version++;
|
||||
}
|
||||
}
|
||||
state = { mode: "planning", plan: path, signoffs: {}, worker: state.worker, workerStopped: state.workerStopped }; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
state = { mode: "planning", plan: path, worker: state.worker, workerStopped: state.workerStopped }; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(planningSeed(objective, path));
|
||||
} catch (error) { ctx.ui.notify(String(error), "error"); }
|
||||
},
|
||||
});
|
||||
pi.registerTool({
|
||||
name: "OpenGoalWorker", label: "Open native goal worker", description: nativeMessages.openDescription,
|
||||
parameters: Type.Object({ task: Type.Optional(Type.String()), action: Type.Optional(Type.Union([Type.Literal("start"), Type.Literal("fresh"), Type.Literal("recover")])), model: Type.Optional(Type.String()), reviewedThrough: Type.Optional(Type.String()), writersStopped: Type.Optional(Type.Boolean()), sessionFile: Type.Optional(Type.String()) }),
|
||||
parameters: Type.Object({ task: Type.String({ minLength: 1 }), model: Type.Optional(Type.String({ description: nativeMessages.modelDescription })) }),
|
||||
async execute(_id, params, signal, _update, ctx) {
|
||||
if (state.child || state.mode !== "supervising" || !state.plan) return result(goalToolBlocked(state.mode));
|
||||
const action = params.action ?? "start";
|
||||
if (opening || state.worker?.pending || action === "start" && state.worker) return result(nativeMessages.alreadyRecorded);
|
||||
const preference = action === "recover" ? null : notedPlanValue("preferred worker model");
|
||||
if (params.model || preference && (preference.includes("/") || !/^(?:none|\(none|default|inherit|not stated)\b/i.test(preference))) return result(nativeMessages.modelRaceBoundary);
|
||||
if (action !== "recover" && !params.task) return result(nativeMessages.taskRequired);
|
||||
if (action === "fresh" && (!state.worker?.identity || !params.reviewedThrough)) return result(nativeMessages.reviewRequired);
|
||||
if (opening || state.worker) return result(nativeMessages.alreadyRecorded);
|
||||
if (!params.task.trim()) return result(nativeMessages.taskRequired);
|
||||
const preference = params.model?.trim() || notedPlanValue("preferred worker model");
|
||||
const model = preference && (preference.includes("/") || !/^(?:none|\(none|default|inherit|not stated)\b/i.test(preference)) ? preference : undefined;
|
||||
if (!channel?.snapshot().connected || !channel.snapshot().supported) return result(nativeMessages.intercomNotReady);
|
||||
const stamp = generation, plan = state.plan;
|
||||
const peers = await channel.listSessions().catch(() => undefined);
|
||||
if (!peers) return result(nativeMessages.intercomNotReady);
|
||||
const self = peers.filter(peer => peer.pid === process.pid);
|
||||
if (self.length !== 1) return result(nativeMessages.noIdentity);
|
||||
if (stamp !== generation || opening || signal?.aborted) return result(messages.cancelled);
|
||||
if (stamp !== generation || opening || state.worker || signal?.aborted) return result(messages.cancelled);
|
||||
const requestId = randomUUID();
|
||||
const request: WorkerRequest = { id: requestId, action, task: params.task, model: params.model, reviewedThrough: params.reviewedThrough, writersStopped: params.writersStopped, phase: "probe", previous: state.worker?.identity };
|
||||
if (action === "recover") {
|
||||
if (!params.writersStopped || params.model || params.task) return result(nativeMessages.stopRequired);
|
||||
try {
|
||||
request.sessionFile = params.sessionFile || state.worker?.sessionFile;
|
||||
if (!request.sessionFile || !isAbsolute(request.sessionFile)) return result(nativeMessages.notDurable);
|
||||
const saved = savedWorker(request.sessionFile);
|
||||
request.savedId = saved.header.id; request.savedDigest = saved.digest;
|
||||
request.savedIntercom = saved.state.parent?.selfId ?? (request.sessionFile === state.worker?.sessionFile ? state.worker.intercomId : undefined);
|
||||
if (saved.header.cwd !== ctx.cwd || saved.state.plan !== plan || saved.state.parent?.intercomId !== self[0].id || !request.savedIntercom) return result(nativeMessages.notOwned);
|
||||
} catch (error) { return result(String(error)); }
|
||||
}
|
||||
state.worker = { ...state.worker, requestId: state.worker?.requestId ?? requestId, parentId: self[0].id, pending: request }; state.workerStopped = false; workerRevision++; opening = true; save();
|
||||
state.worker = { requestId, parentId: self[0].id, task: params.task }; state.workerStopped = false; workerRevision++; opening = true; save();
|
||||
try {
|
||||
const pane = await openProjectPane({ cwd: ctx.cwd, focus: false, signal }); // No prompt before verified capability/model selection.
|
||||
if (pane.ok && state.plan === plan && state.worker?.pending?.id === requestId) { state.worker.paneId = pane.data.binding.paneId; save(); probe(); }
|
||||
// Stock open sends startup only to a newly created context; existing panes receive nothing.
|
||||
const pane = await openProjectPane({ cwd: ctx.cwd, message: workerAssignment(plan, self[0].id, requestId, params.task, model), focus: false, signal });
|
||||
if (pane.ok && state.plan === plan && state.worker?.requestId === requestId) { state.worker.paneId = pane.data.binding.paneId; save(); }
|
||||
return result(pane.ok ? JSON.stringify({ disposition: pane.data.disposition, paneId: pane.data.binding.paneId, projectRoot: pane.data.binding.projectRoot, bindingPath: pane.data.bindingPath }) + nativeMessages.openReceipt : JSON.stringify(pane));
|
||||
} catch (error) { return result(nativeMessages.openFailed + String(error)); }
|
||||
finally { opening = false; }
|
||||
@@ -713,28 +823,101 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "AttachGoalPlan", label: "Attach delegated plan", description: attachGoalPlanDescription,
|
||||
parameters: Type.Object({ path: Type.String(), parent: Type.Optional(Type.String()), requestId: Type.Optional(Type.String()) }),
|
||||
async execute(_id, params, _signal, _update, ctx) {
|
||||
async execute(_id, params, signal, _update, ctx) {
|
||||
if (!state.child && (state.mode !== "chat" || !params.parent || !params.requestId)) return result(messages.childAttachOnly);
|
||||
if (state.child && state.plan && state.plan !== params.path) return result(messages.invalidAttachment);
|
||||
const authorize = !state.child || state.plan && state.plan !== params.path || params.parent !== undefined || params.requestId !== undefined;
|
||||
if (state.child && authorize && (!state.parent || params.parent !== state.parent.intercomId || !params.requestId?.trim())) return result(nativeMessages.reattachAuthorization);
|
||||
let text: string;
|
||||
try {
|
||||
if (!isAbsolute(params.path)) return result(messages.invalidAttachment);
|
||||
const items = goals(readFileSync(params.path, "utf8"));
|
||||
text = readFileSync(params.path, "utf8");
|
||||
const items = goals(text);
|
||||
if (!items.length || items.some(g => !g.subject)) return result(messages.invalidAttachment);
|
||||
} catch { return result(messages.invalidAttachment); }
|
||||
if (!state.child) {
|
||||
if (!channel?.snapshot().connected) return result(nativeMessages.intercomNotReady);
|
||||
if (authorize) {
|
||||
if (!channel?.snapshot().connected || !channel.snapshot().supported) return result(nativeMessages.intercomNotReady);
|
||||
const stamp = generation;
|
||||
const peers = await channel.listSessions().catch(() => undefined);
|
||||
if (!peers) return result(nativeMessages.intercomNotReady);
|
||||
if (stamp !== generation) return result(messages.cancelled);
|
||||
if (!peers?.some(peer => peer.id === params.parent && peer.pid !== process.pid)) return result(nativeMessages.parentUnavailable);
|
||||
state = { ...initial(), child: true, mode: "solo", parent: { intercomId: params.parent!, requestId: params.requestId!, selfId: peers.find(peer => peer.pid === process.pid)?.id, started: true } };
|
||||
if (!peers || !channel?.snapshot().connected || !channel.snapshot().supported) return result(nativeMessages.intercomNotReady);
|
||||
if (stamp !== generation || signal?.aborted) return result(messages.cancelled);
|
||||
if (!peers.some(peer => peer.id === params.parent && peer.pid !== process.pid)) return result(nativeMessages.parentUnavailable);
|
||||
try { if (readFileSync(params.path, "utf8") !== text) return result(messages.invalidAttachment); } catch { return result(messages.invalidAttachment); }
|
||||
if (!state.child) {
|
||||
state = { ...initial(), child: true, mode: "solo" };
|
||||
// First attachment can occur after agent_start in an ordinary chat.
|
||||
if (agentRunActive) pi.appendEntry(RUN, { plan: params.path, parent: { intercomId: params.parent, requestId: params.requestId }, session: identity(ctx) });
|
||||
}
|
||||
state.parent = { intercomId: params.parent!, requestId: params.requestId! };
|
||||
}
|
||||
state.plan = params.path; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx);
|
||||
if (state.parent) channel?.publish({ type: "attached", to: state.parent.intercomId, requestId: state.parent.requestId, plan: state.plan, sessionFile: ctx.sessionManager.getSessionFile(), identity: identity(ctx) }, { audience: "capable" });
|
||||
return result(childPlanAttached(params.path));
|
||||
},
|
||||
});
|
||||
pi.registerTool({
|
||||
name: "ReportGoalEvent", label: "Report delegated worker event", description: reportGoalEventDescription,
|
||||
parameters: Type.Object({
|
||||
kind: Type.String({ enum: ["review_request", "decision", "blocker", "completion", "progress", "running", "waiting", "receipt", "no_change"] }),
|
||||
summary: Type.String({ minLength: 1 }),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
if (!state.child || !state.parent || !state.plan || !liveContext) throw new Error("Only an attached delegated worker can report a goal event.");
|
||||
const kind = params.kind as GoalEventKind;
|
||||
if (!params.summary.trim()) throw new Error("Supply the canonical event summary and exact artifact paths when applicable.");
|
||||
const stopped = reportStop(params.summary.trim(), kind);
|
||||
if (!stopped) throw new Error("No active worker run is available for this event.");
|
||||
return result(REVIEWABLE_EVENTS.has(kind) ? "Recorded one canonical event for parent review." : "Recorded one visible status event without formal review.");
|
||||
},
|
||||
});
|
||||
const sourceQuote = Type.Object({ path: Type.String({ minLength: 1 }), entryId: Type.Optional(Type.String()), quote: Type.String({ minLength: 1 }) });
|
||||
pi.registerTool({
|
||||
name: "review_subagent", label: "Review worker report", description: reportReviewDescription,
|
||||
parameters: Type.Object({
|
||||
reportId: Type.String({ minLength: 1 }), goal: sourceQuote,
|
||||
evidence: Type.Array(Type.Object({ path: Type.String({ minLength: 1 }), entryId: Type.Optional(Type.String()), quote: Type.Optional(Type.String()), observation: Type.String({ minLength: 1 }) }), { minItems: 1 }),
|
||||
observation: Type.String({ minLength: 1 }), unmet: Type.String({ minLength: 1 }),
|
||||
verdict: Type.String({ enum: ["accepted", "changes_requested", "blocked"] }), continuation: Type.Optional(Type.String()),
|
||||
}),
|
||||
async execute(_id, params, signal, _update, ctx) {
|
||||
if (state.child || state.mode !== "supervising") throw new Error("Only the active parent supervisor can review worker reports.");
|
||||
const report = records<Report>(ctx, REPORT).find(report => report.id === params.reportId);
|
||||
if (!report) throw new Error("Unknown owned report ID; use the reportId shown in /goals status.");
|
||||
if (!["accepted", "changes_requested", "blocked"].includes(params.verdict) || !params.goal.quote.trim() || !params.observation.trim() || !params.unmet.trim() || params.verdict === "changes_requested" && !params.continuation?.trim()) throw new Error("Supply the verdict, inspection, unmet requirements (or none), and a concrete continuation for changes_requested.");
|
||||
const sources = [params.goal, ...params.evidence].map(source => {
|
||||
const gitEvidence = /^git:([0-9a-f]{7,64}):(.+)$/i.exec(source.path);
|
||||
let path = source.path, bytes: Buffer, text: string;
|
||||
if (gitEvidence) {
|
||||
const [, commit, gitPath] = gitEvidence;
|
||||
if (source.entryId || !gitPath.split("/").every(part => part && part !== "." && part !== "..")) throw new Error(`Invalid Git evidence: ${source.path}`);
|
||||
try { bytes = Buffer.from(execFileSync("git", ["show", `${commit}:${gitPath}`], { cwd: ctx.cwd, encoding: "buffer" })); }
|
||||
catch { throw new Error(`Unavailable Git evidence: ${source.path}`); }
|
||||
text = bytes.toString("utf8");
|
||||
} else {
|
||||
path = resolve(ctx.cwd, source.path); bytes = readFileSync(path); text = bytes.toString("utf8");
|
||||
if (source.entryId) {
|
||||
const entry = savedSession(path).getBranch().find(entry => entry.id === source.entryId);
|
||||
if (!entry) throw new Error(`Missing session entry: ${path}#${source.entryId}`);
|
||||
const content = entry.type === "message" && "content" in entry.message ? entry.message.content : entry.type === "custom_message" ? entry.content : undefined;
|
||||
text = typeof content === "string" ? content : Array.isArray(content) ? content.filter(part => part.type === "text").map(part => part.text).join("\n") : JSON.stringify(entry);
|
||||
}
|
||||
}
|
||||
if (!bytes.length) throw new Error(`Empty evidence: ${path}`);
|
||||
const binary = bytes.includes(0) || /\.(png|jpe?g|gif|webp|pdf|mp4)$/i.test(path);
|
||||
if (!source.quote?.trim() && !binary || source.quote && !text.includes(source.quote)) throw new Error(`Quote does not match source: ${path}`);
|
||||
if ("observation" in source && !source.observation.trim()) throw new Error(`Describe the inspected evidence: ${path}`);
|
||||
return `${path}${source.entryId ? `#${source.entryId}` : ""}\n${source.quote ? `> ${source.quote}` : "[non-text capture]"}${"observation" in source ? `\nObserved: ${source.observation}` : ""}`;
|
||||
});
|
||||
const content = reportReviewContent(report.id, report.sessionFile, sources, params.observation, params.unmet, params.verdict, params.continuation || "");
|
||||
const review: ReportReview = { id: digest(content), reportId: report.id, report: report.id, verdict: params.verdict, content, continuation: params.continuation || "" };
|
||||
if (records<ReportReview>(ctx, REVIEW).some(saved => reviewedReportId(saved) === report.id)) return result("This worker revision already has a delivered review; a later stop report is a new revision.");
|
||||
if (!channel?.snapshot().connected || !channel.snapshot().supported || signal?.aborted) throw new Error("Review delivery unavailable; report remains pending.");
|
||||
const payload = { type: "review", to: report.session, sessionFile: report.sessionFile, requestId: report.requestId, plan: report.plan, review };
|
||||
if (Buffer.byteLength(JSON.stringify(payload)) > 16000) throw new Error("Review exceeds Intercom's 16 KiB limit; shorten the quotes and retain source references.");
|
||||
if (!records<ReportReview>(ctx, REVIEW_DRAFT).some(saved => saved.id === review.id)) pi.appendEntry(REVIEW_DRAFT, review);
|
||||
channel.publish(payload, { audience: "capable" });
|
||||
return result("Review sent for saving in the worker conversation. Obligation remains pending until the exact saved review is verified; use /goals status to inspect delivery.");
|
||||
},
|
||||
});
|
||||
pi.registerTool({
|
||||
name: "CompleteGoal", label: "Review goal evidence",
|
||||
description: completeGoalDescription,
|
||||
@@ -756,8 +939,8 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
try { for (const file of evidence) if (!readFileSync(file).length) throw new Error(emptyEvidence(file)); }
|
||||
catch (error) { return result(evidenceUnavailable(error)); }
|
||||
const subject = key(matches[0].subject);
|
||||
const othersAccepted = goals(text).every((goal) => goal.status === "cancelled" || key(goal.subject) === subject || (goal.status === "done" && state.signoffs[key(goal.subject)]));
|
||||
if (othersAccepted && !(matches[0].status === "done" && state.signoffs[subject])) {
|
||||
const othersAccepted = goals(text).every((goal) => goal.status === "cancelled" || key(goal.subject) === subject || goal.status === "done");
|
||||
if (othersAccepted && matches[0].status !== "done") {
|
||||
if (clearChangedFinalReview(text)) return result(finalReviewInvalidated);
|
||||
if (finalReviewTurnDigest !== digest(text)) {
|
||||
if (!state.finalReview) {
|
||||
@@ -769,17 +952,17 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
const lines = text.split("\n");
|
||||
lines[matches[0].index] = lines[matches[0].index].replace(/\[[ xX/-]\]/, "[x]");
|
||||
lines[matches[0].index] = lines[matches[0].index].replace(/\[[ xX/✓-]\]/, "[✓]");
|
||||
let log = lines.findIndex(line => FOLD_LINE.test(line));
|
||||
if (log === -1) { lines.push("", "## Log"); log = lines.length - 1; }
|
||||
lines.splice(log + 1, 0, "", completionLog(params.goal, params.observation, evidence, state.mode === "solo"));
|
||||
writeFileSync(path, `${lines.join("\n").trimEnd()}\n`);
|
||||
state.signoffs[key(matches[0].subject)] = { evidence, observation: params.observation, signature: goalAcceptanceSignature(text, matches[0].subject)! };
|
||||
state.finalReview = undefined;
|
||||
finalReviewTurnDigest = undefined;
|
||||
planHash = digest(planViews(planText()).notify);
|
||||
save(); refresh(ctx);
|
||||
const remaining = goals(planText()).some((goal) => goal.status !== "cancelled" && (goal.status !== "done" || !state.signoffs[key(goal.subject)]));
|
||||
const remaining = Boolean(unfinishedGoals(planText()));
|
||||
if (!remaining) requestCheckInRemoval(ctx);
|
||||
return result(completionResult(matches[0].subject, ctx.sessionManager.getSessionId(), remaining, state.mode === "solo"));
|
||||
});
|
||||
},
|
||||
|
||||
+12
-3
@@ -10,7 +10,11 @@ export function noticeDisplay(pi: ExtensionAPI) {
|
||||
context.messageType === "user" && mirrored.has(markdown) ? "" : markdown);
|
||||
pi.registerEntryRenderer(NOTICE, (entry, { expanded }, theme) => {
|
||||
const { content } = entry.data as { content: string };
|
||||
const label = content.includes("\nPlan changed.") ? "Plan changed · review requested" : "Goal instructions";
|
||||
const label = content.includes("\nPlan changed") ? "Plan changed · review requested"
|
||||
: content.includes("## Worker revision reviews") ? "Worker revisions · review requested"
|
||||
: content.includes("## Worker revision report") ? "Worker revision report"
|
||||
: content.includes("## Worker status:") ? "Worker status"
|
||||
: "Goal instructions";
|
||||
if (expanded) return new Markdown(content, 0, 0, getMarkdownTheme());
|
||||
return {
|
||||
render: (width) => [truncateToWidth(theme.fg("muted", `[pi-goals] ${label} · ${keyHint("app.tools.expand", "expand")}`), width)],
|
||||
@@ -18,14 +22,19 @@ export function noticeDisplay(pi: ExtensionAPI) {
|
||||
};
|
||||
});
|
||||
return {
|
||||
hide(content: string) {
|
||||
mirrored.add(content);
|
||||
},
|
||||
mirror(content: string) {
|
||||
mirrored.add(content);
|
||||
pi.appendEntry(NOTICE, { content });
|
||||
},
|
||||
restore(ctx: ExtensionContext) {
|
||||
restore(ctx: ExtensionContext, hiddenTypes: string[] = []) {
|
||||
mirrored.clear();
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "custom" && entry.customType === NOTICE) mirrored.add((entry.data as { content: string }).content);
|
||||
if (entry.type !== "custom" || entry.customType !== NOTICE && !hiddenTypes.includes(entry.customType)) continue;
|
||||
const content = (entry.data as { content?: unknown }).content;
|
||||
if (typeof content === "string") mirrored.add(content);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+5
-13
@@ -1,5 +1,5 @@
|
||||
// Pi/OpenAI: Log, at any heading level, is the single boundary between current work and history.
|
||||
export const GOAL_LINE = /^\s*(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*goal:\s*(.*)$/i;
|
||||
export const GOAL_LINE = /^\s*(?:\d+\.|[-*])\s*\[([ xX/✓-])\]\s*goal:\s*(.*)$/i;
|
||||
export const FOLD_LINE = /^#{1,6}[ \t]+Log[ \t]*\r?$/im;
|
||||
const identity = /^[ \t]*[-*]\s*(?:active worker|worker session|worker intercom session|preferred worker model):/i;
|
||||
|
||||
@@ -44,22 +44,14 @@ export function planContextView(plan: string, tier: "short" | "medium" | "full")
|
||||
return [short, ...(userVoice.length ? ["", ...userVoice] : []), ...(goalsHeading && goalLines.length ? ["", goalsHeading, ...goalLines] : [])].join("\n").trimEnd();
|
||||
}
|
||||
|
||||
// Pi/OpenAI: Approval covers shared requirements and this goal, not checkbox/task/evidence maintenance.
|
||||
export function goalAcceptanceSignature(plan: string, goal: string): string | undefined {
|
||||
const lines = foldPlan(plan).split("\n");
|
||||
const goals = lines.flatMap((line, index) => {
|
||||
const match = GOAL_LINE.exec(line);
|
||||
return match ? [{ index, subject: match[2].trim().toLowerCase() }] : [];
|
||||
});
|
||||
const matches = goals.filter(item => item.subject === goal.trim().toLowerCase());
|
||||
if (matches.length !== 1) return undefined;
|
||||
const selected = matches[0];
|
||||
const end = goals.find(item => item.index > selected.index)?.index ?? lines.length;
|
||||
const content = [...lines.slice(0, goals[0].index), `goal: ${selected.subject}`, ...lines.slice(selected.index + 1, end)];
|
||||
// Pi/OpenAI: Requirement changes request full supervisor context, never automatic approval changes.
|
||||
export function planRequirements(plan: string): string {
|
||||
const content = foldPlan(plan).split("\n").map(line => line.replace(GOAL_LINE, (_, _box, subject) => `goal: ${subject.trim()}`));
|
||||
const kept: string[] = [];
|
||||
let omittedIndent: number | undefined;
|
||||
let omittedHeading: number | undefined;
|
||||
for (const line of content) {
|
||||
if (line.startsWith("goal: ")) { omittedIndent = undefined; omittedHeading = undefined; }
|
||||
if (identity.test(line)) continue;
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(line);
|
||||
if (heading) {
|
||||
|
||||
+58
-42
@@ -112,8 +112,8 @@ Write the plan file in roughly this shape -- the file is read directly by the hu
|
||||
## Appendix (context, not approved)
|
||||
|
||||
Conventions:
|
||||
- A goal is a checkbox line beginning "goal:". Checkbox state: [ ] open, [/] active, [x] done,
|
||||
[-] cancelled. Leave goals [ ] at planning.
|
||||
- A goal is a checkbox line beginning "goal:". Checkbox state: [ ] open, [/] active, [x] reported done,
|
||||
[✓] parent-reviewed (CompleteGoal only), [-] cancelled. Leave goals [ ] at planning.
|
||||
- subtle failure mode + discriminator are the heart of this. Name the ways a "done" could look
|
||||
achieved but not be (empty output, a silently-errored step, a gamed test, a no-op that dodged
|
||||
every trap and showed nothing). The discriminator is the POSITIVE observation that success
|
||||
@@ -152,28 +152,30 @@ export const planDocument = (objective: string) => `# ${objective.split("\n")[0]
|
||||
export const discuss = "Type your changes in chat; the draft stays open.";
|
||||
|
||||
// Ready and explicit native peer attachment. No worker environment or agent-file contract.
|
||||
export const attachGoalPlanDescription = "Attach the absolute plan path explicitly supplied by the parent. On first attachment, also supply its exact Intercom parent UUID and requestId from the startup task. Read it without rewriting it. Restores plan context; grants no parent completion authority. No discovery or worker launch.";
|
||||
export const childPlanRole = "You are the delegated implementation worker. Save evidence and report progress for your delegated work; leave plan maintenance to the parent. Preserve agreed goals, requirements and discriminators; the supervisor owns goal-status changes and completion approval. Do not launch a second writer. Call AttachGoalPlan with the explicit plan path in your task before implementation (also after reconnect if unbound). Immediately report your actual Intercom UUID, saved-session path and current provider/model to the supplied supervisor ID. Identify unavailable fields as unknown; do not equate runtime IDs, session filenames and Intercom IDs. Send progress, completion and blocker reports there with artifact paths, then stay open for live messages. Do not exit or use caller_ping; unsent editor drafts are not visible in model context.";
|
||||
export const attachGoalPlanDescription = "Attach the absolute plan path explicitly supplied by the parent. On first attachment or explicit same-parent plan/request change, supply the exact existing Intercom parent UUID and newly assigned requestId. Changes require live parent verification; a different parent cannot take over. Omit these fields only to restore unchanged plan context. Preserve session history and prior reviews. Read the plan without rewriting it. Restores plan context; grants no parent completion authority. No discovery or worker launch.";
|
||||
export const reportGoalEventDescription = "Report a meaningful event for the current delegated worker run. Later results or failures may follow progress; unchanged repetitions are deduplicated. review_request, decision, blocker and completion create a parent review obligation. progress, running, waiting, receipt and no_change stay visible without formal review. Use review_request only for a bounded artifact that needs judgment; completion only when the assigned task is complete; blocker only when autonomous progress cannot continue. Routine intermediate work and queued jobs are progress or waiting.";
|
||||
export const childPlanRole = "You are the delegated implementation worker. Save evidence and report progress for your delegated work; leave plan maintenance to the parent. Preserve agreed goals, requirements and discriminators; the supervisor owns goal-status changes and completion approval. Do not launch a second writer. Call AttachGoalPlan with the explicit plan path in your task before implementation (also after reconnect if unbound). Immediately report your actual Intercom UUID, saved-session path and current provider/model to the supplied supervisor ID. Identify unavailable fields as unknown; do not equate runtime IDs, session filenames and Intercom IDs. Call ReportGoalEvent when there is a meaningful result or status change, including a later blocker or completion after progress. Do not repeat unchanged events. review_request, decision, blocker and completion require parent judgment. progress, running, waiting, receipt and no_change do not; use progress when work changed but the correct instruction is simply to continue. Put the canonical summary and exact artifact paths in the event. When waiting, name the child/job you await, its owner or handle, and what will wake you. Ending a turn while followed work continues is not task completion. Then stay open for live messages. Do not exit or use caller_ping; unsent editor drafts are not visible in model context.";
|
||||
export function readyApproved(workerName: string, planPath: string, notedWorker: string | undefined, plan: string, supervisorId: string): string {
|
||||
const launch = notedWorker
|
||||
? `Inspect the recorded worker session ${notedWorker}; if live, steer that exact Intercom session. Do not open or replace its conversation. For stopped-writer recovery use OpenGoalWorker action recover with writersStopped=true after inspecting the actual old runtime and saved history. It restores context without replaying a task.`
|
||||
: `Use OpenGoalWorker with a bounded first task for '${workerName}'. It uses Nico project.open, not subagent.`;
|
||||
return `[pi-goals: approval — Ready]\nReady approved this plan: ${planPath}. Stay here as supervisor. ${launch} Confirm your actual Intercom UUID with status/list; your Pi session ID ${supervisorId} is a distinct field. Await explicit worker attachment and a report with actual Intercom UUID, saved-session path and resolved model. Inspect results and steer corrections in that same open session. A receipt or idle pane is not completion. Only after reviewing the exact saved session may OpenGoalWorker action fresh start independent context in that pane, retaining its saved history; supply reviewedThrough from the inspected latest entry.\n\n${quotedPlan(planPath, foldPlan(plan), "working set before Log")}`;
|
||||
? `Inspect recorded history ${notedWorker} and actual writer state. If live, steer that exact Intercom session; do not replace its conversation. If stopped, preserve history and drafts and use stock project.status/project.close/project.open only after verified safe stop.`
|
||||
: `Use OpenGoalWorker with a bounded proposed task for '${workerName}'. It uses stock project.open, not subagent execution. A new worker attaches and waits; after inspecting its report, send the authorized task through exact-session Intercom.`;
|
||||
return `[pi-goals: approval — Ready]\nReady approved this plan: ${planPath}. Stay here as supervisor. ${launch} Confirm your actual Intercom UUID with status/list; your Pi session ID ${supervisorId} is a distinct field. Await explicit worker attachment and a report with actual Intercom UUID, saved-session path and resolved model. Inspect results and steer corrections in that same open session. A receipt or idle pane is not attachment, writer exit or completion.\n\n${quotedPlan(planPath, foldPlan(plan), "working set before Log")}`;
|
||||
}
|
||||
|
||||
export function workerAssignment(plan: string, parent: string, requestId: string, task: string): string {
|
||||
return `You are already attached as the delegated goals-worker in this native Nico project pane for plan ${plan}; assignment ${requestId}. This grants worker context only, never supervisor approval authority. Read the complete supplied plan, applicable AGENTS.md and skills. Confirm the exact parent Intercom UUID ${parent} in the live roster; send it your initial actual Intercom UUID, saved-session path, resolved provider/model and thinking level. Do not infer one identity from another. Use normal tools; no model switch was requested by this launch. Implement only this assignment:\n\n${task}\n\nSave actual artifacts and verification output. Report blocked, error and result evidence through Intercom to that exact parent. The parent independently inspects and may send a concrete correction here. Do not approve goals or launch another writer. Respect human pauses and intervention. Keep this conversation open with the final review visible; do not exit, reset, switch session or close the pane.`;
|
||||
export function workerAssignment(plan: string, parent: string, requestId: string, task: string, model?: string): string {
|
||||
const preference = model ? `User-supplied model preference: ${JSON.stringify(model)}. This is an instruction, not observed configuration. Configure it through supported controls in this worker session and report the actual provider/model after verification. Preserve later human model changes; do not reapply an older preference. If this choice is unavailable, report that specific limitation without silently substituting or stalling unrelated authorized work.` : "Inherit the native model; no model switch was requested by this assignment.";
|
||||
return `You are a new goals-worker in a native project pane for plan ${plan}; request ${requestId}. First call AttachGoalPlan with path ${JSON.stringify(plan)}, parent ${JSON.stringify(parent)} and requestId ${JSON.stringify(requestId)}. Until attachment succeeds, do not implement. Read the supplied plan, applicable AGENTS.md and skills. Confirm the exact parent Intercom UUID ${parent} in the live roster; send it your initial actual Intercom UUID, saved-session path, resolved provider/model and thinking level. Do not infer one identity from another. Use normal tools. ${preference} After attaching and reporting, WAIT for an explicit assignment from that exact parent Intercom session before implementation; the parent may have paused since opening this pane. Proposed task (context only, not execution permission):\n\n${task}\n\nSave actual artifacts and verification output. Report blocked, error and result evidence through Intercom to that exact parent. The parent independently inspects and may send a concrete correction here. Do not approve goals or launch another writer. Respect human pauses and intervention. Keep this conversation open with the final review visible; do not exit, reset, switch session or close the pane.`;
|
||||
}
|
||||
// Supervision and turn-event upkeep (not a scheduled wake-up).
|
||||
const supervisorJob = "Your job is to be an autonomous research partner and supervisor with responsibility for the user's goals. Keep perspective, bring diligence, and use research taste and wisdom to sustain work overnight and keep it on track. Resolve routine implementation decisions yourself; ask the user only when their judgment or authorization is needed. Let each check-in follow what changed or needs attention, rather than repeat the previous recap.";
|
||||
const supervisorJob = "Your job is to be an autonomous research partner and supervisor with responsibility for the user's goals. Keep perspective, bring diligence, and use research taste and wisdom to sustain work overnight and keep it on track. Resolve routine implementation decisions yourself; ask the user only when their judgment or authorization is needed. At each check-in, inspect the plan and workers for drift, loops and stuck/stopped/blocked work; ensure follow-up and give a brief user-facing plan update rather than repeat the previous recap.";
|
||||
export function supervisor(workerName: string, planPath: string, supervisorId: string): string {
|
||||
return `You are the goal supervisor in the main chat for ${planPath}. ${supervisorJob} Inspect actual artifacts, saved verification, applicable AGENTS.md and skills yourself; delegate implementation to '${workerName}'. Keep authorized work moving to the requested outcome, not merely approval paperwork. Investigate blocked/waiting/done claims and change ineffective instructions. Give brief visible assessments with judgment. You may maintain the plan but must not weaken the goal to accept worker output.
|
||||
return `You are the goal supervisor in the main chat for ${planPath}. ${supervisorJob} Inspect actual artifacts, saved verification, applicable AGENTS.md and skills yourself; delegate implementation to '${workerName}'. Keep authorized work moving to the requested outcome, not merely approval paperwork. Investigate blocked/waiting/done claims using recent saved tool calls with arguments and results, then current child/job status when needed. History proves a launch or watch at that time, not current liveness. A worker ending its turn may still await work; verify follow-up and change ineffective instructions. Give brief visible assessments with judgment. You may maintain the plan but must not weaken the goal to accept worker output.
|
||||
You can be playful: let the humor come from what actually happened. Avoid repeating recent jokes, nicknames or kaomoji; plain updates are welcome too. No forced cheerfulness or novelty. If supervision gets repetitive, step back and change your approach. Keep it brief and aimed at the goal, not another reporting chore.
|
||||
You can speculate and brainstorm around uncertainty or unexpected results. Label guesses as guesses, consider alternative explanations, and look for a useful way to tell them apart. Keep exploration brief, open-minded and fun: take a step back, play with surprising ideas, question the current framing, and enjoy exploring the broader perspective while staying connected to the agreed goal.
|
||||
(b •_•)b -- wassname
|
||||
Take uncertainty as an invitation to investigate, not something to hide. Have room to play with ideas, question yourself and the worker, and appreciate a good surprise. Investigate surprising results, find mistaken assumptions, make complicated ideas simpler, and disagree usefully rather than agree politely. Keep the work moving without turning supervision into paperwork. A little affectionate teasing is welcome when it fits, and workers can push back too. Keep the humor friendly and the criticism specific. -- Pi/Astra
|
||||
Use OpenGoalWorker for the first native project pane and stock Intercom for exact-session assignment/report/steering. Do not use subagent as a second backend. A stored binding is not proof of liveness; missing runtime state is not proof of stop. Verify actual Intercom identities with list/status; your Pi session ID is ${supervisorId}, a distinct field. Require artifact paths, saved verification and blocker/error reports. When the worker stops for any reason, inspect actual artifacts and saved messages before approving or correcting it in the same open session. A recap or receipt alone sends no instruction and proves no action. Record actual pane identity, '- worker session:' and '- worker intercom session:' with provenance. CompleteGoal belongs only to this parent or explicitly confirmed solo self-verification.
|
||||
Keep normal tools and honor human model changes. project.open has no model override: inspect the native worker's resolved model; if a requested model is unavailable, report it rather than silently substituting. After compaction reread the plan. Lost connection or exhausted credits does not erase work. Preserve drafts and saved sessions; confirm other writers stopped before solo takeover. OpenGoalWorker action recover restores an owned durable session only after confirmed stopped writers, without replaying work or changing its model. Action fresh requires the exact last reviewed entry and an idle draft-free peer, and retains the old history. Revisions use ordinary Intercom in the same context. Requested-model automation currently fails closed at the public setter race; no preference means keep the native default. Never replace an unreviewed conversation or start a duplicate writer.`;
|
||||
Keep normal tools and honor human model changes. Inherit by default. If the user supplies a model preference, pass it explicitly to the agent through OpenGoalWorker's model instruction or exact-session Intercom steering; let the agent configure it through supported controls and verify its actual choice. project.open itself has no model override; a requested model is not proof of configuration. Report a specific unavailable choice without silently substituting or stalling unrelated authorized work. After compaction reread the plan. Lost connection or exhausted credits does not erase work. Preserve drafts and saved sessions; confirm other writers stopped before solo takeover. Revisions use ordinary Intercom in the same context. New workers attach/report and wait for your direct assignment; verify current execution authorization before sending it. For stopped-worker replacement, inspect saved history and partial work, preserve any editor draft/queued input and confirm the exact writer stopped before stock project.close/project.open. Idle or absence alone cannot establish draft safety or writer exit. If safety is unobservable, retain the pane and inspect it; do not invent recovery controls. Do not reapply historical preferences over later human choices. Never replace an unreviewed conversation or start a duplicate writer.`;
|
||||
}
|
||||
// Routine notices quote only selected goal lines; full context stops at Log.
|
||||
const goalLines = (text: string) => foldPlan(text).split("\n").filter(line => GOAL_LINE.test(line)).join("\n");
|
||||
@@ -184,10 +186,20 @@ export function planContext(mode: string, path: string | undefined, text: string
|
||||
return `[pi-goals: context resync]\nCurrent goal mode: ${mode}. Earlier role messages are historical; this current role governs. Read the plan file for details and earlier evidence; do not restart completed work.\n\n${quotedPlan(path, tier === "full" ? foldPlan(text) : goalLines(text), tier === "full" ? "active plan above Log" : "unfinished or unreviewed goal lines")}`;
|
||||
}
|
||||
export function planChangedReview(planPath: string, text = ""): string {
|
||||
return `[pi-goals: reminder — plan changed]\nPlan changed: inspect current requirements, completion claims and evidence at ${planPath}. Evidence-only edits do not revoke execution approval. Continue only unfinished authorized work; respect pauses and do not assume approval for changed scope. Manual ticks are claims, not sign-off. Do not start a duplicate writer.${text ? `\n\n${quotedPlan(planPath, goalLines(text), "selected goal lines")}` : ""}`;
|
||||
return `[pi-goals: reminder — plan changed]\nPlan changed: inspect current requirements, completion claims and evidence at ${planPath}. Evidence-only edits do not revoke execution approval. Continue only unfinished authorized work; respect pauses and do not assume approval for changed scope. [x] is reported done, not reviewed. [✓] records parent review through CompleteGoal. When requirements change, inspect the evidence and reopen affected reviewed goals with [ ] or [/] if necessary; status is not automatically invalidated. Do not start a duplicate writer.${text ? `\n\n${quotedPlan(planPath, goalLines(text), "selected goal lines")}` : ""}`;
|
||||
}
|
||||
export function workerAttachment(plan: string, session: string, text: string): string {
|
||||
return `Worker attachment for ${plan}, exact Intercom session ${session}:\n${text}\nMetadata only; no acknowledgement or review turn requested.`;
|
||||
}
|
||||
// Pi/OpenAI: supervisor-authored report reviews, separate from goal completion.
|
||||
export const reportReviewDescription = "Review one owned worker revision after inspecting its actual artifacts. reportId is the exact session:revision token shown in /goals status, not report prose. Quote the assigned goal/task and evidence from files; git:<commit>:<path> reads an immutable tracked revision. An optional saved-session entryId selects decoded message text. State observations and unmet requirements; use accepted, changes_requested or blocked. Changes requested need a concrete continuation. Text quotes are checked, not their relevance or quality. Non-text evidence needs a nonempty capture and specific observation. Delivery stays pending until the worker saves the visible review. Acceptance never completes a goal or wakes/closes the worker.";
|
||||
export const reportReviewContent = (report: string, sessionFile: string, sources: string[], observation: string, unmet: string, verdict: string, continuation: string) => `## Worker review: ${verdict}\n\n- Report: \`${report}\`\n- Saved session: \`${sessionFile}\`\n\n### Assigned goal/task\n\n${sources[0]}\n\n### Evidence\n\n${sources.slice(1).join("\n\n")}\n\n### Review\n\n- Inspected: ${observation}\n- Unmet: ${unmet}\n- Continuation: ${continuation || "none"}\n\nThis is a report review, not CompleteGoal.\n\n— Pi supervisor`;
|
||||
export const pendingReportReviews = (reports: string[]) => `## Worker revision reviews\n\nInspect each saved stop report and actual artifacts, then use review_subagent with its reportId. Independent authorized work may continue; attachment receipts and ordinary Intercom messages are not review obligations.\n\n${reports.map(report => `- ${report}`).join("\n")}`;
|
||||
export function workerReview(plan: string, session: string, text: string): string {
|
||||
return `Worker event for ${plan}, exact Intercom session ${session}:\n${text}\nThis is a report, not completion approval. Inspect actual artifacts and saved messages; if correction is needed, send it to the same session. Preserve its visible review conversation. Respect pauses; do not reply merely to acknowledge.`;
|
||||
return `[pi-goals: worker review]\n## Worker revision report\n\n- Plan: \`${plan}\`\n- Intercom session: \`${session}\`\n\n### Report\n\n${text}\n\nThis is a report, not completion approval. Inspect actual artifacts and saved messages; if correction is needed, send it to the same session. Preserve its visible review conversation. Respect pauses; do not reply merely to acknowledge.`;
|
||||
}
|
||||
export function workerStatus(plan: string, session: string, eventId: string, kind: string, text: string): string {
|
||||
return `[pi-goals: worker status]\n## Worker status: ${kind}\n\n- Plan: \`${plan}\`\n- Intercom session: \`${session}\`\n- Event: \`${eventId}\`\n\n${text}\n\nVisible diagnostic only; no review_subagent obligation was created.`;
|
||||
}
|
||||
|
||||
export function manualReview(planPath: string, text: string): string {
|
||||
@@ -197,16 +209,27 @@ export function finalReview(planPath: string, text: string): string {
|
||||
return `[pi-goals: reminder — final completion review]\nFinal completion review: the preceding CompleteGoal request did not record approval. Read the complete file at ${planPath}, including requirements, evidence and Log, and inspect the cited artifacts yourself. Then call CompleteGoal again with the exact remaining goal and evidence. Changed requirements need a new review. Plan revision: ${createHash("sha256").update(text).digest("hex")}.\n\n${quotedPlan(planPath, goalLines(text), "selected goal lines")}`;
|
||||
}
|
||||
|
||||
// Check-ins. The installed scheduler owns storage/timing/UI. Removal guidance must never add jobs.
|
||||
export function removeGoalSchedule(sessionId: string): string {
|
||||
return `With schedule_prompt, list jobs and read .pi/schedule-prompts.json to verify ownership; tool text omits session binding. Remove by jobId only the job named ${JSON.stringify(`goals-${sessionId}`)} bound to session ${JSON.stringify(sessionId)}. Never use cleanup; leave other jobs untouched. Do not add, enable or recreate any job. If unavailable or ownership is ambiguous, report it; /schedule-prompt opens the user controls.`;
|
||||
// Check-ins. The installed scheduler owns storage/timing/UI; only new default wakes are one line.
|
||||
export const goalCheckInWake = "Goal check-in: only while supervising unfinished goals, read the attached plan and recent worker history. Check for drift, loops and stuck/stopped/blocked work; ensure follow-up. Verify current child/job status when needed; an ended turn may still await work. Steer authorized work towards the goals without duplicating writers and give a brief user-facing plan update. Otherwise do not resume work. Never create a timer from this wake.";
|
||||
export const schedulerMessages = {
|
||||
unconfirmed: "Owned check-in removal unconfirmed: no fresh scheduler result could be observed in this saved session. The request is cancelled; later results will not trigger removal. Inspect /schedules all and use exact owned IDs with /schedule-remove.",
|
||||
unavailable: "Owned check-in removal unavailable: verified @jl1990/pi-scheduler commands are not loaded. No model turn or replacement timer was started. Inspect /schedules all.",
|
||||
queued: "Goals cleared; original plan unchanged. Owned check-in lookup/removal requested through scheduler commands; inspect /schedules all for the result.",
|
||||
cleared: "Goals cleared; original plan unchanged. Check-in removal is not confirmed; inspect scheduler controls.",
|
||||
foreign: "Matching check-in names with missing/different session scope were left unchanged. Inspect /schedules all; do not use broad cleanup.",
|
||||
invalid: "A scheduler task has an invalid or ambiguous command ID; it was left unchanged.",
|
||||
format: "This scheduler normalizes prompt whitespace. Do not silently migrate or rewrite a custom prompt whose bytes would change; keep it intact and ask for an explicit replacement.",
|
||||
creation: "Goal check-ins require supervising unfinished goals, action prompt, type interval and scope session. List existing owned tasks first; never add a second check-in.",
|
||||
};
|
||||
export function removeGoalSchedule(sessionId: string, pause = false): string {
|
||||
return `Use list_scheduled_tasks with includeAll:true. Verify task details: name ${JSON.stringify(`goals-${sessionId}`)}, action prompt, scope session, and sessionFile exactly your current saved session. ${pause ? "Disable" : "Remove"} only those owned task IDs with manage_scheduled_task. Never use cleanup or change foreign tasks. Do not add, enable or recreate any job. ${pause ? "Disable only currently enabled owned jobs; leave already-disabled jobs unchanged. Keep prompt and interval bytes unchanged; a disabled job survives reload." : "If unavailable or ownership is ambiguous, report it."} Public user controls are /schedules all and /schedule-disable, /schedule-enable or /schedule-remove <id>.`;
|
||||
}
|
||||
export function scheduleCheckIn(sessionId: string, planPath: string): string {
|
||||
return `Hourly check-in is one visible schedule_prompt job; plan-change and upkeep reviews are event hooks, not another timer. List first. If an owned job named ${JSON.stringify(`goals-${sessionId}`)} already exists, retain its human-edited prompt, interval and enabled/disabled state unchanged; never recreate, overwrite or re-enable it. Only while supervising unfinished non-cancelled goals, if missing on this explicit start/resume, add one session-bound interval '1h' job with no model override. Read .pi/schedule-prompts.json and verify that new job's session is ${JSON.stringify(sessionId)}; tool text does not expose binding. If the new job is unbound, remove that job by ID and report the scope error. Do not change other jobs. Its initial prompt: Hourly goal check-in: read ${planPath} and the current goal mode. If paused, exited, solo or all non-cancelled goals reviewed, remove only this owned job without resuming work. Otherwise inspect progress and evidence, give a brief assessment and keep authorized work moving without a duplicate writer. Do not reinstall a missing job from a scheduled check-in. Users inspect/toggle/remove jobs with /schedule-prompt and edit prompt/interval through schedule_prompt update. Never use cleanup. Retain their edits, but warn that this installed scheduler deletes disabled jobs on reload/shutdown; do not promise they persist. If schedule_prompt is unavailable, report hourly check-ins unavailable; do not build a timer.`;
|
||||
export function scheduleCheckIn(sessionId: string, planPath: string, sessionFile = "", paused: Record<string, string> = {}): string {
|
||||
return `For ${planPath}, keep one visible @jl1990/pi-scheduler check-in. Plan-change/upkeep reviews are event hooks, not another timer. List first with list_scheduled_tasks includeAll:true; inspect structured details. Owned tasks have name ${JSON.stringify(`goals-${sessionId}`)}, action prompt, scope session and sessionFile ${JSON.stringify(sessionFile)}. Retain existing custom prompt, interval and disabled state; never recreate or overwrite them. Only these jobs disabled by this goal pause may be enabled on explicit resume, and only if their disabledAt still matches: ${JSON.stringify(paused)}. Leave later human edits unchanged. If no owned task exists on this explicit start/resume and unfinished non-cancelled goals remain, use schedule_task with action:prompt, type:interval, schedule:1h, scope:session and the exact name above; omit maxRuns and unrelated fields. Its new default prompt is ${JSON.stringify(goalCheckInWake)}. No model parameter or subagent job. Verify returned scope/sessionFile; never touch foreign jobs or use cleanup. Edit cadence through manage_scheduled_task action:update with only id and schedule; do not resend a prompt when changing interval. A scheduled wake must never create a missing job. Before migrating any legacy job, verify its owned identity, custom prompt bytes, interval and disabled state through the old public controls; retire only that authorized old job. If those controls/evidence are unavailable, report migration blocked rather than infer deletion or silently copy/flatten a prompt. If the new scheduler is unavailable, report it; do not build a timer.`;
|
||||
}
|
||||
|
||||
// Completion and runtime errors. Tool returns are model-facing too.
|
||||
export const completeGoalDescription = "Parent supervisor or solo self-verification only. Inspect the actual artifact and saved verification first; cite nonempty evidence files and describe what you observed. Exact goal subject required. The final remaining goal first queues a full-plan review; call CompleteGoal again from that review to record it. Manual ticks and worker reports are claims; ignored/uncommitted evidence is allowed. This records judgment, not an independent judge.";
|
||||
export const completeGoalDescription = "Parent supervisor or solo self-verification only. Inspect the actual artifact and saved verification first; cite nonempty evidence files and describe what you observed. Exact goal subject required. The final remaining goal first queues a full-plan review; call CompleteGoal again from that review to record it. Writes [✓] and review evidence in Log. [x] ticks and worker reports are claims; ignored/uncommitted evidence is allowed. This records judgment, not an independent judge.";
|
||||
export const messages = {
|
||||
noPlan: "no plan attached",
|
||||
emptyPlan: "empty plan (save may be in progress)",
|
||||
@@ -219,7 +242,7 @@ export const messages = {
|
||||
export const goalToolBlocked = (mode: string) => `Goals are ${mode}; no worker launch/resume authorized.`;
|
||||
export const emptyEvidence = (path: string) => `Empty evidence: ${path}`;
|
||||
export const evidenceUnavailable = (error: unknown) => `Evidence unavailable: ${String(error)}. No sign-off recorded.`;
|
||||
export const planUnavailable = (path: string | undefined, error: unknown) => `Goal plan ${path ?? "not attached"} unavailable: ${String(error)}. Do not implement or sign off until it is restored or explicitly attached. Retain all progress and signoffs; do not restart completed work.`;
|
||||
export const planUnavailable = (path: string | undefined, error: unknown) => `Goal plan ${path ?? "not attached"} unavailable: ${String(error)}. Do not implement or sign off until it is restored or explicitly attached. Retain all progress and reviewed plan status; do not restart completed work.`;
|
||||
export const childPlanAttached = (path: string) => `Attached worker plan ${path}; plan context restored without altering the file. Parent retains completion authority.`;
|
||||
export function completionLog(goal: string, observation: string, evidence: string[], solo: boolean): string {
|
||||
return `- ${solo ? "Solo self-verification" : "Parent review"}: ${JSON.stringify(goal)}; ${JSON.stringify(observation)}; evidence ${JSON.stringify(evidence)}`;
|
||||
@@ -234,43 +257,36 @@ export function completionResult(goal: string, sessionId: string, remaining: boo
|
||||
|
||||
// Pause/resume and solo recovery. Stored stop confirmation is invalidated on every worker launch.
|
||||
export const pausedRole = "Goal work is paused. Do not launch, resume or authorize work. Incoming reports are observations, not permission. Help inspect or stop existing workers if requested.";
|
||||
export function pauseExitNotice(worker: { intercomId?: string; sessionFile?: string; paneId?: string } | undefined, exited: boolean): string {
|
||||
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? `Locate the recorded native pane ${worker.paneId ?? "unknown"}, Intercom session ${worker.intercomId ?? "unknown"}, saved session ${worker.sessionFile ?? "unknown"}. Send an explicit pause there; inspect and confirm actual stop without closing the review conversation.` : "No worker recorded: inspect Intercom and native panes; absence is not proof of stop."} Remote stop is NOT yet confirmed. Resume only after explicit authorization.`;
|
||||
export function pauseExitNotice(worker: { intercomId?: string; sessionFile?: string; paneId?: string; identity?: { paneId?: string } } | undefined, exited: boolean): string {
|
||||
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? `Locate the recorded native pane ${worker.identity?.paneId || worker.paneId || "unknown"}, Intercom session ${worker.intercomId ?? "unknown"}, saved session ${worker.sessionFile ?? "unknown"}. Send an explicit pause there; inspect and confirm actual stop without closing the review conversation.` : "No worker recorded: inspect Intercom and native panes; absence is not proof of stop."} Remote stop is NOT yet confirmed. Resume only after explicit authorization.`;
|
||||
}
|
||||
export function resumeNotice(workerName: string, planPath: string, worker: { sessionFile?: string; intercomId?: string } | undefined): string {
|
||||
return `User authorized continuation of ${planPath}. Inspect worker state before any launch. ${worker ? `Use the existing session ${worker.sessionFile ?? "unknown"} and exact Intercom UUID ${worker.intercomId ?? "unknown"}; if live, inspect/message it. Do not open a replacement. For saved-session recovery use OpenGoalWorker action recover only after confirming all other writers stopped; do not pass a task or model to replay.` : `Use OpenGoalWorker for '${workerName}' only after confirming no prior writer exists.`} Continue only unfinished goals; retain saved progress and scheduler edits.`;
|
||||
return `User authorized continuation of ${planPath}. Inspect worker state before any launch. ${worker ? `Use the existing session ${worker.sessionFile ?? "unknown"} and exact Intercom UUID ${worker.intercomId ?? "unknown"}; if live, inspect/message it. Do not open a replacement. If stopped, inspect saved history and partial work, preserve drafts and use stock pane controls only after confirming safe stop. Continue only remaining work in a newly authorized context; never replay the completed assignment. Preserve later human model choices.` : `Use OpenGoalWorker for '${workerName}' only after confirming no prior writer exists.`} Continue only unfinished goals; retain saved progress and scheduler edits.`;
|
||||
}
|
||||
export const soloRole = "Solo mode: implement the approved plan directly; do not delegate a concurrent writer. Verify artifacts before CompleteGoal; completion is self-verification, not independent supervisor review. Continue only unfinished goals and keep plan/evidence current.";
|
||||
export function soloNotice(planPath: string): string {
|
||||
return `User authorized solo work on ${planPath} after confirming no other writer remains. ${soloRole}`;
|
||||
}
|
||||
export function attachNotice(planPath: string, solo: boolean, notedWorker: string | undefined): string {
|
||||
return `Attached to the existing plan ${planPath}; read it and its evidence without restarting completed work or re-deriving settled decisions. ${notedWorker ? `Recorded worker session: ${notedWorker}; inspect liveness before resume.` : ""} ${solo ? soloRole : "Present /goals review or /goals ready; no implementation before approval."}`;
|
||||
}
|
||||
|
||||
export const nativeMessages = {
|
||||
actionApplied: (action: string, peer: unknown) => `Native ${action} confirmed in the exact peer: ${JSON.stringify(peer)}. Recovery submits no task and does not change the model. Inspect the observed identity and durable history before further steering.`,
|
||||
externalOwnershipUnknown: (path: string, worker?: { intercomId?: string; sessionFile?: string; paneId?: string; identity?: { paneId?: string } }) => {
|
||||
const pane = worker?.identity?.paneId || worker?.paneId;
|
||||
return `Cannot verify ownership of ${path}: the supported Intercom roster does not identify per-plan supervisors; a missing row is not exit proof. Original supervisor unknown. Current context and authority unchanged; no adoption or takeover authorized. Read-only inspection: read({path:${JSON.stringify(path)}}). ${worker ? `Current worker only (not proof of the target's owner): ${worker.intercomId ? `intercom action:list, locate exact ID ${worker.intercomId}. ` : ""}${worker.sessionFile ? `read({path:${JSON.stringify(worker.sessionFile)}}). ` : ""}${pane ? `herdr pane process-info --pane ${JSON.stringify(pane)}. ` : ""}` : ""}Use /goals status for current references. Return to the original supervisor's saved context only when independently identified; no target can be inferred here.`;
|
||||
},
|
||||
samePlanRestored: "Plan context refreshed; mode and worker binding unchanged. No new work authorized.",
|
||||
workerPause: (paused: boolean) => `Worker ${paused ? "paused" : "unpaused"} locally; no new task submitted and no approval authority granted.`,
|
||||
notDurable: "No durable owned worker transcript is available; a prospective saved path is not recovery evidence.",
|
||||
notOwned: "The exact session/plan/parent ownership does not match; no worker action taken.",
|
||||
controlDescription: "Verified native worker control only; arbitrary command text never starts work.",
|
||||
controlRejected: "Native worker control rejected; inspect actual peer state before retrying.",
|
||||
controlChanged: "Worker identity, history, input, pause or writer state changed; no replacement/task authorized.",
|
||||
controlCancelled: "Native session replacement was cancelled; prior history remains active.",
|
||||
reviewRequired: "Fresh work requires the exact last reviewed session entry ID; preserve the current conversation until reviewed.",
|
||||
stopRequired: "Recovery requires confirmed stopped writers and a durable owned saved session. No task replay or model override is accepted on recovery.",
|
||||
taskRequired: "Supply an explicit bounded task for a new or fresh worker context.",
|
||||
modelRaceBoundary: "Requested-model startup is held: public setModel cannot atomically preserve a concurrent human selection. No task was submitted under a fallback.",
|
||||
openDescription: "After Ready, use action start for a blank native peer and bounded task. Use fresh only after inspecting the recorded session and supplying its exact reviewedThrough leaf ID; old history is retained. Use recover with writersStopped=true and the owned saved session, never a task replay. Requested models currently fail closed at an unresolved public setter race. A pane receipt is not actual attachment or completion.",
|
||||
taskRequired: "Supply an explicit bounded proposed task for a new worker context.",
|
||||
modelDescription: "User preference for agent-led configuration and verification, not a launch override.",
|
||||
openDescription: "After Ready, open a native project pane with a bounded proposed task. Stock open sends startup only into a newly created Pi context; existing panes receive no message. The worker must AttachGoalPlan and report, then wait for an explicit exact-session assignment. Inherit model defaults unless the user supplies a preference for agent-led configuration. Receipts are not attachment. Revisions use Intercom; stopped-worker replacement uses stock controls after inspected history, preserved drafts and confirmed stop, not this tool.",
|
||||
disconnected: "Intercom disconnected; liveness and stop are unconfirmed. Inspect the saved session and pane; do not launch a replacement.",
|
||||
shuttingDown: "Worker session shutting down; inspect its last saved messages. No goal sign-off inferred.",
|
||||
noAssistant: "Worker run ended without an assistant result; inspect saved messages.",
|
||||
alreadyRecorded: "A worker is already recorded or opening. Inspect its native pane and exact Intercom session; do not create a duplicate or replace its conversation.",
|
||||
noIdentity: "Intercom identity unavailable; no worker opened.",
|
||||
openReceipt: "\nAwait verified peer capability and actual action confirmation. An existing binding is only a surface; draft, history and ownership checks still apply. Do not resend or infer work from this receipt.",
|
||||
openReceipt: "\nIf opened, await AttachGoalPlan and a correlated Intercom report, then send an explicitly authorized assignment to that exact session. If already-open, no startup was sent: inspect the existing conversation and ownership, do not retask or close it blindly. Any model preference awaits agent configuration/verification. Never infer attachment or implementation from this receipt.",
|
||||
openFailed: "Native open failed; inspect binding and possible live writer before retry or solo takeover: ",
|
||||
parentUnavailable: "Parent Intercom identity is not live; no worker attachment changed.",
|
||||
reattachAuthorization: "Changing an attached plan/request requires explicit authorization from the recorded parent: supply that same parent Intercom UUID and its new requestId. Different-parent takeover or missing fields is refused; no attachment changed.",
|
||||
intercomNotReady: "Intercom is still connecting. Call intercom status/list, verify the live parent identity, then retry this operation in the same session. No attachment or launch changed.",
|
||||
reportUnavailable: "Automatic worker notice could not reach Intercom. The saved result remains here; restore the connection and report to the exact parent. Do not infer delivery or completion.",
|
||||
attached: (sessionFile: string) => `Worker attached. Saved session: ${sessionFile}. Attachment is not completion.`,
|
||||
attached: (sessionFile: string) => `Worker attached. Saved session: ${sessionFile}. Inspect its report and current authorization before assigning work through Intercom. Attachment is not completion.`,
|
||||
};
|
||||
|
||||
Vendored
+22
@@ -1,6 +1,28 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { workerAttachment } from "../../src/prompts.js";
|
||||
|
||||
export default function offlineModel(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("fixture-reload", { handler: async (_args, ctx) => { await ctx.reload(); } });
|
||||
// Seed OpenGoalWorker's launch binding without Herdr; attachment/delivery use real Intercom/Pi.
|
||||
pi.registerCommand("fixture-worker-binding", { handler: async (args, ctx) => {
|
||||
const current = ctx.sessionManager.getBranch().findLast(entry => entry.type === "custom" && entry.customType === "pi-goals-main-supervisor-v1");
|
||||
if (current?.type !== "custom" || (current.data as { mode?: string }).mode !== "supervising") throw new Error("Ready must precede worker allocation");
|
||||
pi.appendEntry("pi-goals-main-supervisor-v1", { ...current.data as object, worker: JSON.parse(args) });
|
||||
await ctx.reload();
|
||||
} });
|
||||
pi.registerCommand("fixture-legacy-supersession", { handler: async (_args, ctx) => {
|
||||
const entry = ctx.sessionManager.getBranch().findLast(entry => entry.type === "custom" && entry.customType === "pi-goals-main-supervisor-v1");
|
||||
if (entry?.type !== "custom") throw new Error("Missing supervisor state");
|
||||
const { plan, worker } = entry.data as any;
|
||||
const report = { plan, session: worker.intercomId, sessionFile: worker.sessionFile, requestId: worker.requestId, text: "Historical artifact" };
|
||||
pi.appendEntry("pi-goals-report", { ...report, id: "legacy:A" });
|
||||
pi.appendEntry("pi-goals-report", { ...report, id: "legacy:B", supersedes: "legacy:A" });
|
||||
pi.appendEntry("pi-goals-report-review", { id: "legacy-review", report: "legacy:B", verdict: "accepted", content: "Historical review", continuation: "" });
|
||||
await ctx.reload();
|
||||
} });
|
||||
pi.registerCommand("fixture-attachment-notice", {
|
||||
handler: (_args, ctx) => pi.sendMessage({ customType: "pi-goals-supervision", content: workerAttachment(ctx.cwd, "fixture-peer", "Attachment recorded."), display: true }, { triggerTurn: false }),
|
||||
});
|
||||
pi.registerProvider("offline", {
|
||||
baseUrl: process.env.PI_GOALS_OFFLINE_MODEL_URL!,
|
||||
apiKey: "test",
|
||||
|
||||
+6
-12
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldPlan, goalAcceptanceSignature } from "../src/plan.js";
|
||||
import { foldPlan, planRequirements } from "../src/plan.js";
|
||||
|
||||
const plan = `# Plan
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("foldPlan (current goals are above Log; durable memory is below it)", (
|
||||
});
|
||||
});
|
||||
|
||||
const acceptancePlan = `# Plan
|
||||
const requirementPlan = `# Plan
|
||||
## User-visible result
|
||||
Produce a verified result.
|
||||
- preferred worker model: provider/model
|
||||
@@ -76,22 +76,16 @@ it.each([
|
||||
["[ ] goal: first", "[x] goal: first"],
|
||||
["[ ] write output", "[x] write output"],
|
||||
["proof.log", "new-proof.log"],
|
||||
["goal: second", "goal: changed second"],
|
||||
["provider/model", "provider/other"],
|
||||
["/worker.jsonl", "/resumed.jsonl"],
|
||||
["Old progress", "More history"],
|
||||
])("approval ignores maintenance change %s", (before, after) => {
|
||||
expect(goalAcceptanceSignature(acceptancePlan.replace(before, after), "first")).toBe(goalAcceptanceSignature(acceptancePlan, "first"));
|
||||
])("requirement context ignores maintenance change %s", (before, after) => {
|
||||
expect(planRequirements(requirementPlan.replace(before, after))).toBe(planRequirements(requirementPlan));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["exact bytes", "a different acceptance criterion"],
|
||||
["Produce a verified result.", "Produce two verified results."],
|
||||
])("approval changes when requirement %s changes", (before, after) => {
|
||||
expect(goalAcceptanceSignature(acceptancePlan.replace(before, after), "first")).not.toBe(goalAcceptanceSignature(acceptancePlan, "first"));
|
||||
});
|
||||
|
||||
it("does not give a signature to missing or duplicate goals", () => {
|
||||
expect(goalAcceptanceSignature(acceptancePlan, "missing")).toBeUndefined();
|
||||
expect(goalAcceptanceSignature(acceptancePlan.replace("goal: second", "goal: first"), "first")).toBeUndefined();
|
||||
])("requirement context changes when %s changes", (before, after) => {
|
||||
expect(planRequirements(requirementPlan.replace(before, after))).not.toBe(planRequirements(requirementPlan));
|
||||
});
|
||||
|
||||
+207
-152
@@ -2,11 +2,13 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSy
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createEditTool, type ExtensionAPI, SessionManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { openProjectPane } from "pi-subagents/project-panes";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import goalsExtension from "../src/index.js";
|
||||
import { upkeep, workerAssignment } from "../src/prompts.js";
|
||||
import { upkeep } from "../src/prompts.js";
|
||||
|
||||
vi.mock("pi-subagents/project-panes", () => ({ openProjectPane: vi.fn(async () => ({ ok: true, data: { bindingPath: "/project/.pi/subagents/project-pane.json", disposition: "opened", binding: { paneId: "native-pane", projectRoot: "/project", command: "pi" } } })) }));
|
||||
|
||||
@@ -23,9 +25,9 @@ async function waitFor(predicate: () => boolean, ms = 1500): Promise<void> {
|
||||
}
|
||||
function fixture(child = false) {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "goals-main-test-")); roots.push(cwd);
|
||||
const entries: any[] = child ? [{ type: "custom", customType: "pi-goals-main-supervisor-v1", data: { mode: "solo", child: true, signoffs: {} } }] : []; const hooks = new Map<string, any>(); const commands = new Map<string, any>(); const tools = new Map<string, any>();
|
||||
const entries: any[] = child ? [{ type: "custom", customType: "pi-goals-main-supervisor-v1", data: { mode: "solo", child: true } }] : []; const hooks = new Map<string, any>(); const commands = new Map<string, any>(); const tools = new Map<string, any>();
|
||||
const messages: any[] = [];
|
||||
const ctx = { cwd, sessionManager: { getBranch: () => entries, getSessionId: () => "copy-only", getSessionFile: () => join(cwd, "session.jsonl"), getLeafId: () => "reviewed-leaf", getHeader: () => ({ id: "copy-only", cwd }) }, hasUI: true, waitForIdle: vi.fn(async () => {}), newSession: vi.fn(async (_options: any) => ({ cancelled: false })), switchSession: vi.fn(async (_path: string) => ({ cancelled: false })), hasPendingMessages: vi.fn(() => false), ui: {
|
||||
const ctx = { cwd, isIdle: vi.fn(() => true), sessionManager: { getBranch: () => entries, getSessionId: () => "copy-only", getSessionFile: () => join(cwd, "session.jsonl"), getLeafId: () => "reviewed-leaf", getHeader: () => ({ id: "copy-only", cwd }) }, hasUI: true, hasPendingMessages: vi.fn(() => false), ui: {
|
||||
getEditorText: vi.fn(() => ""), theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async (_title: string, _options: string[]) => "Ready"), editor: vi.fn(),
|
||||
} };
|
||||
let registration: any;
|
||||
@@ -41,7 +43,7 @@ function fixture(child = false) {
|
||||
sendUserMessage: (content: string, options: any) => messages.push({ message: { content }, options, savedPrompt: true }),
|
||||
events: { emit: vi.fn((name, data) => { if (name === "intercom:extension-register") { registration = data; data.onReady(channel); } }) },
|
||||
getAllTools: vi.fn((): any[] => []),
|
||||
getCommands: () => [...commands.keys()].map(name => ({name})),
|
||||
getCommands: vi.fn((): any[] => [...commands.keys()].map(name => ({name}))),
|
||||
};
|
||||
goalsExtension(pi as unknown as ExtensionAPI);
|
||||
hooks.get("session_start")({}, ctx);
|
||||
@@ -66,8 +68,8 @@ function fixture(child = false) {
|
||||
await delay(25);
|
||||
};
|
||||
const start = (_id: string) => hooks.get("tool_call")({ toolName: "OpenGoalWorker" }, ctx);
|
||||
const launch = async (details: { id: string; sessionFile: string }) => {
|
||||
await tools.get("OpenGoalWorker").execute("open", { task: "Implement first output" }, undefined, undefined, ctx);
|
||||
const launch = async (details: { id: string; sessionFile: string; task?: string }) => {
|
||||
await tools.get("OpenGoalWorker").execute("open", { task: details.task ?? "Implement first output" }, undefined, undefined, ctx);
|
||||
const state = entries.at(-1).data;
|
||||
registration.onEvent({ type: "message", fromSessionId: details.id, payload: { type: "attached", to: state.worker.parentId, requestId: state.worker.requestId, plan: state.plan, sessionFile: details.sessionFile } });
|
||||
};
|
||||
@@ -164,18 +166,30 @@ it("clear preserves the plan without a backup, warns for misbound jobs and allow
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
await f.launch({ id: "stale", sessionFile: "/tmp/old-worker.jsonl" });
|
||||
const jobs = [
|
||||
{ id: "owned", name: "goals-copy-only", session: "copy-only", enabled: true },
|
||||
{ id: "older", name: "older-plan", session: "copy-only", enabled: true },
|
||||
{ id: "foreign", name: "goals-copy-only", session: "other", enabled: true },
|
||||
{ id: "unbound", name: "goals-copy-only", enabled: true },
|
||||
{ id: "owned", name: "goals-copy-only", action: "prompt", scope: "session", sessionFile: f.ctx.sessionManager.getSessionFile() },
|
||||
{ id: "older", name: "older-plan", action: "prompt", scope: "session", sessionFile: f.ctx.sessionManager.getSessionFile() },
|
||||
{ id: "foreign", name: "goals-copy-only", action: "prompt", scope: "session", sessionFile: "/other.jsonl" },
|
||||
{ id: "unbound", name: "goals-copy-only", action: "prompt", scope: "global" },
|
||||
];
|
||||
const schedule = join(f.ctx.cwd, ".pi/schedule-prompts.json"); writeFileSync(schedule, JSON.stringify({ version: 1, jobs }));
|
||||
const sourceInfo = { path: fileURLToPath(import.meta.resolve("@jl1990/pi-scheduler/extensions/scheduler/index.ts")) };
|
||||
f.pi.getCommands.mockReturnValue([...["schedules", "schedule-remove"].map(name => ({ name, source: "extension", sourceInfo: { path: "/foreign/index.ts" } })), ...["schedules:2", "schedule-remove:2"].map(name => ({ name, source: "extension", sourceInfo }))]);
|
||||
const branch = f.ctx.sessionManager.getBranch();
|
||||
branch.push({ type: "custom_message", id: "reviewed-leaf", customType: "scheduled-task", details: { includeAll: true, tasks: jobs } });
|
||||
writeFileSync(f.ctx.sessionManager.getSessionFile(), "persisted fixture\n");
|
||||
const before = f.messages.length; await f.command("clear");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", signoffs: {} });
|
||||
expect(JSON.parse(readFileSync(schedule, "utf8")).jobs).toEqual(jobs.slice(1));
|
||||
expect(f.pi.events.emit).toHaveBeenCalledWith("cron:change", { type: "remove", jobId: "owned" });
|
||||
expect(f.ctx.ui.notify).toHaveBeenCalledWith("Goal check-ins left unchanged (session binding missing or different): foreign, unbound. Inspect /schedule-prompt.", "warning");
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat" });
|
||||
expect(f.messages.slice(before)).toEqual([{ message: { content: "/schedules:2 all" }, options: { expandPromptTemplates: true, deliverAs: "followUp" }, savedPrompt: true }]);
|
||||
// Passive command results append public entries and persist, without message_end.
|
||||
branch.push({ type: "custom_message", id: "fresh-list", customType: "scheduled-task", details: { includeAll: true, tasks: jobs } });
|
||||
writeFileSync(f.ctx.sessionManager.getSessionFile(), "new persistence signal\n");
|
||||
await waitFor(() => f.messages.length === before + 2);
|
||||
expect(f.messages.at(-1)).toMatchObject({ message: { content: "/schedule-remove:2 owned" }, options: { expandPromptTemplates: true } });
|
||||
expect(f.messages).toHaveLength(before + 2);
|
||||
expect(f.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("missing/different session scope"), "warning");
|
||||
const wake = (id: string) => ({ source: "extension", text: `[Scheduled task ${id} fired]\nName: goals-copy-only\nAction: prompt\nType: interval\n` });
|
||||
expect(f.hooks.get("input")(wake("owned"), f.ctx)).toEqual({ action: "handled" });
|
||||
expect(f.hooks.get("input")(wake("foreign"), f.ctx)).toBeUndefined();
|
||||
expect(f.hooks.get("input")({ ...wake("owned"), source: "interactive" }, f.ctx)).toBeUndefined();
|
||||
const directory = join(f.ctx.cwd, ".pi/plan");
|
||||
expect(readdirSync(directory)).toEqual([basename(f.path)]);
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
@@ -186,14 +200,42 @@ it("clear preserves the plan without a backup, warns for misbound jobs and allow
|
||||
expect(readFileSync(next.plan, "utf8")).not.toContain("first output");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
expect(readdirSync(directory)).toHaveLength(2);
|
||||
expect(f.messages).toHaveLength(before + 1); // New alone queues its normal planning turn.
|
||||
expect(f.messages).toHaveLength(before + 3); // Two scheduler commands, then New's normal planning turn.
|
||||
});
|
||||
|
||||
it.each(["missing", "timeout", "busy-timeout", "non-agent-busy", "cancelled"])("clear fails closed for %s session observation", async kind => {
|
||||
const f = fixture();
|
||||
const sourceInfo = { path: fileURLToPath(import.meta.resolve("@jl1990/pi-scheduler/extensions/scheduler/index.ts")) };
|
||||
f.pi.getCommands.mockReturnValue(["schedules", "schedule-remove"].map(name => ({ name, source: "extension", sourceInfo })));
|
||||
const branch = f.ctx.sessionManager.getBranch();
|
||||
branch.push({ type: "custom", id: "reviewed-leaf" });
|
||||
if (kind !== "missing") writeFileSync(f.ctx.sessionManager.getSessionFile(), "persisted");
|
||||
if (kind === "busy-timeout" || kind === "non-agent-busy") f.ctx.isIdle.mockReturnValue(false);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await f.command("clear");
|
||||
if (kind === "busy-timeout") {
|
||||
// A run beginning after dispatch must suspend the already-armed deadline.
|
||||
f.hooks.get("agent_start")({}, f.ctx);
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
expect(f.ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("removal unconfirmed"), "warning");
|
||||
f.ctx.isIdle.mockReturnValue(true); await f.hooks.get("agent_settled")({}, f.ctx);
|
||||
}
|
||||
if (kind === "cancelled") f.shutdown();
|
||||
else await vi.advanceTimersByTimeAsync(5_000);
|
||||
} finally { vi.useRealTimers(); }
|
||||
branch.push({ type: "custom_message", id: "late-list", customType: "scheduled-task", details: { includeAll: true, tasks: [{ id: "owned", name: "goals-copy-only", action: "prompt", scope: "session", sessionFile: f.ctx.sessionManager.getSessionFile() }] } });
|
||||
writeFileSync(f.ctx.sessionManager.getSessionFile(), "late persistence");
|
||||
await delay(20);
|
||||
expect(f.messages.map(message => message.message.content)).toEqual(kind === "missing" ? [] : ["/schedules all"]);
|
||||
if (kind !== "cancelled") expect(f.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("removal unconfirmed"), "warning");
|
||||
});
|
||||
|
||||
it.each(["missing", "empty"])("clear resets a %s plan without a model call", async kind => {
|
||||
const f = fixture(); await f.draft(); const before = f.messages.length;
|
||||
if (kind === "missing") rmSync(f.path); else writeFileSync(f.path, "");
|
||||
await f.command("clear");
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", signoffs: {} });
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat" });
|
||||
expect(f.messages).toHaveLength(before);
|
||||
});
|
||||
|
||||
@@ -279,7 +321,7 @@ it.each(["FIRST OUTPUT", "renamed output", "duplicate", "historical"])("completi
|
||||
}
|
||||
const after = readFileSync(f.path, "utf8");
|
||||
if (subject === "renamed output" || subject === "duplicate") expect(after).toBe(before);
|
||||
else { expect(after).toContain("- [x] goal: first output"); expect(after.split("## Log")[1]).toContain("\n- [ ] goal: first output\n"); expect(after).toContain("- [ ] unrelated task"); }
|
||||
else { expect(after).toContain("- [✓] goal: first output"); expect(after.split("## Log")[1]).toContain("\n- [ ] goal: first output\n"); expect(after).toContain("- [ ] unrelated task"); }
|
||||
});
|
||||
|
||||
it("rejects an existing zero-byte evidence file", async () => {
|
||||
@@ -290,23 +332,28 @@ it("rejects an existing zero-byte evidence file", async () => {
|
||||
expect(result.content[0].text).toContain("Empty evidence"); expect(readFileSync(f.path, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("requires actual nonempty evidence, distinguishes manual ticks, and retains signoffs on reload", async () => {
|
||||
it("requires actual nonempty evidence, distinguishes manual ticks, and retains reviewed markers through same-context restoration", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
const complete = (goal: string, evidence: string[], signal?: AbortSignal) => f.tools.get("CompleteGoal").execute("t", { goal, evidence, observation: "Inspected exact saved bytes" }, signal, undefined, f.ctx);
|
||||
expect((await complete("first output", ["missing.log"])).content[0].text).toContain("Evidence unavailable");
|
||||
mkdirSync(join(f.ctx.cwd, "evidence")); writeFileSync(join(f.ctx.cwd, "evidence/pass.log"), "actual fixture bytes\n");
|
||||
expect((await complete("first output", ["evidence/pass.log"], AbortSignal.abort())).content[0].text).toContain("Cancelled");
|
||||
await complete("first output", ["evidence/pass.log"]);
|
||||
await f.command(`attach ${f.path}`);
|
||||
writeFileSync(f.path, readFileSync(f.path, "utf8").replace("[ ] goal: second", "[x] goal: second"));
|
||||
f.hooks.get("session_start")({}, f.ctx);
|
||||
expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("goals", "👀 1/2 goals");
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toContain("✓ G1: first output");
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toContain("x G2: second output");
|
||||
f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
const reminder = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message.content;
|
||||
expect(reminder).toContain("[x] goal: second output");
|
||||
expect(reminder).not.toContain("first output");
|
||||
writeFileSync(f.path, readFileSync(f.path, "utf8").replace("[x] goal: first", "[ ] goal: first"));
|
||||
writeFileSync(f.path, readFileSync(f.path, "utf8").replace("[✓] goal: first", "[ ] goal: first"));
|
||||
f.hooks.get("agent_end")({ messages: [] }, f.ctx);
|
||||
expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("goals", "👀 0/2 goals");
|
||||
writeFileSync(f.path, readFileSync(f.path, "utf8").replace("[ ] goal: first", "[x] goal: first"));
|
||||
f.hooks.get("agent_end")({ messages: [] }, f.ctx);
|
||||
expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("goals", "👀 0/2 goals");
|
||||
f.shutdown();
|
||||
@@ -400,11 +447,19 @@ it("gives pause scheduler guidance but clears on exit without a model prompt", a
|
||||
expect(stop).toContain("Do not add, enable or recreate any job");
|
||||
expect(stop).not.toContain("interval '1h'");
|
||||
expect(stop).toContain("Remote stop is NOT yet confirmed");
|
||||
const task = { id: "owned", name: "goals-copy-only", action: "prompt", scope: "session", sessionFile: f.ctx.sessionManager.getSessionFile(), disabledAt: "2026-09-14T01:00:00Z" };
|
||||
f.hooks.get("tool_result")({ toolName: "manage_scheduled_task", input: { action: "disable" }, details: { task } }, f.ctx);
|
||||
f.shutdown(); f.hooks.get("session_start")({}, f.ctx);
|
||||
await f.command("resume");
|
||||
expect(f.messages.at(-1).message.content).toContain(task.disabledAt);
|
||||
expect(f.messages.at(-1).message.content).toContain("Leave later human edits unchanged");
|
||||
expect(f.hooks.get("tool_call")({ toolName: "schedule_task", input: { name: task.name, action: "prompt", type: "interval", scope: "session", prompt: "custom\n indentation" } }, f.ctx).block).toBe(true);
|
||||
f.ctx.sessionManager.getBranch().push({ type: "message", message: { role: "toolResult", toolName: "schedule_task", details: { task } } });
|
||||
expect(f.hooks.get("tool_call")({ toolName: "manage_scheduled_task", input: { action: "update", id: "own", prompt: "custom\n indentation" } }, f.ctx).block).toBe(true);
|
||||
const before = f.messages.length;
|
||||
await f.command("exit");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", signoffs: {} });
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat" });
|
||||
});
|
||||
|
||||
it("requires a full-plan review turn before recording the final goal", async () => {
|
||||
@@ -435,8 +490,8 @@ it("requires a full-plan review turn before recording the final goal", async ()
|
||||
f.hooks.get("turn_end")({}, f.ctx); // Evidence-reading tool round must not invalidate this review.
|
||||
const finalText = (await complete("second output")).content[0].text;
|
||||
expect(finalText).toContain("All non-cancelled goals are reviewed.");
|
||||
expect(finalText).toContain('job named "goals-copy-only"');
|
||||
expect(finalText).toContain("leave other jobs untouched");
|
||||
expect(finalText).toContain('name "goals-copy-only"');
|
||||
expect(finalText).toContain("Never use cleanup or change foreign tasks");
|
||||
for (let i = 0; i < 10; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
|
||||
f.shutdown();
|
||||
@@ -519,21 +574,22 @@ it("requires confirmed worker stop before solo takeover and never lets two write
|
||||
expect(text).toContain("self-verification");
|
||||
});
|
||||
|
||||
it("attaches an existing plan without restarting completed work, and restores its noted worker session", async () => {
|
||||
it("leaves an unverified external plan and its noted worker untouched", async () => {
|
||||
const f = fixture();
|
||||
const existing = join(f.ctx.cwd, "existing.md");
|
||||
writeFileSync(existing, "# Plan\n- preferred worker model: deepseek flash\n- worker session: /tmp/attach-child.jsonl\n- [ ] goal: attached goal\n\n## Log\n- previous progress kept\n");
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
|
||||
await f.command(`attach ${existing}`);
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
expect(f.entries.at(-1).data.plan).toBe(existing);
|
||||
expect(f.messages.at(-1).message.content).toContain("without restarting completed work");
|
||||
expect(f.messages.at(-1).message.content).toContain("/tmp/attach-child.jsonl");
|
||||
expect(f.entries).toEqual([]);
|
||||
expect(f.messages).toEqual([]);
|
||||
expect(f.ctx.ui.select).not.toHaveBeenCalled();
|
||||
expect(readFileSync(existing, "utf8")).toContain("previous progress kept");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Original supervisor unknown"), "warning");
|
||||
});
|
||||
|
||||
it("attaches directly into solo mode and reports the recorded session in status", async () => {
|
||||
const f = fixture();
|
||||
const existing = join(f.ctx.cwd, "existing.md");
|
||||
it("retains same-current-plan solo recovery and reports the recorded session in status", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
const existing = f.path;
|
||||
writeFileSync(existing, "# Plan\n- worker session: /tmp/attach-child.jsonl\n- [ ] goal: attached goal\n\n## Log\n");
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped");
|
||||
await f.command(`attach ${existing} solo`);
|
||||
@@ -565,9 +621,10 @@ it.each(["exit", "quit", "clear", "menu"])("%s exits planning with the draft pre
|
||||
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", undefined);
|
||||
expect(readFileSync(f.path, "utf8")).toContain("first output");
|
||||
expect(f.messages.length).toBe(before); // notify only, no model turn started
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
|
||||
await f.command(`attach ${f.path}`);
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
expect(f.entries.at(-1).data.mode).toBe("chat");
|
||||
expect(f.messages.length).toBe(before);
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Cannot verify ownership"), "warning");
|
||||
});
|
||||
|
||||
it("records the preferred worker model as a visible plan preference", async () => {
|
||||
@@ -591,7 +648,7 @@ it.each(["solo", "attach"])("%s takeover cannot bypass confirmation or survive a
|
||||
expect(f.entries.at(-1).data.workerStopped).not.toBe(true);
|
||||
});
|
||||
|
||||
it("attach solo requires stop confirmation for a noted worker even in a fresh session", async () => {
|
||||
it("external attach solo cannot turn a noted worker or stop checkbox into ownership proof", async () => {
|
||||
const f = fixture(); const path = join(f.ctx.cwd, "saved.md");
|
||||
writeFileSync(path, `# Plan\n- worker session: /tmp/known.jsonl\n${f.plan}`);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Cancel");
|
||||
@@ -599,21 +656,29 @@ it("attach solo requires stop confirmation for a noted worker even in a fresh se
|
||||
expect(f.entries).toHaveLength(0);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped");
|
||||
await f.command(`attach ${path} solo`);
|
||||
expect(f.entries.at(-1).data).toMatchObject({ mode: "solo", workerStopped: true, worker: { sessionFile: "/tmp/known.jsonl" } });
|
||||
expect(f.entries).toHaveLength(0);
|
||||
expect(f.ctx.ui.select).not.toHaveBeenCalled();
|
||||
expect(f.messages).toEqual([]);
|
||||
expect(readFileSync(path, "utf8")).toContain("worker session: /tmp/known.jsonl");
|
||||
});
|
||||
|
||||
it("retains the stopped session reference and refuses replacement in the first-session port", async () => {
|
||||
it("retains current solo authority and stopped-session reference when external adoption is blocked", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
await f.launch({ id: "child", sessionFile: "/tmp/prior.jsonl" });
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
const binding = f.entries.at(-1).data.worker;
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command(`attach ${f.path} solo`);
|
||||
expect(f.entries.at(-1).data.worker).toEqual(binding);
|
||||
const other = join(f.ctx.cwd, "another.md"); writeFileSync(other, "- [ ] goal: next\n## Log\n");
|
||||
const before = f.entries.at(-1), messageCount = f.messages.length;
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
|
||||
await f.command(`attach ${other}`);
|
||||
expect(f.entries.at(-1).data).toMatchObject({ mode: "planning", plan: other, workerStopped: true, worker: { sessionFile: "/tmp/prior.jsonl" } });
|
||||
await f.command("ready");
|
||||
expect(f.entries.at(-1).data).toMatchObject({ mode: "solo", plan: f.path, workerStopped: true, worker: { sessionFile: "/tmp/prior.jsonl" } });
|
||||
expect(f.entries.at(-1)).toBe(before); expect(f.messages).toHaveLength(messageCount);
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining('read({path:"/tmp/prior.jsonl"})'), "warning");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining('herdr pane process-info --pane "native-pane"'), "warning");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("locate exact ID child"), "warning");
|
||||
const response = await f.tools.get("OpenGoalWorker").execute("open", { task: "next task" }, undefined, undefined, f.ctx);
|
||||
expect(response.content[0].text).toContain("already recorded");
|
||||
expect(response.content[0].text).toContain("solo");
|
||||
expect(f.entries.at(-1).data.workerStopped).toBe(true);
|
||||
});
|
||||
|
||||
@@ -621,7 +686,8 @@ it("solo closes a pending plan watcher and sends removal-only scheduler guidance
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
await f.atomicWrite(f.plan.replace("first output", "changed output"));
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
expect(f.messages.at(-1).message.content).toContain('job named "goals-copy-only" bound to session "copy-only"');
|
||||
expect(f.messages.at(-1).message.content).toContain('name "goals-copy-only", action prompt, scope session');
|
||||
expect(f.messages.at(-1).message.content).toContain("sessionFile exactly your current saved session");
|
||||
expect(f.messages.at(-1).message.content).toContain("Do not add, enable or recreate any job");
|
||||
await delay(250);
|
||||
await f.atomicWrite(f.plan.replace("first output", "solo output"));
|
||||
@@ -629,7 +695,7 @@ it("solo closes a pending plan watcher and sends removal-only scheduler guidance
|
||||
expect(f.changed()).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["missing", "empty", "directory"])("%s plan snapshots never erase signoffs and resync retries after repair", async failure => {
|
||||
it.each(["missing", "empty", "directory"])("%s plan snapshots remain unavailable and resync retries after repair", async failure => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
writeFileSync(join(f.ctx.cwd, "proof.log"), "PASS\n");
|
||||
await f.tools.get("CompleteGoal").execute("c", { goal: "first output", evidence: ["proof.log"], observation: "Observed PASS" }, undefined, undefined, f.ctx);
|
||||
@@ -638,7 +704,7 @@ it.each(["missing", "empty", "directory"])("%s plan snapshots never erase signof
|
||||
else { rmSync(f.path); if (failure === "directory") mkdirSync(f.path); }
|
||||
await delay(250); // also exercise unavailable read after debounce has expired
|
||||
f.hooks.get("agent_end")({ messages: [] }, f.ctx);
|
||||
expect(f.entries.at(-1).data.signoffs["first output"]).toBeDefined();
|
||||
expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("goals", expect.stringContaining("unavailable"));
|
||||
f.hooks.get("session_compact")();
|
||||
const unavailable = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
expect(unavailable.systemPrompt).toContain("unavailable");
|
||||
@@ -646,10 +712,9 @@ it.each(["missing", "empty", "directory"])("%s plan snapshots never erase signof
|
||||
if (failure === "directory") rmSync(f.path, { recursive: true });
|
||||
writeFileSync(f.path, signed);
|
||||
const resync = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
expect(resync.message.content).toContain("- [x] goal: first output");
|
||||
expect(resync.message.content).toContain("- [✓] goal: first output");
|
||||
expect(readFileSync(f.path, "utf8")).toContain("Observed PASS");
|
||||
await delay(250);
|
||||
expect(f.entries.at(-1).data.signoffs["first output"]).toBeDefined();
|
||||
expect(f.changed()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -668,10 +733,11 @@ it("ignores post-completion maintenance but reviews evidence, requirement or man
|
||||
f.hooks.get("message_end")({ message: { role: "user", content: f.messages.at(-1).message.content } });
|
||||
await f.atomicWrite(signed.replace("## Log", "- discriminator: exact bytes and trailing newline\n## Log"));
|
||||
await waitFor(() => f.changed() === 2);
|
||||
expect(readFileSync(f.path, "utf8")).toContain("[✓] goal: first output"); // Supervisor decides whether changed requirements require reopening.
|
||||
f.hooks.get("message_end")({ message: { role: "user", content: f.messages.at(-1).message.content } });
|
||||
await f.atomicWrite(signed.replace("[x] goal: first", "[ ] goal: first"));
|
||||
await f.atomicWrite(signed.replace("[✓] goal: first", "[ ] goal: first"));
|
||||
await waitFor(() => f.changed() === 3);
|
||||
expect(f.entries.at(-1).data.signoffs["first output"]).toBeUndefined();
|
||||
expect(readFileSync(f.path, "utf8")).toContain("[ ] goal: first output");
|
||||
});
|
||||
|
||||
it("cancelled goals do not prevent final cleanup, and solo writes self-verification in Log", async () => {
|
||||
@@ -714,15 +780,15 @@ it("persisted child attaches its plan without a widget, retains task context, an
|
||||
|
||||
it("prioritizes unfinished goals and says when the widget list is truncated", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
writeFileSync(f.path, "- [x] goal: completed one\n- [x] goal: completed two\n- [/] goal: active work\n- [ ] goal: open one\n- [ ] goal: open two\n");
|
||||
writeFileSync(f.path, "- [✓] goal: completed one\n- [✓] goal: completed two\n- [/] goal: active work\n- [ ] goal: open one\n- [ ] goal: open two\n");
|
||||
await f.command("ready");
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual([relative(f.ctx.cwd, f.path), "◼ G3: active work", "◻ G4: open one", "◻ G5: open two", "… 2 ✓"]);
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual(["◼ G3: active work", "◻ G4: open one", "◻ G5: open two", `… 2 ✓; ${relative(f.ctx.cwd, f.path)}`]);
|
||||
f.shutdown();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["[x]", "[ ]", "[ ]", "… 1 ✓, 2 ◻"],
|
||||
["[/]", "[x]", "[-]", "… 1 ✓, 1 ◼, 1 ✗"],
|
||||
["[✓]", "[ ]", "[ ]", "… 1 ✓, 2 ◻"],
|
||||
["[/]", "[✓]", "[-]", "… 1 ✓, 1 ◼, 1 ✗"],
|
||||
["[ ]", "[ ]", "[ ]", "… 3 ◻"],
|
||||
])("summarizes only hidden goal statuses: %s %s %s", async (first, second, third, summary) => {
|
||||
const f = fixture(); await f.draft();
|
||||
@@ -730,7 +796,7 @@ it.each([
|
||||
writeFileSync(f.path, marks.map((mark, index) => `- ${mark} goal: output ${index + 1}`).join("\n"));
|
||||
await f.command("ready");
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual([
|
||||
relative(f.ctx.cwd, f.path), "◼ G1: output 1", "◼ G2: output 2", "◼ G3: output 3", summary,
|
||||
"◼ G1: output 1", "◼ G2: output 2", "◼ G3: output 3", `${summary}; ${relative(f.ctx.cwd, f.path)}`,
|
||||
]);
|
||||
f.shutdown();
|
||||
});
|
||||
@@ -741,11 +807,17 @@ it.each(["solo", "supervising"])("%s widget omits long tasks without altering th
|
||||
writeFileSync(f.path, text);
|
||||
if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); }
|
||||
else await f.command("ready");
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual([relative(f.ctx.cwd, f.path), "◼ G1: first output", "◻ G2: second output"]);
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual(["◼ G1: first output", "◻ G2: second output", relative(f.ctx.cwd, f.path)]);
|
||||
f.ctx.cwd = join(f.ctx.cwd, "another-project", "nested");
|
||||
await f.command("status");
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual([`${basename(f.path)} (external)`, "◼ G1: first output", "◻ G2: second output"]);
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual(["◼ G1: first output", "◻ G2: second output", `${basename(f.path)} (external)`]);
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining(`Plan: ${f.path}`), "info");
|
||||
f.ctx.mode = "tui";
|
||||
await f.command("status");
|
||||
const widget = f.ctx.ui.setWidget.mock.lastCall?.[1]();
|
||||
expect(widget.render(100)).toEqual([" ◼ G1: first output", " ◻ G2: second output", ` ${basename(f.path)} (external)`]);
|
||||
expect(widget.render(24)).toHaveLength(3);
|
||||
expect(widget.render(24).every((line: string) => visibleWidth(line) <= 24)).toBe(true);
|
||||
expect(readFileSync(f.path, "utf8")).toBe(text);
|
||||
});
|
||||
|
||||
@@ -859,7 +931,7 @@ it("changed plan or shutdown during takeover never grants solo permission", asyn
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
});
|
||||
|
||||
it("requires explicit supervisor ownership confirmation when attaching an existing plan", async () => {
|
||||
it("blocks unknown external ownership without offering an attestation or launching work", async () => {
|
||||
const f = fixture(); const path = join(f.ctx.cwd, "shared.md");
|
||||
writeFileSync(path, f.plan);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Cancel");
|
||||
@@ -867,7 +939,10 @@ it("requires explicit supervisor ownership confirmation when attaching an existi
|
||||
expect(f.entries).toHaveLength(0);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
|
||||
await f.command(`attach ${path}`);
|
||||
expect(f.entries.at(-1).data).toMatchObject({ mode: "planning", plan: path });
|
||||
expect(f.entries).toHaveLength(0);
|
||||
expect(f.ctx.ui.select).not.toHaveBeenCalled();
|
||||
expect(f.messages).toEqual([]);
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("supported Intercom roster does not identify per-plan supervisors"), "warning");
|
||||
});
|
||||
|
||||
it("does not approve cancelled goals or display current completion for an unavailable plan", async () => {
|
||||
@@ -882,11 +957,6 @@ it("does not approve cancelled goals or display current completion for an unavai
|
||||
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", [expect.stringContaining("unavailable")]);
|
||||
});
|
||||
|
||||
it("keeps interactive workers open", () => {
|
||||
const task = workerAssignment("/plan.md", "parent", "request", "bounded task");
|
||||
expect(task).toContain("do not exit, reset, switch session or close the pane");
|
||||
});
|
||||
|
||||
it.each(["stop", "exit", "edit", "session_tree"])("discards pending upkeep after %s instead of reviving stale work", async change => {
|
||||
const f = fixture(); await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
@@ -957,9 +1027,8 @@ it("serializes CompleteGoal after a real built-in edit without losing either suc
|
||||
await editing; await completing;
|
||||
const text = readFileSync(f.path, "utf8");
|
||||
expect(text).toContain("# Plan with progress note");
|
||||
expect(text).toContain("- [x] goal: first output");
|
||||
expect(text).toContain("- [✓] goal: first output");
|
||||
expect(text).toContain("Parent review:");
|
||||
expect(f.entries.at(-1).data.signoffs["first output"]).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(["pause", "replace", "tree", "cancel"])("rejects queued completion after %s while waiting for a file mutation", async change => {
|
||||
@@ -982,7 +1051,7 @@ it.each(["pause", "replace", "tree", "cancel"])("rejects queued completion after
|
||||
expect(response.content[0].text).not.toContain("Recorded parent judgment");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
expect(readFileSync(current, "utf8")).toBe(before);
|
||||
expect(f.entries.at(-1).data.signoffs).toEqual({});
|
||||
expect(readFileSync(f.path, "utf8")).not.toContain("[✓]");
|
||||
});
|
||||
|
||||
it.each([true, false])("solo stop/reload/resume preserves ownership with companion tools=%s", async tools => {
|
||||
@@ -1008,26 +1077,7 @@ it("rejects blank goal subjects on Ready and CompleteGoal", async () => {
|
||||
const before = readFileSync(f.path, "utf8");
|
||||
await f.tools.get("CompleteGoal").execute("blank", { goal: " ", evidence: ["proof.log"], observation: "Read PASS" }, undefined, undefined, f.ctx);
|
||||
expect(readFileSync(f.path, "utf8")).toBe(before);
|
||||
expect(f.entries.at(-1).data.signoffs).toEqual({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["criterion", false], ["scope", false], ["other goal", true], ["task", true], ["evidence", true], ["Log", true],
|
||||
])("%s edits retain signoff=%s according to reviewed acceptance", async (change, retained) => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
writeFileSync(f.path, f.plan.replace("# Plan", "# Plan\nShared scope: exact bytes").replace("goal: first output\n", "goal: first output\n - discriminator: original criterion\n - tasks:\n - [ ] original task\n - evidence: original evidence\n"));
|
||||
writeFileSync(join(f.ctx.cwd, "proof.log"), "PASS\n");
|
||||
await f.tools.get("CompleteGoal").execute("complete", { goal: "first output", evidence: ["proof.log"], observation: "Read PASS" }, undefined, undefined, f.ctx);
|
||||
const signed = readFileSync(f.path, "utf8");
|
||||
const replacements: Record<string, [string, string]> = {
|
||||
criterion: ["original criterion", "new criterion"], scope: ["exact bytes", "two files"], "other goal": ["second output", "new second output"],
|
||||
task: ["[ ] original task", "[x] maintained task"], evidence: ["original evidence", "additional evidence"], Log: ["## Log", "## Log\n- historical note"],
|
||||
};
|
||||
writeFileSync(f.path, signed.replace(...replacements[change as string]));
|
||||
f.hooks.get("agent_end")({ messages: [] }, f.ctx);
|
||||
expect(Boolean(f.entries.at(-1).data.signoffs["first output"])).toBe(retained);
|
||||
f.hooks.get("session_start")({}, f.ctx);
|
||||
expect(Boolean(f.entries.at(-1).data.signoffs["first output"])).toBe(retained);
|
||||
expect(readFileSync(f.path, "utf8")).not.toContain("[✓]");
|
||||
});
|
||||
|
||||
it("passive pause is visible immediately while its model notice waits safely for the next prompt", async () => {
|
||||
@@ -1044,39 +1094,47 @@ it("opens no-focus, records explicit attachment only, and wakes review only for
|
||||
f.channel.listSessions.mockRejectedValueOnce(new Error("Intercom is not connected"));
|
||||
const waiting = await f.tools.get("OpenGoalWorker").execute("open", { task: "first" }, undefined, undefined, f.ctx);
|
||||
expect(waiting.content[0].text).toContain("still connecting"); expect(openProjectPane).not.toHaveBeenCalled();
|
||||
await f.launch({ id: "worker-id", sessionFile: "/tmp/native-worker.jsonl" });
|
||||
await f.launch({ id: "worker-id", sessionFile: "/tmp/native-worker.jsonl", task: "Inspect [cached interruption audit](slop/audits/20260916_job1551_a2_cached_interruption_audit.md) before rerun" });
|
||||
expect(openProjectPane).toHaveBeenCalledWith(expect.objectContaining({ cwd: f.ctx.cwd, focus: false }));
|
||||
expect(vi.mocked(openProjectPane).mock.calls[0][0]).not.toHaveProperty("message");
|
||||
expect(vi.mocked(openProjectPane).mock.calls[0][0].message).toContain("WAIT for an explicit assignment");
|
||||
const worker = f.entries.at(-1).data.worker;
|
||||
expect(worker).toMatchObject({ paneId: "native-pane", intercomId: "worker-id", sessionFile: "/tmp/native-worker.jsonl" });
|
||||
const notice = { type: "stopped", to: worker.parentId, requestId: worker.requestId, plan: f.path, text: "Blocked: input missing" };
|
||||
expect(f.messages.at(-1)).toMatchObject({ message: { customType: "pi-goals-supervision", display: true, content: expect.stringContaining("Metadata only; no acknowledgement or review turn requested") }, options: { triggerTurn: false } });
|
||||
expect(f.messages.at(-1).savedPrompt).toBeUndefined();
|
||||
const notice = { type: "stopped", to: worker.parentId, requestId: worker.requestId, plan: f.path, entryId: "revision-1", kind: "blocker", text: "Blocked: input missing" };
|
||||
const count = f.messages.length;
|
||||
for (const fromSessionId of [worker.parentId, "foreign-id"]) f.event({ type: "message", fromSessionId, payload: notice });
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, plan: "/foreign.md" } });
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, requestId: "stale" } });
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, entryId: undefined } });
|
||||
expect(f.messages).toHaveLength(count);
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, identity: { sessionId: "worker-session", sessionFile: "/tmp/native-worker.jsonl", paneId: "native-pane", leafId: "reviewed-leaf", requestId: worker.requestId, durable: true } } });
|
||||
await f.tools.get("OpenGoalWorker").execute("fresh", { action: "fresh", task: "later work", reviewedThrough: "reviewed-leaf" }, undefined, undefined, f.ctx);
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: notice });
|
||||
expect(f.entries.at(-1).data.worker.pending).toBeDefined();
|
||||
const pendingId = f.entries.at(-1).data.worker.pending.id;
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, type: "attached", sessionFile: "/tmp/native-worker.jsonl" } });
|
||||
expect(f.entries.at(-1).data.worker.pending.id).toBe(pendingId);
|
||||
expect(f.messages.at(-1).options).toEqual({ deliverAs: "nextTurn" });
|
||||
expect(f.messages.at(-1).message.content).not.toContain("Native fresh confirmed");
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: notice });
|
||||
expect(f.messages.at(-1)).toMatchObject({ savedPrompt: true, message: { content: expect.stringContaining("Blocked: input missing") } });
|
||||
expect(f.entries.at(-1).data.signoffs).toEqual({});
|
||||
expect(f.messages.at(-2)).toMatchObject({ message: { customType: "pi-goals-supervision", display: false, content: expect.stringContaining("## Worker revision report") } });
|
||||
expect(f.messages.at(-2)?.message.content).toContain("Blocked: input missing");
|
||||
expect(f.ctx.sessionManager.getBranch().some((entry: any) => entry.customType === "pi-goals-notice" && entry.data.content.includes("## Worker revision report"))).toBe(true);
|
||||
expect(f.messages.at(-1)?.message.content).toContain("## Worker revision reviews");
|
||||
expect(f.messages.at(-1)?.message.content).toContain("cached interruption audit");
|
||||
expect(f.messages.at(-1)?.message.content).not.toContain("](");
|
||||
const afterFirstRevision = f.messages.length;
|
||||
for (const text of ["Done: output.txt", "Error: execution failed"]) f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, text } });
|
||||
expect(f.messages).toHaveLength(afterFirstRevision);
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, entryId: "waiting-1", kind: "waiting", text: "Pueue 1552 is running." } });
|
||||
expect(f.messages.at(-1)?.message.content).toContain("## Worker status: waiting");
|
||||
await f.command("status");
|
||||
expect(f.ctx.ui.notify.mock.lastCall?.[0]).toContain("Latest worker status event: waiting");
|
||||
expect(f.ctx.ui.notify.mock.lastCall?.[0]).not.toContain("waiting-1");
|
||||
expect(readFileSync(f.path, "utf8")).not.toContain("[✓]");
|
||||
await f.command("stop");
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: notice });
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, entryId: "revision-2", text: "New report during pause" } });
|
||||
expect(f.messages.at(-1).options).toEqual({ deliverAs: "nextTurn" });
|
||||
await f.command("clear");
|
||||
const cleared = f.messages.length; f.event({ type: "message", fromSessionId: "worker-id", payload: notice });
|
||||
const cleared = f.messages.length; f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, entryId: "revision-3" } });
|
||||
expect(f.messages).toHaveLength(cleared);
|
||||
});
|
||||
|
||||
it("ordinary project peer explicitly attaches as worker, never gaining approval authority", async () => {
|
||||
const f = fixture(); const path = join(f.ctx.cwd, "supplied.md"); writeFileSync(path, f.plan);
|
||||
Object.assign(f.ctx, { model: { provider: "offline", id: "inherited" } });
|
||||
const tool = f.tools.get("AttachGoalPlan");
|
||||
await tool.execute("attach", { path }, undefined, undefined, f.ctx);
|
||||
expect(f.entries).toHaveLength(0);
|
||||
@@ -1085,20 +1143,30 @@ it("ordinary project peer explicitly attaches as worker, never gaining approval
|
||||
expect(waiting.content[0].text).toContain("still connecting"); expect(f.entries).toHaveLength(0);
|
||||
await tool.execute("attach", { path, parent: "live-parent", requestId: "assignment-id" }, undefined, undefined, f.ctx);
|
||||
expect(f.entries.at(-1).data).toMatchObject({ child: true, parent: { intercomId: "live-parent", requestId: "assignment-id" }, plan: path });
|
||||
expect(f.channel.publish).toHaveBeenCalledWith(expect.objectContaining({ type: "attached", to: "live-parent", sessionFile: f.ctx.sessionManager.getSessionFile() }), { audience: "capable" });
|
||||
expect(f.channel.publish).toHaveBeenCalledWith(expect.objectContaining({ type: "attached", to: "live-parent", sessionFile: f.ctx.sessionManager.getSessionFile(), identity: expect.objectContaining({ model: "offline/inherited" }) }), { audience: "capable" });
|
||||
await f.command("ready"); await f.command("solo");
|
||||
const reply = await f.tools.get("CompleteGoal").execute("complete", { goal: "first output", evidence: [path], observation: "claim" }, undefined, undefined, f.ctx);
|
||||
expect(reply.content[0].text).toContain("only to the active parent");
|
||||
const next = join(f.ctx.cwd, "next.md"); writeFileSync(next, f.plan);
|
||||
const before = f.entries.length;
|
||||
await tool.execute("missing", { path: next }, undefined, undefined, f.ctx);
|
||||
f.channel.listSessions.mockResolvedValue([{ id: "live-parent", pid: process.pid + 1 }, { id: "foreign-parent", pid: process.pid + 2 }]);
|
||||
await tool.execute("foreign", { path: next, parent: "foreign-parent", requestId: "next" }, undefined, undefined, f.ctx);
|
||||
expect(f.entries).toHaveLength(before); // neither a missing request nor a live stranger can take over
|
||||
await tool.execute("next", { path: next, parent: "live-parent", requestId: "next" }, undefined, undefined, f.ctx);
|
||||
expect(f.entries.at(-1).data).toMatchObject({ plan: next, parent: { intercomId: "live-parent", requestId: "next" } });
|
||||
f.hooks.get("session_start")({}, f.ctx); f.hooks.get("session_compact")();
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).systemPrompt).toContain("delegated implementation worker");
|
||||
f.hooks.get("agent_end")({ messages: [{ role: "assistant", content: [{ type: "text", text: "Result at output.txt" }], stopReason: "stop" }] }, f.ctx);
|
||||
const assistant = { role: "assistant", content: [{ type: "text", text: "Result at output.txt" }], stopReason: "stop" };
|
||||
f.ctx.sessionManager.getBranch().push({ type: "message", id: "saved-report", message: assistant });
|
||||
f.hooks.get("agent_end")({ messages: [assistant] }, f.ctx);
|
||||
expect(f.channel.publish).toHaveBeenLastCalledWith(expect.objectContaining({ type: "stopped", text: "Result at output.txt" }), { audience: "capable" });
|
||||
});
|
||||
|
||||
it("pending or failed native opening never permits an unconfirmed second writer", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
const tool = f.tools.get("OpenGoalWorker");
|
||||
expect(tool.parameters.properties.task.minLength ?? 0).toBe(0);
|
||||
expect(tool.parameters.properties.task.minLength).toBe(1);
|
||||
expect((await tool.execute("empty", { task: "" }, undefined, undefined, f.ctx)).content[0].text).toContain("task");
|
||||
let release!: () => void;
|
||||
vi.mocked(openProjectPane).mockImplementationOnce(() => new Promise((_resolve, reject) => { release = () => reject(new Error("connection lost after open")); }));
|
||||
@@ -1110,56 +1178,43 @@ it("pending or failed native opening never permits an unconfirmed second writer"
|
||||
const again = await f.tools.get("OpenGoalWorker").execute("open", { task: "again" }, undefined, undefined, f.ctx);
|
||||
expect(again.content[0].text).toContain("already recorded");
|
||||
expect(openProjectPane).toHaveBeenCalledTimes(1);
|
||||
const worker = f.entries.at(-1).data.worker, saved = join(f.ctx.cwd, "owned-worker.jsonl");
|
||||
writeFileSync(saved, [{ type: "session", id: "old-worker", cwd: f.ctx.cwd }, { type: "custom", customType: "pi-goals-main-supervisor-v1", data: { child: true, mode: "solo", plan: f.path, parent: { intercomId: worker.parentId, selfId: "old-worker" } } }, { type: "message", message: { role: "assistant", content: [{ type: "text", text: "prior work" }] } }].map(entry => JSON.stringify(entry)).join("\n"));
|
||||
f.event({ type: "message", fromSessionId: "old-worker", payload: { type: "attached", to: worker.parentId, requestId: worker.requestId, plan: f.path, sessionFile: saved } });
|
||||
expect((await tool.execute("replay", { action: "recover", task: "repeat work", writersStopped: true }, undefined, undefined, f.ctx)).content[0].text).toContain("No task replay");
|
||||
await tool.execute("recover", { action: "recover", task: "", model: "", sessionFile: "", reviewedThrough: "", writersStopped: true }, undefined, undefined, f.ctx);
|
||||
expect(f.entries.at(-1).data.worker.pending).toMatchObject({ action: "recover", sessionFile: saved, task: "" });
|
||||
expect(openProjectPane).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("verified native control preserves drafts/history and fresh context uses only the public replacement API", async () => {
|
||||
const f = fixture(); const path = join(f.ctx.cwd, "owned.md"); writeFileSync(path, f.plan);
|
||||
await f.tools.get("AttachGoalPlan").execute("attach", { path, parent: "live-parent", requestId: "previous" }, undefined, undefined, f.ctx);
|
||||
const file = f.ctx.sessionManager.getSessionFile();
|
||||
writeFileSync(file, [{ type: "session", id: "copy-only", cwd: f.ctx.cwd }, ...f.entries, { type: "message", id: "reviewed-leaf", message: { role: "assistant", content: [{ type: "text", text: "reviewed output" }] } }].map(entry => JSON.stringify(entry)).join("\n"));
|
||||
const before = readFileSync(file, "utf8");
|
||||
f.event({ type: "message", fromSessionId: "live-parent", payload: { type: "inspect", paneId: process.env.HERDR_PANE_ID, requestId: "next" } });
|
||||
const expected = f.channel.publish.mock.calls.at(-1)![0].identity;
|
||||
const request = { id: "next", action: "fresh", phase: "control", reviewedThrough: "reviewed-leaf", previous: expected, task: "independent work" };
|
||||
const dispatch = async (from = "live-parent") => {
|
||||
f.event({ type: "message", fromSessionId: from, payload: { type: "control", to: "parent-intercom", plan: path, expected, request } });
|
||||
await f.commands.get("goals-worker-control").handler("", f.ctx);
|
||||
};
|
||||
f.ctx.ui.getEditorText.mockReturnValue("unsent draft"); await dispatch();
|
||||
expect(f.ctx.newSession).not.toHaveBeenCalled(); expect(f.ctx.ui.getEditorText()).toBe("unsent draft"); expect(readFileSync(file, "utf8")).toBe(before);
|
||||
f.ctx.ui.getEditorText.mockReturnValue(""); f.ctx.hasPendingMessages.mockReturnValueOnce(true); await dispatch();
|
||||
expect(f.ctx.newSession).not.toHaveBeenCalled();
|
||||
await dispatch("foreign"); expect(f.ctx.newSession).not.toHaveBeenCalled();
|
||||
request.id = "reviewed-next"; await dispatch();
|
||||
expect(f.ctx.newSession).toHaveBeenCalledWith(expect.objectContaining({ parentSession: file, setup: expect.any(Function) }));
|
||||
expect(f.ctx.switchSession).not.toHaveBeenCalled(); expect(readFileSync(file, "utf8")).toBe(before);
|
||||
const appendCustomEntry = vi.fn(); await f.ctx.newSession.mock.calls[0][0].setup({ appendCustomEntry });
|
||||
expect(appendCustomEntry).toHaveBeenCalledWith("pi-goals-main-supervisor-v1", expect.objectContaining({ child: true, mode: "solo", plan: path, parent: { intercomId: "live-parent", requestId: "reviewed-next" } }));
|
||||
expect(f.messages.filter(message => message.savedPrompt).every(message => message.message.content === "/goals-worker-control")).toBe(true);
|
||||
request.id = "replacement-fails"; f.ctx.newSession.mockRejectedValueOnce(new Error("replacement rejected")); await dispatch();
|
||||
expect(f.ctx.newSession).toHaveBeenCalledTimes(2);
|
||||
expect(f.channel.publish).toHaveBeenLastCalledWith(expect.objectContaining({ type: "rejected", text: expect.stringContaining("replacement rejected") }), { audience: "capable" });
|
||||
f.hooks.get("session_shutdown")({ reason: "new" }, f.ctx);
|
||||
expect(f.channel.publish).toHaveBeenLastCalledWith(expect.objectContaining({ type: "stopped", text: expect.stringContaining("shutting down") }), { audience: "capable" });
|
||||
});
|
||||
|
||||
it("model requests fail closed and an unavailable control command never falls through to inference", async () => {
|
||||
it("leaves an existing stock pane unbound instead of replacing or retasking it", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
const before = f.entries.length;
|
||||
for (const model of ["offline/requested", "missing/unavailable"]) {
|
||||
const reply = await f.tools.get("OpenGoalWorker").execute("open", { task: "must not run", model }, undefined, undefined, f.ctx);
|
||||
expect(reply.content[0].text).toContain("public setModel");
|
||||
vi.mocked(openProjectPane).mockResolvedValueOnce({ ok: true, data: { bindingPath: "/existing/binding.json", disposition: "already-open", binding: { paneId: "existing-pane", projectRoot: f.ctx.cwd, command: "pi" } } });
|
||||
const before = f.messages.length;
|
||||
const reply = await f.tools.get("OpenGoalWorker").execute("open", { task: "proposed work" }, undefined, undefined, f.ctx);
|
||||
expect(reply.content[0].text).toContain("no startup was sent");
|
||||
expect(f.entries.at(-1).data.worker).toMatchObject({ paneId: "existing-pane" });
|
||||
expect(f.entries.at(-1).data.worker.intercomId).toBeUndefined();
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.channel.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["inherit", "plan", "explicit"])("hands off %s model policy without claiming configuration or invoking an unavailable control", async policy => {
|
||||
const f = fixture(); await f.draft();
|
||||
if (policy !== "inherit") await f.command("model offline/requested");
|
||||
await f.command("ready");
|
||||
const model = policy === "explicit" ? "missing/unavailable" : undefined;
|
||||
const requested = model ?? (policy === "plan" ? "offline/requested" : undefined);
|
||||
await f.tools.get("OpenGoalWorker").execute("open", { task: "bounded work", model }, undefined, undefined, f.ctx);
|
||||
const worker = f.entries.at(-1).data.worker;
|
||||
expect(worker.intercomId).toBeUndefined();
|
||||
const startup = vi.mocked(openProjectPane).mock.calls[0][0].message!;
|
||||
expect(startup).toContain(requested ? `User-supplied model preference: ${JSON.stringify(requested)}` : "Inherit the native model");
|
||||
if (requested) expect(startup).toContain("Preserve later human model changes");
|
||||
expect(vi.mocked(openProjectPane).mock.calls[0][0]).not.toHaveProperty("model");
|
||||
const identity = { paneId: "observed-pane", sessionId: "worker", sessionFile: "/tmp/worker.jsonl", model: "offline/inherited" };
|
||||
f.event({ type: "message", fromSessionId: "worker", payload: { type: "attached", to: worker.parentId, requestId: worker.requestId, plan: f.path, sessionFile: identity.sessionFile, identity } });
|
||||
await f.command("status");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Last observed worker model: offline/inherited"), "info");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("native pane: observed-pane"), "info");
|
||||
if (model) {
|
||||
f.event({ type: "message", fromSessionId: "worker", payload: { type: "stopped", to: worker.parentId, requestId: worker.requestId, plan: f.path, entryId: "model-unavailable", kind: "progress", text: "Requested missing/unavailable is unavailable; unrelated work can continue." } });
|
||||
expect(f.messages.at(-1)?.message.content).toContain("## Worker status: progress");
|
||||
await f.command("status");
|
||||
expect(f.ctx.ui.notify.mock.lastCall?.[0]).toContain("Pending worker revision reviews: none");
|
||||
}
|
||||
expect(f.entries).toHaveLength(before); expect(openProjectPane).not.toHaveBeenCalled();
|
||||
f.commands.delete("goals-worker-control");
|
||||
const count = f.messages.length;
|
||||
f.event({ type: "message", fromSessionId: "live-parent", payload: { type: "control", to: "parent-intercom", plan: f.path, expected: { paneId: process.env.HERDR_PANE_ID }, request: { id: "unknown-command", action: "start" } } });
|
||||
expect(f.messages).toHaveLength(count);
|
||||
|
||||
});
|
||||
|
||||
@@ -30,9 +30,24 @@ it("collapses mirrored prompts only in the UI, expands the exact text, and resto
|
||||
expect(expanded.render(80)).toEqual(new Markdown(content, 0, 0, getMarkdownTheme()).render(80));
|
||||
expect(expanded.render(80).join("\n")).toContain("Final evidence line");
|
||||
|
||||
display.restore({ sessionManager: { getBranch: () => [] } } as unknown as ExtensionContext);
|
||||
const review = "[pi-goals]\n## Worker revision reviews\n\n- revision run-1: output ready (reportId worker:run-1)";
|
||||
display.mirror(review);
|
||||
const reviewCollapsed = render({ type: "custom", customType: "pi-goals-notice", data: { content: review } }, { expanded: false }, theme);
|
||||
expect(reviewCollapsed.render(80).join("\n")).toContain("Worker revisions · review requested");
|
||||
|
||||
const status = "[pi-goals: worker status]\n## Worker status: waiting\n\nPueue 1552 is running.";
|
||||
display.mirror(status);
|
||||
const statusCollapsed = render({ type: "custom", customType: "pi-goals-notice", data: { content: status } }, { expanded: false }, theme);
|
||||
expect(statusCollapsed.render(80).join("\n")).toContain("Worker status");
|
||||
|
||||
const deliveredReview = "## Worker review: changes_requested\n\nCorrect output.txt.";
|
||||
display.hide(deliveredReview);
|
||||
expect(transform(deliveredReview, { messageType: "user" })).toBe("");
|
||||
display.restore({ sessionManager: { getBranch: () => [] } } as unknown as ExtensionContext, ["pi-goals-report-review"]);
|
||||
expect(transform(content, { messageType: "user" })).toBe(content);
|
||||
display.restore({ sessionManager: { getBranch: () => [entry] } } as unknown as ExtensionContext);
|
||||
const reviewEntry = { type: "custom", customType: "pi-goals-report-review", data: { content: deliveredReview } };
|
||||
display.restore({ sessionManager: { getBranch: () => [entry, reviewEntry] } } as unknown as ExtensionContext, ["pi-goals-report-review"]);
|
||||
expect(transform(content, { messageType: "user" })).toBe("");
|
||||
expect(transform(deliveredReview, { messageType: "user" })).toBe("");
|
||||
expect(entry.data.content).toBe(content);
|
||||
});
|
||||
|
||||
+37
-6
@@ -1,14 +1,45 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("declares current entry and bundled extension resources that exist after install", () => {
|
||||
const manifest = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
expect(manifest.pi.extensions[0]).toBe("./src/index.ts");
|
||||
for (const path of manifest.pi.extensions) expect(existsSync(resolve(path)), path).toBe(true);
|
||||
for (const name of ["pi-subagents", "pi-intercom", "pi-schedule-prompt"]) {
|
||||
for (const name of ["pi-subagents", "pi-intercom", "@jl1990/pi-scheduler"]) {
|
||||
expect(manifest.dependencies[name]).toBeTruthy();
|
||||
expect(manifest.bundledDependencies).toContain(name);
|
||||
expect(manifest.bundleDependencies).toContain(name);
|
||||
}
|
||||
expect(manifest.dependencies["pi-subagents"]).toBe("0.66.0");
|
||||
expect(JSON.parse(readFileSync("package-lock.json", "utf8")).packages[""].bundleDependencies).toEqual(manifest.bundleDependencies);
|
||||
});
|
||||
|
||||
it.each([true, false])("prepares offline=%s without changing the source profile or launching Pi", offline => {
|
||||
const source = mkdtempSync(join(tmpdir(), "pi-goals-profile-test-"));
|
||||
const settings = { packages: [], extensions: ["./unrelated-addon.ts"], defaultProvider: "human-choice", defaultModel: "keep-this" };
|
||||
const files = { "settings.json": JSON.stringify(settings), "auth.json": "{}", "models.json": '{"providers":{}}' };
|
||||
for (const [name, text] of Object.entries(files)) writeFileSync(join(source, name), text);
|
||||
let root: string | undefined;
|
||||
try {
|
||||
// An absent source profile/SDK proves the offline route does not read or import them.
|
||||
const prepared = spawnSync(process.execPath, [resolve("scripts/prepare-trial.mjs"), offline ? "unused-sdk" : resolve("node_modules/@earendil-works/pi-coding-agent"), ...(offline ? ["--offline", "http://127.0.0.1:12345"] : [])], { encoding: "utf8", env: { ...process.env, PI_OFFLINE: "1", PI_CODING_AGENT_DIR: offline ? join(source, "absent") : source } });
|
||||
expect(prepared.status, prepared.stderr).toBe(0);
|
||||
const trial = JSON.parse(prepared.stdout); root = trial.root;
|
||||
const actual = JSON.parse(readFileSync(join(trial.agentDir, "settings.json"), "utf8"));
|
||||
const manifest = JSON.parse(readFileSync(trial.manifest, "utf8"));
|
||||
const start = readFileSync(trial.start, "utf8");
|
||||
expect(actual.packages).toEqual([resolve(".")]);
|
||||
expect(actual.extensions).toEqual(offline ? [resolve("test/fixtures/offline-model.ts")] : settings.extensions);
|
||||
expect(actual.defaultModel).toBe(offline ? "test" : settings.defaultModel);
|
||||
expect(actual.defaultProvider).toBe(offline ? "offline" : settings.defaultProvider);
|
||||
expect(manifest.profile).toBe(offline ? "offline" : "inherited");
|
||||
expect(manifest.stateFile).toBe(join(trial.root, "scheduler/tasks.json"));
|
||||
expect(start).toContain(`export PI_SCHEDULER_STATE_FILE='${manifest.stateFile}'`);
|
||||
expect(start).not.toContain("--no-tools");
|
||||
expect(start).toContain(offline ? "exec pi --model offline/test" : "exec pi\n");
|
||||
if (offline) { expect(actual.enabledModels).toEqual(["offline/test"]); expect(start).toContain("export PI_OFFLINE=1"); }
|
||||
for (const [name, text] of Object.entries(files)) expect(readFileSync(join(source, name), "utf8")).toBe(text);
|
||||
expect(JSON.parse(readFileSync(join(trial.agentDir, "auth.json"), "utf8"))).toEqual({});
|
||||
expect(JSON.parse(readFileSync(join(trial.agentDir, "models.json"), "utf8"))).toEqual({ providers: {} });
|
||||
} finally { if (root) rmSync(root, { recursive: true, force: true }); rmSync(source, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { manualReview, planChangedReview, planContext, planning, planningSeed, readyApproved, upkeep } from "../src/prompts.js";
|
||||
|
||||
const plan = `# Keep the user context
|
||||
|
||||
Make the requested output easy to inspect.
|
||||
|
||||
## User-visible result
|
||||
A concrete artifact the user can read.
|
||||
|
||||
## User voice
|
||||
- > "Preserve this requirement word for word."
|
||||
|
||||
## Goals
|
||||
1. [/] goal: verify output
|
||||
- tasks:
|
||||
1. [ ] run the full check
|
||||
- evidence: proof.log
|
||||
|
||||
## Log
|
||||
old progress report`;
|
||||
|
||||
it("keeps hindsight-judged user outcomes in initial and recurring planning instructions", () => {
|
||||
const seed = planningSeed("Make search useful", "/plan.md");
|
||||
for (const prompt of [seed, planning("/plan.md")]) {
|
||||
expect(prompt).toContain('"I know it when I see it"');
|
||||
expect(prompt).toContain("actual results in hindsight");
|
||||
expect(prompt).toContain("do not invent numerical gates to replace judgment");
|
||||
expect(prompt).toContain("technical deliverable nouns and verbs");
|
||||
expect(prompt).not.toContain("not an implementation task");
|
||||
expect(prompt).not.toContain("not a task label");
|
||||
}
|
||||
expect(seed).toContain("goal: <short, concrete requested outcome>");
|
||||
expect(seed).toContain("Put observable examples under verification");
|
||||
expect(seed).toContain("what distinguishes it from merely looking done");
|
||||
expect(seed).toContain("not stricter assistant-invented requirements");
|
||||
expect(seed).toContain("by the user or justified by existing evidence");
|
||||
expect(seed).not.toContain("imperative outcome");
|
||||
});
|
||||
|
||||
it("keeps routine upkeep to its reason, goal lines and source path", () => {
|
||||
const base = upkeep("/plan.md", plan);
|
||||
expect(base).toMatch(/^\[pi-goals: reminder — upkeep\]\n/);
|
||||
expect(base).not.toContain("Preserve this requirement word for word.");
|
||||
expect(base).toContain("Eight unchanged turns");
|
||||
expect(base).toContain("/plan.md");
|
||||
expect(base).toContain("goal: verify output");
|
||||
expect(base).not.toContain("run the full check");
|
||||
});
|
||||
|
||||
it("separates routine goal lines from active context without historical Log", () => {
|
||||
const short = planContext("supervising", "/plan.md", plan, "short");
|
||||
expect(short).toContain("unfinished or unreviewed goal lines");
|
||||
expect(short).toContain("/plan.md");
|
||||
expect(short).not.toContain("Preserve this requirement");
|
||||
expect(short).toContain("goal: verify output");
|
||||
|
||||
const medium = planContext("supervising", "/plan.md", plan, "medium");
|
||||
expect(medium).not.toContain("Preserve this requirement word for word.");
|
||||
expect(medium).toContain("1. [/] goal: verify output");
|
||||
expect(medium).not.toContain("run the full check");
|
||||
expect(medium).not.toContain("proof.log");
|
||||
expect(medium).not.toContain("old progress report");
|
||||
|
||||
const full = planContext("supervising", "/plan.md", plan, "full");
|
||||
expect(full).toContain("run the full check");
|
||||
expect(full).toContain("proof.log");
|
||||
expect(full).toContain("Preserve this requirement word for word.");
|
||||
expect(full).not.toContain("old progress report");
|
||||
});
|
||||
|
||||
it("puts selected goal lines in plan-change and manual-review messages", () => {
|
||||
for (const text of [planChangedReview("/plan.md", plan), manualReview("/plan.md", plan)]) {
|
||||
expect(text).toContain("goal: verify output");
|
||||
expect(text).toContain("/plan.md");
|
||||
expect(text).not.toContain("Preserve this requirement word for word.");
|
||||
expect(text).not.toContain("run the full check");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps approved work moving after evidence review without overriding pauses or scope approval", () => {
|
||||
const text = planChangedReview("/plan.md", plan);
|
||||
expect(text).toContain("Evidence-only edits do not revoke execution approval");
|
||||
expect(text).toContain("Continue only unfinished authorized work");
|
||||
expect(text).toContain("respect pauses and do not assume approval for changed scope");
|
||||
});
|
||||
|
||||
it("keeps the current working set in the ready message but omits history", () => {
|
||||
const approved = readyApproved("goals-worker", "/plan.md", undefined, plan, "pi-session");
|
||||
expect(approved).toContain("Preserve this requirement word for word.");
|
||||
expect(approved).toContain("goal: verify output");
|
||||
expect(approved).not.toContain("old progress report");
|
||||
});
|
||||
+198
-122
@@ -1,16 +1,15 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { type ChildProcessWithoutNullStreams, execFileSync, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expect, it } from "vitest";
|
||||
import { foldPlan } from "../src/plan.js";
|
||||
|
||||
type RpcMessage = { type: string; id?: string; method?: string; [key: string]: unknown };
|
||||
type ModelRequest = { messages: Array<{ role: string; content: string | Array<{ type: string; text?: string }> }> };
|
||||
const messageText = (content: ModelRequest["messages"][number]["content"]) => typeof content === "string" ? content : content.filter(part => part.type === "text").map(part => part.text).join("\n");
|
||||
|
||||
class RpcClient {
|
||||
readonly messages: RpcMessage[] = [];
|
||||
@@ -65,122 +64,199 @@ const isSelect = (message: RpcMessage) => message.type === "extension_ui_request
|
||||
const isEditor = (message: RpcMessage) => message.type === "extension_ui_request" && message.method === "editor";
|
||||
const systemText = (request: ModelRequest) => request.messages.filter(message => ["system", "developer"].includes(message.role)).map(message => message.content).join("\n");
|
||||
|
||||
describe("RPC review flow", () => {
|
||||
it.each(["Edit", "Discuss"])("automatically proposes a draft, handles %s, then enters the supervisor role on Ready", async (choice) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-rpc-"));
|
||||
const requests: ModelRequest[] = [];
|
||||
const plan = "# Plan\n\n## Goals\n\n1. [ ] goal: name the output\n - subtle failure mode: the output has no name\n - discriminator: the plan names the output\n\n## Log\n";
|
||||
let planPath = "";
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
const modelRequest = JSON.parse(body) as ModelRequest;
|
||||
requests.push(modelRequest);
|
||||
if (requests.length === 1) {
|
||||
const pathMatch = systemText(modelRequest).match(/Plan only in (.+?);/);
|
||||
if (!pathMatch) throw new Error("Planning prompt did not name its plan file");
|
||||
planPath = pathMatch[1];
|
||||
streamResponse(response, {
|
||||
tool_calls: [{
|
||||
index: 0, id: "write-plan", type: "function",
|
||||
function: { name: "write", arguments: JSON.stringify({ path: planPath, content: plan }) },
|
||||
}],
|
||||
}, "tool_calls");
|
||||
return;
|
||||
}
|
||||
streamResponse(response, { content: "Plan inspected." }, "stop");
|
||||
});
|
||||
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Offline model did not bind a TCP port.");
|
||||
|
||||
const pi = spawn(resolve("node_modules/.bin/pi"), [
|
||||
"--mode", "rpc", "--no-session", "--no-extensions", "--model", "offline/test",
|
||||
"-e", resolve("test/fixtures/offline-model.ts"),
|
||||
"-e", resolve("src/index.ts"),
|
||||
], {
|
||||
cwd,
|
||||
env: {
|
||||
// Pi/gpt-6-astra: test the parent role even when vitest itself runs in a worker.
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("PI_SUBAGENT_") && !name.startsWith("PI_GOALS_"))),
|
||||
PI_CODING_AGENT_DIR: join(cwd, ".agent"),
|
||||
PI_GOALS_OFFLINE_MODEL_URL: `http://127.0.0.1:${address.port}`,
|
||||
},
|
||||
});
|
||||
const client = new RpcClient(pi);
|
||||
const exited = once(pi, "exit");
|
||||
try {
|
||||
client.send({ type: "prompt", id: "goals", message: "/goals new work out the thing" });
|
||||
const review = await client.waitFor(isSelect);
|
||||
expect(review.options).toEqual(["Ready", "Discuss", "Edit", "Cancel"]);
|
||||
client.send({ type: "get_state", id: "session-name" });
|
||||
const state = await client.waitFor(message => message.type === "response" && message.id === "session-name");
|
||||
expect(basename(planPath)).toBe(`${(state.data as { sessionId: string }).sessionId.slice(-6)}-v1.md`);
|
||||
expect(review.title).toContain(planPath);
|
||||
const proposal = client.messages.find(message => message.type === "message_end" && (message.message as { customType?: string })?.customType === "goal-plan-proposal");
|
||||
expect(proposal?.message).toMatchObject({ content: plan, display: true });
|
||||
expect(readFileSync(planPath, "utf8")).toBe(plan);
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(systemText(requests[0])).toContain("Plan only in");
|
||||
|
||||
const choiceStart = client.messages.length;
|
||||
client.send({ type: "extension_ui_response", id: review.id, value: choice });
|
||||
let approvedPlan = plan;
|
||||
if (choice === "Edit") {
|
||||
const editor = await client.waitFor(isEditor, choiceStart);
|
||||
expect(editor.prefill).toBe(plan);
|
||||
expect(requests).toHaveLength(2);
|
||||
approvedPlan = plan.replace("the plan names the output", "the plan names output.txt and its exact bytes");
|
||||
const editStart = client.messages.length;
|
||||
client.send({ type: "extension_ui_response", id: editor.id, value: approvedPlan });
|
||||
await client.waitFor(message => message.type === "extension_ui_request" && message.method === "setWidget", editStart);
|
||||
expect(readFileSync(planPath, "utf8")).toBe(approvedPlan);
|
||||
expect(requests).toHaveLength(2);
|
||||
} else {
|
||||
await client.waitFor(message => message.type === "agent_settled", choiceStart);
|
||||
client.send({ type: "get_state", id: "idle-discuss" });
|
||||
const idle = await client.waitFor(message => message.type === "response" && message.id === "idle-discuss");
|
||||
expect(idle.data).toMatchObject({ isStreaming: false, pendingMessageCount: 0 });
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(client.messages.slice(choiceStart).filter(message => message.type === "agent_start" || isEditor(message))).toEqual([]);
|
||||
const userStart = client.messages.length;
|
||||
client.send({ type: "prompt", id: "user-discussion", message: "Keep the output name, but explain the failure mode." });
|
||||
await client.waitFor(message => message.type === "agent_settled", userStart);
|
||||
expect(requests).toHaveLength(3);
|
||||
expect(systemText(requests[2])).toContain("Plan only in");
|
||||
expect(JSON.stringify(requests[2].messages)).toContain("Keep the output name, but explain the failure mode.");
|
||||
}
|
||||
const beforeReady = requests.length;
|
||||
const reopenStart = client.messages.length;
|
||||
client.send({ type: "prompt", id: "review", message: "/goals review" });
|
||||
const ready = await client.waitFor(isSelect, reopenStart);
|
||||
expect(requests).toHaveLength(beforeReady);
|
||||
const readyStart = client.messages.length;
|
||||
client.send({ type: "extension_ui_response", id: ready.id, value: "Ready" });
|
||||
await client.waitFor(message => message.type === "agent_end", readyStart);
|
||||
expect(requests).toHaveLength(beforeReady + 1);
|
||||
const supervisor = requests.at(-1)!;
|
||||
expect(systemText(supervisor)).toContain("You are the goal supervisor in the main chat");
|
||||
expect(systemText(supervisor)).not.toContain("Plan only in");
|
||||
expect(JSON.stringify(supervisor.messages)).toContain(JSON.stringify(foldPlan(approvedPlan)).slice(1, -1));
|
||||
const approval = supervisor.messages.filter(message => message.role === "user").map(message => messageText(message.content)).find(text => text.includes("Ready approved this plan:"))!;
|
||||
expect(approval).toContain("[pi-goals: approval — Ready]");
|
||||
expect(approval).toContain(`Plan excerpt (working set before Log) from ${JSON.stringify(planPath)}:\n\x60\x60\x60md\n${foldPlan(approvedPlan)}\n\x60\x60\x60`);
|
||||
expect(client.messages.filter(message => message.type === "tool_execution_start").map(message => message.toolName)).toEqual(["write"]);
|
||||
expect(client.messages.filter(message => message.type === "extension_error")).toEqual([]);
|
||||
const notices = client.messages.filter(message => message.type === "entry_appended" && (message.entry as { customType?: string })?.customType === "pi-goals-notice");
|
||||
expect(notices.length).toBeGreaterThanOrEqual(2);
|
||||
for (const notice of notices) {
|
||||
const content = (notice.entry as { data: { content: string } }).data.content;
|
||||
expect(client.messages.some(event => event.type === "message_end" && (event.message as any)?.role === "user" && (event.message as any)?.content[0]?.text === content)).toBe(true);
|
||||
expect(supervisor.messages.filter(message => message.role === "user" && messageText(message.content) === content)).toHaveLength(1);
|
||||
}
|
||||
console.log(`RPC ${choice}: visible automatic proposal; ${choice === "Edit" ? "editor saved exact plan without model call" : "discussion retained planning role without editor"}; Ready request used supervisor role; only write executed.`);
|
||||
} finally {
|
||||
pi.kill();
|
||||
await exited;
|
||||
await new Promise<void>((done) => server.close(() => done()));
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
// One real-Pi story: planning, failed work, lost delivery, correction, restored history, cleanup.
|
||||
it("plans and reviews the same worker across failure, delivery retry and reload", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-rpc-"));
|
||||
const requests = { parent: [] as ModelRequest[], worker: [] as ModelRequest[] };
|
||||
const replies = { parent: [] as any[], worker: [] as any[] };
|
||||
const clients: RpcClient[] = [];
|
||||
let planPath = "", serial = 0, hold: (() => Promise<void>) | undefined;
|
||||
let holdRole: "parent" | "worker" = "parent";
|
||||
const plan = '# Plan\n\n## User voice\nKeep the greeting readable.\n\n## Goals\n- [ ] goal: deliver greeting\n - greeting.txt must contain hello.\n\n## Log\nArchived notes stay on disk.\n';
|
||||
const call = (name: string, args: object) => ({ tool_calls: [{ index: 0, id: `fixture-${++serial}`, type: "function", function: { name, arguments: JSON.stringify(args) } }] });
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ""; for await (const chunk of request) body += chunk;
|
||||
const role = request.url?.startsWith("/worker") ? "worker" : "parent";
|
||||
const input = JSON.parse(body) as ModelRequest; requests[role].push(input);
|
||||
if (hold && role === holdRole) { const pending = hold; hold = undefined; await pending(); }
|
||||
if (response.destroyed) return;
|
||||
let answer = replies[role].shift();
|
||||
if (role === "parent" && requests.parent.length === 1) {
|
||||
planPath = systemText(input).match(/Plan only in (.+?);/)![1];
|
||||
answer = call("write", { path: planPath, content: plan });
|
||||
}
|
||||
}, 25_000);
|
||||
});
|
||||
if (answer?.fail) { response.writeHead(400, { "content-type": "application/json" }); response.end(JSON.stringify({ error: { message: "Fixture execution failed after progress" } })); return; }
|
||||
streamResponse(response, answer || { content: "Inspected." }, answer?.tool_calls ? "tool_calls" : "stop");
|
||||
});
|
||||
await new Promise<void>(done => server.listen(0, "127.0.0.1", done));
|
||||
const port = (server.address() as import("node:net").AddressInfo).port;
|
||||
function start(role: "parent" | "worker", sessionFile?: string) {
|
||||
const child = spawn(resolve("node_modules/.bin/pi"), ["--mode", "rpc", "--no-extensions", "--model", "offline/test",
|
||||
"-e", resolve("test/fixtures/offline-model.ts"), "-e", resolve("src/index.ts"),
|
||||
"-e", resolve("node_modules/pi-intercom/index.ts"), "-e", resolve("node_modules/@jl1990/pi-scheduler/extensions/scheduler/index.ts"),
|
||||
...(sessionFile ? ["--session", sessionFile] : [])], { cwd, env: {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("PI_SUBAGENT_") && !name.startsWith("PI_GOALS_") && !name.startsWith("HERDR_"))),
|
||||
PI_CODING_AGENT_DIR: join(cwd, "agent"), PI_OFFLINE: "1", PI_INTERCOM_SCOPE_ID: basename(cwd),
|
||||
PI_SCHEDULER_STATE_FILE: join(cwd, "scheduler.json"), PI_GOALS_OFFLINE_MODEL_URL: `http://127.0.0.1:${port}/${role}`,
|
||||
} }); const client = new RpcClient(child); clients.push(client); return client;
|
||||
}
|
||||
async function command(client: RpcClient, message: string) {
|
||||
const after = client.messages.length, id = `command-${++serial}`;
|
||||
client.send({ type: "prompt", id, message });
|
||||
await client.waitFor(m => m.type === "response" && m.id === id, after);
|
||||
}
|
||||
async function run(client: RpcClient, role: "parent" | "worker", ...answers: any[]) {
|
||||
const after = client.messages.length; replies[role].push(...answers);
|
||||
client.send({ type: "prompt", id: `run-${++serial}`, message: "Continue the isolated fixture task." });
|
||||
await client.waitFor(m => m.type === "agent_settled", after);
|
||||
}
|
||||
async function state(client: RpcClient): Promise<any> {
|
||||
const id = `state-${++serial}`; client.send({ type: "get_state", id });
|
||||
return (await client.waitFor(m => m.type === "response" && m.id === id)).data;
|
||||
}
|
||||
const entries = (path: string) => readFileSync(path, "utf8").trim().split("\n").map(line => JSON.parse(line));
|
||||
const records = (path: string, type: string) => entries(path).filter(e => e.type === "custom" && e.customType === type).map(e => e.data);
|
||||
async function report(parent: RpcClient, after: number) {
|
||||
const event = await parent.waitFor(m => m.type === "entry_appended" && (m.entry as any)?.customType === "pi-goals-report", after);
|
||||
await parent.waitFor(m => m.type === "agent_settled", parent.messages.indexOf(event));
|
||||
return (event.entry as any).data;
|
||||
}
|
||||
async function stop(client: RpcClient) { const exited = once(client.process, "exit"); client.process.kill(); await exited; }
|
||||
let parent = start("parent"), worker: RpcClient | undefined;
|
||||
try {
|
||||
parent.send({ type: "prompt", id: "new", message: "/goals new deliver the greeting" });
|
||||
const proposal = await parent.waitFor(isSelect);
|
||||
parent.send({ type: "extension_ui_response", id: proposal.id, value: "Edit" });
|
||||
const editor = await parent.waitFor(isEditor);
|
||||
const approved = plan.replace("contain hello", "contain hello followed by a newline");
|
||||
const editAt = parent.messages.length;
|
||||
parent.send({ type: "extension_ui_response", id: editor.id, value: approved });
|
||||
await parent.waitFor(m => m.type === "extension_ui_request" && m.method === "setWidget", editAt);
|
||||
expect(readFileSync(planPath, "utf8")).toBe(approved); expect(requests.parent).toHaveLength(2);
|
||||
const discussion = parent.messages.length;
|
||||
parent.send({ type: "prompt", id: "discuss", message: "/goals review" });
|
||||
const discuss = await parent.waitFor(isSelect, discussion);
|
||||
parent.send({ type: "extension_ui_response", id: discuss.id, value: "Discuss" });
|
||||
await parent.waitFor(m => m.type === "response" && m.command === "prompt", discussion);
|
||||
const discussionAt = parent.messages.length;
|
||||
parent.send({ type: "prompt", id: "discussion", message: "Keep the edited requirement." });
|
||||
const ready = await parent.waitFor(isSelect, discussionAt);
|
||||
expect(systemText(requests.parent.at(-1)!)).toContain("Plan only in");
|
||||
const readyAt = parent.messages.length;
|
||||
parent.send({ type: "extension_ui_response", id: ready.id, value: "Ready" });
|
||||
const approvedTurn = await parent.waitFor(m => m.type === "agent_end", readyAt);
|
||||
await parent.waitFor(m => m.type === "agent_settled", parent.messages.indexOf(approvedTurn));
|
||||
expect(systemText(requests.parent.at(-1)!)).toContain("goal supervisor");
|
||||
expect(JSON.stringify(requests.parent.at(-1)!.messages)).toContain(JSON.stringify(foldPlan(approved)).slice(1, -1));
|
||||
const initialCount = requests.parent.length;
|
||||
await command(parent, `/goals attach ${planPath}`);
|
||||
expect(requests.parent).toHaveLength(initialCount);
|
||||
|
||||
// Only native pane allocation is replaced by fixture setup; everything below uses real IPC/history.
|
||||
const selfAt = parent.messages.length; await run(parent, "parent", call("intercom", { action: "status" }));
|
||||
const selfResult = parent.messages.slice(selfAt).find(m => m.type === "tool_execution_end" && m.toolName === "intercom") as any;
|
||||
const parentId = JSON.stringify(selfResult.result.content).match(/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}/)![0];
|
||||
await command(parent, `/fixture-worker-binding ${JSON.stringify({ parentId, requestId: "rpc-assignment", task: "Deliver greeting" })}`);
|
||||
const parentState = await state(parent);
|
||||
worker = start("worker");
|
||||
await run(worker, "worker", call("intercom", { action: "list" }));
|
||||
await run(worker, "worker", call("AttachGoalPlan", { path: planPath, parent: parentId, requestId: "rpc-assignment" }), call("ReportGoalEvent", { kind: "receipt", summary: "Attached and waiting." }));
|
||||
const workerState = await state(worker), workerFile = workerState.sessionFile;
|
||||
expect(records(parentState.sessionFile, "pi-goals-worker-event").map(event => event.kind)).toEqual(["receipt"]);
|
||||
const greeting = join(cwd, "greeting.txt");
|
||||
const workerAt = worker.messages.length;
|
||||
let releaseWorker!: () => void; const heldWorker = new Promise<void>(done => { releaseWorker = done; });
|
||||
const workerRequested = once(server, "worker-held", { signal: AbortSignal.timeout(8_000) });
|
||||
holdRole = "worker"; hold = () => { server.emit("worker-held"); return heldWorker; };
|
||||
replies.worker.push(call("write", { path: greeting, content: "helo\n" }), call("ReportGoalEvent", { kind: "progress", summary: "Greeting written; verifying." }), { fail: true });
|
||||
const workerId = records(parentState.sessionFile, "pi-goals-main-supervisor-v1").at(-1).worker.intercomId;
|
||||
try {
|
||||
await run(parent, "parent", call("intercom", { action: "send", to: workerId, message: "Explicit assignment: deliver greeting.txt per the plan, verify it and report the result." }));
|
||||
await workerRequested; await stop(parent); // lose notification while retaining the real worker history
|
||||
} finally { releaseWorker(); }
|
||||
await worker.waitFor(m => m.type === "agent_settled", workerAt);
|
||||
parent = start("parent", parentState.sessionFile); await state(parent);
|
||||
const failure = records(parentState.sessionFile, "pi-goals-report").at(-1);
|
||||
await run(parent, "parent");
|
||||
expect(systemText(requests.parent.at(-1)!)).toContain(failure.id);
|
||||
expect(failure.kind).toBe("blocker"); expect(failure.text).toContain("Fixture execution failed after progress");
|
||||
expect(records(parentState.sessionFile, "pi-goals-report")).toHaveLength(1); // receipts/progress/normal stops stayed quiet
|
||||
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
git("init", "--quiet"); git("add", "greeting.txt");
|
||||
git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "Initial artifact");
|
||||
const revision = git("rev-parse", "HEAD");
|
||||
const form = { reportId: failure.id, goal: { path: planPath, quote: "goal: deliver greeting" }, evidence: [{ path: `git:${revision}:greeting.txt`, quote: "helo", observation: "Read the incorrect greeting" }], observation: "The greeting is missing a letter", unmet: "Expected hello", verdict: "changes_requested", continuation: "Replace greeting.txt with hello followed by one newline, read it back, and report the corrected artifact." };
|
||||
await run(parent, "parent", call("read", { path: greeting }), call("review_subagent", { ...form, evidence: [{ ...form.evidence[0], quote: "invented bytes" }] }));
|
||||
expect(records(parentState.sessionFile, "pi-goals-report-review")).toHaveLength(0);
|
||||
await stop(worker); // exact exit, not disappearance; failed delivery must remain pending
|
||||
await run(parent, "parent", call("review_subagent", form));
|
||||
expect(records(parentState.sessionFile, "pi-goals-report-review")).toHaveLength(0);
|
||||
worker = start("worker", workerFile); await state(worker);
|
||||
const inspectionAt = parent.messages.length;
|
||||
await run(worker, "worker", call("intercom", { action: "list" }));
|
||||
await parent.waitFor(m => m.type === "entry_appended" && (m.entry as any)?.customType === "pi-goals-worker-event", inspectionAt);
|
||||
const correctionAt = parent.messages.length, correctionWorkerAt = worker.messages.length;
|
||||
const statusCount = records(parentState.sessionFile, "pi-goals-worker-event").length;
|
||||
const correctedEvent = { kind: "review_request", summary: `Corrected artifact: ${greeting}` };
|
||||
replies.worker.push(call("write", { path: greeting, content: "hello\n" }), call("read", { path: greeting }), call("ReportGoalEvent", correctedEvent), call("ReportGoalEvent", correctedEvent));
|
||||
await run(parent, "parent", call("review_subagent", form));
|
||||
const correction = await report(parent, correctionAt);
|
||||
await worker.waitFor(m => m.type === "agent_settled", correctionWorkerAt);
|
||||
expect(records(parentState.sessionFile, "pi-goals-worker-event")).toHaveLength(statusCount);
|
||||
expect(correction.id).not.toBe(failure.id); expect(correction.sessionFile).toBe(workerFile);
|
||||
expect(readFileSync(greeting, "utf8")).toBe("hello\n");
|
||||
const workerCount = requests.worker.length;
|
||||
await run(parent, "parent", call("read", { path: greeting }), call("review_subagent", { ...form, reportId: correction.id, evidence: [{ path: greeting, quote: "hello", observation: "Read corrected greeting" }], observation: "Matches the requested greeting", unmet: "none", verdict: "accepted", continuation: "" }));
|
||||
await command(parent, "/goals status");
|
||||
expect(requests.worker).toHaveLength(workerCount); // acceptance does not wake or close worker
|
||||
expect(worker.process.exitCode).toBeNull(); expect(worker.process.signalCode).toBeNull();
|
||||
const savedReviews = records(workerFile, "pi-goals-report-review");
|
||||
expect(savedReviews.map(r => r.verdict)).toEqual(["changes_requested", "accepted"]);
|
||||
expect(savedReviews.map(r => r.report)).toEqual([failure.id, correction.id]); // old consumers key this wire field
|
||||
expect(records(parentState.sessionFile, "pi-goals-report-review")).toEqual(savedReviews);
|
||||
await command(worker, "/fixture-reload"); // real shutdown/start after a formal event stays quiet
|
||||
expect(requests.worker).toHaveLength(workerCount);
|
||||
expect(records(parentState.sessionFile, "pi-goals-worker-event")).toHaveLength(statusCount);
|
||||
expect(records(workerFile, "pi-goals-report-review")).toEqual(savedReviews);
|
||||
const abortAt = worker.messages.length, abortParentAt = parent.messages.length;
|
||||
let releaseAbort!: () => void; const abortedRequest = new Promise<void>(done => { releaseAbort = done; });
|
||||
const abortRequested = once(server, "aborting", { signal: AbortSignal.timeout(8_000) });
|
||||
holdRole = "worker"; hold = () => { server.emit("aborting"); return abortedRequest; };
|
||||
worker.send({ type: "prompt", id: "interrupted", message: "Wait for the interruption fixture." });
|
||||
try { await abortRequested; worker.send({ type: "abort", id: "abort" }); await worker.waitFor(m => m.type === "agent_settled", abortAt); } finally { releaseAbort(); }
|
||||
await parent.waitFor(m => m.type === "entry_appended" && (m.entry as any)?.customType === "pi-goals-worker-event" && (m.entry as any).data.kind === "aborted", abortParentAt);
|
||||
expect(records(parentState.sessionFile, "pi-goals-report")).toHaveLength(2); // intentional interruption is not another formal review
|
||||
await command(parent, "/fixture-legacy-supersession");
|
||||
await run(parent, "parent");
|
||||
expect(systemText(requests.parent.at(-1)!)).not.toContain("Pending worker revision reviews:");
|
||||
expect(readFileSync(planPath, "utf8")).toBe(approved); // reviews never CompleteGoal
|
||||
|
||||
// Retain the existing busy-Clear discriminator: passive scheduler output can flush after five seconds.
|
||||
await run(parent, "parent", call("schedule_task", { name: `goals-${parentState.sessionId}`, action: "prompt", type: "interval", schedule: "1h", scope: "session", prompt: "Goal check-in." }));
|
||||
const task = JSON.parse(readFileSync(join(cwd, "scheduler.json"), "utf8")).tasks[0];
|
||||
expect(task.sessionFile).toBe(parentState.sessionFile);
|
||||
let release!: () => void; const held = new Promise<void>(done => { release = done; });
|
||||
const requested = once(server, "held", { signal: AbortSignal.timeout(8_000) });
|
||||
holdRole = "parent"; hold = () => { server.emit("held"); return held; };
|
||||
const count = requests.parent.length, busyAt = parent.messages.length;
|
||||
parent.send({ type: "prompt", id: "busy", message: "Wait for delayed fixture response." });
|
||||
try { await requested; await command(parent, "/goals clear"); await new Promise(done => setTimeout(done, 6_000)); } finally { release(); }
|
||||
await parent.waitFor(m => m.type === "agent_settled", busyAt);
|
||||
await parent.waitFor(m => m.type === "extension_ui_request" && m.method === "notify" && JSON.stringify(m).includes(`Removed scheduled task ${task.id}`), busyAt);
|
||||
expect(JSON.parse(readFileSync(join(cwd, "scheduler.json"), "utf8")).tasks).toEqual([]);
|
||||
expect(requests.parent).toHaveLength(count + 1);
|
||||
if (process.env.PI_GOALS_TEST_EVIDENCE) {
|
||||
mkdirSync(process.env.PI_GOALS_TEST_EVIDENCE, { recursive: true });
|
||||
for (const [name, text] of Object.entries({ "requests.json": JSON.stringify(requests), "parent.jsonl": readFileSync(parentState.sessionFile, "utf8"), "worker.jsonl": readFileSync(workerFile, "utf8"), "events.json": JSON.stringify(clients.map(client => ({ pid: client.process.pid, events: client.messages, stderr: client.stderr }))) })) writeFileSync(join(process.env.PI_GOALS_TEST_EVIDENCE, name), text);
|
||||
}
|
||||
} finally {
|
||||
if (process.env.PI_GOALS_TEST_EVIDENCE) {
|
||||
mkdirSync(process.env.PI_GOALS_TEST_EVIDENCE, { recursive: true });
|
||||
writeFileSync(join(process.env.PI_GOALS_TEST_EVIDENCE, "last-attempt.json"), JSON.stringify({ requests, parent: parent.messages, worker: worker?.messages }));
|
||||
}
|
||||
for (const { process: child } of clients) if (child.exitCode === null && child.signalCode === null) { const exited = once(child, "exit"); child.kill(); await exited; }
|
||||
if (process.env.PI_GOALS_TEST_EVIDENCE) writeFileSync(join(process.env.PI_GOALS_TEST_EVIDENCE, "cleanup.json"), JSON.stringify(clients.map(({ process: child }) => ({ pid: child.pid, exitCode: child.exitCode, signalCode: child.signalCode }))));
|
||||
await new Promise<void>(done => server.close(() => done())); rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
Reference in New Issue
Block a user