pi-delegation-policy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CODE_OF_CONDUCT.md +24 -0
- package/CONTRIBUTING.md +29 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/SECURITY.md +17 -0
- package/examples/global.json +20 -0
- package/package.json +58 -0
- package/schema/delegation-policy.schema.json +27 -0
- package/src/config.ts +247 -0
- package/src/index.ts +143 -0
- package/src/prompt.ts +56 -0
- package/src/runtime.ts +155 -0
- package/src/types.ts +77 -0
- package/src/ui.ts +209 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0 - 2026-08-25
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- A keyboard-first `/delegate` selector for delegation intensity, model preference, exact Small, Medium, Large, and optional UI Design roles.
|
|
10
|
+
- Global defaults and session-branch state that begin at `off` and survive reload, resume, and tree navigation.
|
|
11
|
+
- Fail-closed validation for missing, unavailable, out-of-scope, or unauthenticated model roles.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Replaced the unpublished preset-based prototype with schema 2 global defaults and session-branch delegation state.
|
|
16
|
+
- Removed project configuration, external skill loading, tool interception, enforcement, persisted thinking, and model fallbacks.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our pledge
|
|
4
|
+
|
|
5
|
+
We pledge to make participation in this project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
|
6
|
+
|
|
7
|
+
## Our standards
|
|
8
|
+
|
|
9
|
+
Examples of behavior that contribute to a positive environment include:
|
|
10
|
+
|
|
11
|
+
- showing empathy and kindness;
|
|
12
|
+
- respecting differing opinions, viewpoints, and experiences;
|
|
13
|
+
- accepting constructive criticism;
|
|
14
|
+
- focusing on what is best for the community.
|
|
15
|
+
|
|
16
|
+
Examples of unacceptable behavior include harassment, trolling, personal attacks, publishing private information without permission, and other conduct that would reasonably be considered inappropriate in a professional setting.
|
|
17
|
+
|
|
18
|
+
## Enforcement
|
|
19
|
+
|
|
20
|
+
Project maintainers may remove, edit, or reject comments, commits, code, issues, and other contributions that violate this Code of Conduct. They may temporarily or permanently restrict participation.
|
|
21
|
+
|
|
22
|
+
Report abusive behavior through a private GitHub channel available to the repository maintainers. Do not use public issues for private reports.
|
|
23
|
+
|
|
24
|
+
This project follows the Contributor Covenant, version 2.1: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for helping improve `pi-delegation-policy`.
|
|
4
|
+
|
|
5
|
+
## Before opening an issue
|
|
6
|
+
|
|
7
|
+
- Search existing issues.
|
|
8
|
+
- Include the package version or commit, Pi version, operating system, and a minimal reproduction.
|
|
9
|
+
- Remove credentials, session files, prompts, personal paths, model account identifiers, and unredacted logs.
|
|
10
|
+
- Report security vulnerabilities through [`SECURITY.md`](SECURITY.md), not a public issue.
|
|
11
|
+
|
|
12
|
+
## Pull requests
|
|
13
|
+
|
|
14
|
+
Pull requests should:
|
|
15
|
+
|
|
16
|
+
- explain the user-visible behavior and the reason for the change;
|
|
17
|
+
- include focused tests for behavior changes;
|
|
18
|
+
- keep the public package English-only;
|
|
19
|
+
- use fictional examples and provider-agnostic documentation;
|
|
20
|
+
- preserve exact role references and the absence of model fallbacks;
|
|
21
|
+
- preserve the boundary: this package guides the main agent and does not create, launch, route, supervise, or block subagents;
|
|
22
|
+
- avoid project configuration, credential handling, telemetry, and network requests;
|
|
23
|
+
- run `npm run format:check`, `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build`.
|
|
24
|
+
|
|
25
|
+
Changes to schema 2, public commands, or the delegated-work policy need documentation and migration notes.
|
|
26
|
+
|
|
27
|
+
## Code of conduct
|
|
28
|
+
|
|
29
|
+
Participation follows [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yivas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# pi-delegation-policy
|
|
2
|
+
|
|
3
|
+
A local Pi extension that lets you choose delegation intensity and exact model references for Small, Medium, Large, and an optional UI Design role. It guides the main agent; it does not run, route, or enforce delegated work.
|
|
4
|
+
|
|
5
|
+
> **Status:** Version 0.1.0 is available from npm. Every new session starts at `off`.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Sets delegation intensity to `off`, `normal`, or `aggressive` for the current session branch.
|
|
10
|
+
- Keeps global defaults for model references, preference, and the optional UI Design role.
|
|
11
|
+
- Stores session changes in Pi's session branch, so they survive reload, resume, and tree navigation.
|
|
12
|
+
- Uses Pi's scoped models when configured, otherwise its available authenticated models.
|
|
13
|
+
- Injects one policy block through Pi's public `before_agent_start` event when the active configuration is valid.
|
|
14
|
+
|
|
15
|
+
## What it does not do
|
|
16
|
+
|
|
17
|
+
This package does not create, launch, route, supervise, or block subagents. It does not change Pi's main model or thinking level. It has no presets, project configuration, external skill loading, tool interception, model fallback, telemetry, credential storage, or network requests.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Install the package in your Pi user settings:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:pi-delegation-policy
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It supports Pi `0.84.1`. Restart Pi or run `/reload` after installation. To install a local checkout instead, use `pi install ./pi-delegation-policy`.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
/delegate Open the keyboard-first selector
|
|
33
|
+
/delegate off Disable policy injection for this session branch
|
|
34
|
+
/delegate normal Enable balanced delegation guidance
|
|
35
|
+
/delegate aggressive Enable delegation-first guidance
|
|
36
|
+
/delegate status Show the effective session state
|
|
37
|
+
/delegate reset Reset this session branch to off
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`Ctrl+Shift+D` opens the selector when the shortcut is available. The footer shows `D:OFF`, `D:NORM`, `D:AGG`, or `D:ERR` without replacing Pi's own status.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Global defaults live at:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
~/.pi/agent/delegation-policy.json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
They use schema version 2. The file stores model references, preference, and an optional UI Design model. It never stores intensity or thinking.
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"schemaVersion": 2,
|
|
55
|
+
"preference": "standard",
|
|
56
|
+
"small": {
|
|
57
|
+
"provider": "example-provider",
|
|
58
|
+
"model": "example-small"
|
|
59
|
+
},
|
|
60
|
+
"medium": {
|
|
61
|
+
"provider": "example-provider",
|
|
62
|
+
"model": "example-medium"
|
|
63
|
+
},
|
|
64
|
+
"large": {
|
|
65
|
+
"provider": "example-provider",
|
|
66
|
+
"model": "example-large"
|
|
67
|
+
},
|
|
68
|
+
"uiDesign": {
|
|
69
|
+
"provider": "example-provider",
|
|
70
|
+
"model": "example-ui-design"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
See [`examples/global.json`](examples/global.json) and the bundled [JSON Schema](schema/delegation-policy.schema.json). The values are fictional.
|
|
76
|
+
|
|
77
|
+
Schema version 1 is inactive and is not migrated automatically. Open `/delegate`, configure schema version 2, and save the effective configuration as defaults before using an active intensity.
|
|
78
|
+
|
|
79
|
+
### Global defaults and session branches
|
|
80
|
+
|
|
81
|
+
A new session or branch always starts at `off`. It inherits global model references and preference until `/delegate` changes a field in that branch. Session fields override only their matching global values. Selecting **Save effective configuration as defaults** copies the current roles, preference, and UI Design setting to the global file without copying intensity.
|
|
82
|
+
|
|
83
|
+
`/delegate reset` writes a session state with `off` and returns non-intensity fields to their global defaults.
|
|
84
|
+
|
|
85
|
+
### Intensity
|
|
86
|
+
|
|
87
|
+
- `off` injects nothing. Invalid or incomplete defaults still show `D:OFF`.
|
|
88
|
+
- `normal` delegates substantial, bounded, independent work when doing so saves effort without losing essential context.
|
|
89
|
+
- `aggressive` favors delegating that work when its objective and acceptance criteria are clear.
|
|
90
|
+
|
|
91
|
+
The main agent keeps global decisions, coordination, integration, and final review in every mode.
|
|
92
|
+
|
|
93
|
+
### Model roles and preference
|
|
94
|
+
|
|
95
|
+
Active modes require exact `provider` and `model` references for Small, Medium, and Large. Pi must expose each reference in the current scope or available model catalog, and its provider must be authenticated. A missing, out-of-scope, unavailable, or unauthenticated role produces `D:ERR` and injects no policy. The extension never substitutes another model or role.
|
|
96
|
+
|
|
97
|
+
The preference only changes the policy's selection bias:
|
|
98
|
+
|
|
99
|
+
- `efficient` favors Small.
|
|
100
|
+
- `standard` uses Small for routine work, Medium for planning, ambiguity, or broad synthesis, and Large for exceptional blockers.
|
|
101
|
+
- `intensive` favors Medium.
|
|
102
|
+
|
|
103
|
+
All three ordinary roles remain available in every preference. The main agent chooses thinking for each delegated task from the task, difficulty, volume, and model capabilities. Thinking is not configured or persisted by this extension.
|
|
104
|
+
|
|
105
|
+
UI Design is optional. When configured, it is limited to visual design direction, exploration, and review. It must not implement an interface, write code, or run tests. When it is off, ordinary roles handle design-related work.
|
|
106
|
+
|
|
107
|
+
## Security and privacy
|
|
108
|
+
|
|
109
|
+
The extension stores provider and model identifiers in local configuration and session entries. It does not store credentials, send telemetry, or make network requests. Review configuration before using it and remove credentials, prompts, personal paths, session files, and unredacted logs from reports.
|
|
110
|
+
|
|
111
|
+
Read [`SECURITY.md`](SECURITY.md) for reporting guidance.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npm ci
|
|
117
|
+
npm run format:check
|
|
118
|
+
npm run lint
|
|
119
|
+
npm run typecheck
|
|
120
|
+
npm test
|
|
121
|
+
npm run build
|
|
122
|
+
npm run pack:check
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The tests use local mocks and make no paid model calls or network requests.
|
|
126
|
+
|
|
127
|
+
## Contributing
|
|
128
|
+
|
|
129
|
+
Read [`CONTRIBUTING.md`](CONTRIBUTING.md). Contributions must preserve the boundary: this extension guides the main agent and does not become a subagent runner, tool interceptor, credential store, or telemetry client.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT. See [`LICENSE`](LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
This project is a local Pi extension. It stores delegation policy data and model identifiers in global defaults and session entries. It does not store credentials, execute subagents, intercept tools, or make network requests.
|
|
6
|
+
|
|
7
|
+
The policy guides the main agent. It cannot guarantee that another system will follow a configured role or thinking choice. Review local configuration before using it.
|
|
8
|
+
|
|
9
|
+
## Reporting
|
|
10
|
+
|
|
11
|
+
Please use GitHub's private vulnerability reporting for this repository. Do not open a public issue for an undisclosed vulnerability. Remove credentials, session files, prompts, personal paths, and unredacted logs from reports.
|
|
12
|
+
|
|
13
|
+
Include the affected version or commit, operating system, Pi version, reproduction steps, expected behavior, observed behavior, and a minimal sanitized configuration.
|
|
14
|
+
|
|
15
|
+
## Supported versions
|
|
16
|
+
|
|
17
|
+
Only the latest published version is supported. Version 0.1.0 is the first supported release.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"preference": "standard",
|
|
4
|
+
"small": {
|
|
5
|
+
"provider": "example-provider",
|
|
6
|
+
"model": "example-small"
|
|
7
|
+
},
|
|
8
|
+
"medium": {
|
|
9
|
+
"provider": "example-provider",
|
|
10
|
+
"model": "example-medium"
|
|
11
|
+
},
|
|
12
|
+
"large": {
|
|
13
|
+
"provider": "example-provider",
|
|
14
|
+
"model": "example-large"
|
|
15
|
+
},
|
|
16
|
+
"uiDesign": {
|
|
17
|
+
"provider": "example-provider",
|
|
18
|
+
"model": "example-ui-design"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-delegation-policy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "A Pi extension for configurable delegation intensity and exact subagent role model references.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": "https://github.com/Yivas/pi-delegation-policy",
|
|
9
|
+
"bugs": "https://github.com/Yivas/pi-delegation-policy/issues",
|
|
10
|
+
"homepage": "https://github.com/Yivas/pi-delegation-policy",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi-extension",
|
|
14
|
+
"delegation",
|
|
15
|
+
"subagents",
|
|
16
|
+
"model-selection"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"schema",
|
|
21
|
+
"examples",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"CONTRIBUTING.md",
|
|
25
|
+
"CODE_OF_CONDUCT.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"SECURITY.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"format": "prettier --write .",
|
|
31
|
+
"format:check": "prettier --check .",
|
|
32
|
+
"lint": "eslint .",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"pack:check": "npm pack --dry-run"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=0.84.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": "0.84.1",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
44
|
+
"@earendil-works/pi-tui": "0.84.1",
|
|
45
|
+
"@eslint/js": "^9.39.5",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"ajv": "^8.20.0",
|
|
48
|
+
"eslint": "^9.0.0",
|
|
49
|
+
"prettier": "^3.0.0",
|
|
50
|
+
"typescript": "^5.7.0",
|
|
51
|
+
"typescript-eslint": "^8.68.0"
|
|
52
|
+
},
|
|
53
|
+
"pi": {
|
|
54
|
+
"extensions": [
|
|
55
|
+
"./src/index.ts"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/Yivas/pi-delegation-policy/blob/main/schema/delegation-policy.schema.json",
|
|
4
|
+
"title": "Pi Delegation Policy global defaults",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": { "const": 2 },
|
|
9
|
+
"preference": { "enum": ["efficient", "standard", "intensive"] },
|
|
10
|
+
"small": { "$ref": "#/$defs/modelRef" },
|
|
11
|
+
"medium": { "$ref": "#/$defs/modelRef" },
|
|
12
|
+
"large": { "$ref": "#/$defs/modelRef" },
|
|
13
|
+
"uiDesign": { "$ref": "#/$defs/modelRef" }
|
|
14
|
+
},
|
|
15
|
+
"$defs": {
|
|
16
|
+
"modelRef": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": ["provider", "model"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"provider": { "type": "string", "minLength": 1 },
|
|
21
|
+
"model": { "type": "string", "minLength": 1 }
|
|
22
|
+
},
|
|
23
|
+
"additionalProperties": false
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"additionalProperties": false
|
|
27
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
emptyGlobalDefaults,
|
|
6
|
+
emptySessionState,
|
|
7
|
+
type EffectiveDelegateState,
|
|
8
|
+
type GlobalDefaults,
|
|
9
|
+
type Intensity,
|
|
10
|
+
type ModelRef,
|
|
11
|
+
type Preference,
|
|
12
|
+
type SessionDelegateState,
|
|
13
|
+
type ValueSource,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export type { GlobalDefaults, SessionDelegateState } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export const SESSION_ENTRY_TYPE = "pi-delegation-policy:session";
|
|
19
|
+
export const GLOBAL_CONFIG_NAME = "delegation-policy.json";
|
|
20
|
+
|
|
21
|
+
const LEGACY_SCHEMA_MESSAGE =
|
|
22
|
+
"Global defaults use schema version 1. Configure schema version 2 with /delegate before activating delegation.";
|
|
23
|
+
const INVALID_CONFIG_MESSAGE = "Global defaults are invalid. Configure them again with /delegate.";
|
|
24
|
+
|
|
25
|
+
export type ConfigDiagnostic = {
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type LoadedDefaults = {
|
|
30
|
+
defaults: GlobalDefaults;
|
|
31
|
+
diagnostics: ConfigDiagnostic[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasOnlyKeys(value: Record<string, unknown>, allowedKeys: readonly string[]): boolean {
|
|
39
|
+
return Object.keys(value).every((key) => allowedKeys.includes(key));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isIntensity(value: unknown): value is Intensity {
|
|
43
|
+
return value === "off" || value === "normal" || value === "aggressive";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPreference(value: unknown): value is Preference {
|
|
47
|
+
return value === "efficient" || value === "standard" || value === "intensive";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseModelRef(value: unknown): ModelRef | undefined {
|
|
51
|
+
if (
|
|
52
|
+
!isRecord(value) ||
|
|
53
|
+
!hasOnlyKeys(value, ["provider", "model"]) ||
|
|
54
|
+
typeof value.provider !== "string" ||
|
|
55
|
+
value.provider.length === 0 ||
|
|
56
|
+
typeof value.model !== "string" ||
|
|
57
|
+
value.model.length === 0
|
|
58
|
+
)
|
|
59
|
+
return undefined;
|
|
60
|
+
return { provider: value.provider, model: value.model };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function copyModelRef(value: ModelRef | undefined): ModelRef | undefined {
|
|
64
|
+
return value ? { ...value } : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseConfig(value: unknown): GlobalDefaults | undefined {
|
|
68
|
+
if (
|
|
69
|
+
!isRecord(value) ||
|
|
70
|
+
!hasOnlyKeys(value, ["schemaVersion", "preference", "small", "medium", "large", "uiDesign"]) ||
|
|
71
|
+
value.schemaVersion !== 2 ||
|
|
72
|
+
(value.preference !== undefined && !isPreference(value.preference))
|
|
73
|
+
)
|
|
74
|
+
return undefined;
|
|
75
|
+
|
|
76
|
+
const small = value.small === undefined ? undefined : parseModelRef(value.small);
|
|
77
|
+
const medium = value.medium === undefined ? undefined : parseModelRef(value.medium);
|
|
78
|
+
const large = value.large === undefined ? undefined : parseModelRef(value.large);
|
|
79
|
+
const uiDesign = value.uiDesign === undefined ? undefined : parseModelRef(value.uiDesign);
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
(value.small !== undefined && !small) ||
|
|
83
|
+
(value.medium !== undefined && !medium) ||
|
|
84
|
+
(value.large !== undefined && !large) ||
|
|
85
|
+
(value.uiDesign !== undefined && !uiDesign)
|
|
86
|
+
)
|
|
87
|
+
return undefined;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
schemaVersion: 2,
|
|
91
|
+
...(value.preference ? { preference: value.preference } : {}),
|
|
92
|
+
...(small ? { small } : {}),
|
|
93
|
+
...(medium ? { medium } : {}),
|
|
94
|
+
...(large ? { large } : {}),
|
|
95
|
+
...(uiDesign ? { uiDesign } : {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function parseSessionState(value: unknown): SessionDelegateState | undefined {
|
|
100
|
+
if (
|
|
101
|
+
!isRecord(value) ||
|
|
102
|
+
!hasOnlyKeys(value, [
|
|
103
|
+
"schemaVersion",
|
|
104
|
+
"intensity",
|
|
105
|
+
"preference",
|
|
106
|
+
"small",
|
|
107
|
+
"medium",
|
|
108
|
+
"large",
|
|
109
|
+
"uiDesign",
|
|
110
|
+
]) ||
|
|
111
|
+
value.schemaVersion !== 2 ||
|
|
112
|
+
!isIntensity(value.intensity) ||
|
|
113
|
+
(value.preference !== undefined && !isPreference(value.preference))
|
|
114
|
+
)
|
|
115
|
+
return undefined;
|
|
116
|
+
|
|
117
|
+
const small = value.small === undefined ? undefined : parseModelRef(value.small);
|
|
118
|
+
const medium = value.medium === undefined ? undefined : parseModelRef(value.medium);
|
|
119
|
+
const large = value.large === undefined ? undefined : parseModelRef(value.large);
|
|
120
|
+
const uiDesign =
|
|
121
|
+
value.uiDesign === undefined || value.uiDesign === null
|
|
122
|
+
? value.uiDesign
|
|
123
|
+
: parseModelRef(value.uiDesign);
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
(value.small !== undefined && !small) ||
|
|
127
|
+
(value.medium !== undefined && !medium) ||
|
|
128
|
+
(value.large !== undefined && !large) ||
|
|
129
|
+
(value.uiDesign !== undefined && value.uiDesign !== null && !uiDesign)
|
|
130
|
+
)
|
|
131
|
+
return undefined;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: 2,
|
|
135
|
+
intensity: value.intensity,
|
|
136
|
+
...(value.preference ? { preference: value.preference } : {}),
|
|
137
|
+
...(small ? { small } : {}),
|
|
138
|
+
...(medium ? { medium } : {}),
|
|
139
|
+
...(large ? { large } : {}),
|
|
140
|
+
...(uiDesign === null ? { uiDesign: null } : uiDesign ? { uiDesign } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isLegacyConfig(value: unknown): boolean {
|
|
145
|
+
return isRecord(value) && value.schemaVersion === 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getGlobalConfigPath(
|
|
149
|
+
agentDirectory = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
|
|
150
|
+
): string {
|
|
151
|
+
return join(agentDirectory, GLOBAL_CONFIG_NAME);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function readConfig(path: string): Promise<LoadedDefaults> {
|
|
155
|
+
try {
|
|
156
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
157
|
+
const defaults = parseConfig(value);
|
|
158
|
+
if (defaults) return { defaults, diagnostics: [] };
|
|
159
|
+
return {
|
|
160
|
+
defaults: emptyGlobalDefaults(),
|
|
161
|
+
diagnostics: [
|
|
162
|
+
{ message: isLegacyConfig(value) ? LEGACY_SCHEMA_MESSAGE : INVALID_CONFIG_MESSAGE },
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
167
|
+
return { defaults: emptyGlobalDefaults(), diagnostics: [] };
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
defaults: emptyGlobalDefaults(),
|
|
171
|
+
diagnostics: [{ message: INVALID_CONFIG_MESSAGE }],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function atomicWrite(path: string, value: unknown): Promise<void> {
|
|
177
|
+
await mkdir(dirname(path), { recursive: true });
|
|
178
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
179
|
+
try {
|
|
180
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
181
|
+
await rename(temporary, path);
|
|
182
|
+
} finally {
|
|
183
|
+
await rm(temporary, { force: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function writeConfig(path: string, defaults: GlobalDefaults): Promise<void> {
|
|
188
|
+
const parsed = parseConfig(defaults);
|
|
189
|
+
if (!parsed) throw new Error("Refusing to write invalid delegation policy defaults.");
|
|
190
|
+
await atomicWrite(path, parsed);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function restoreSessionState(entries: unknown[]): SessionDelegateState {
|
|
194
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
195
|
+
const entry = entries[index] as Record<string, unknown> | undefined;
|
|
196
|
+
if (entry?.type !== "custom" || entry.customType !== SESSION_ENTRY_TYPE) continue;
|
|
197
|
+
const state = parseSessionState(entry.data);
|
|
198
|
+
if (state) return state;
|
|
199
|
+
}
|
|
200
|
+
return emptySessionState();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sourceFor<T>(sessionValue: T | undefined, globalValue: T | undefined): ValueSource {
|
|
204
|
+
if (sessionValue !== undefined) return "session";
|
|
205
|
+
if (globalValue !== undefined) return "global";
|
|
206
|
+
return "default";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resolveDelegateState(
|
|
210
|
+
defaults: GlobalDefaults,
|
|
211
|
+
session: SessionDelegateState,
|
|
212
|
+
): EffectiveDelegateState {
|
|
213
|
+
const uiDesign =
|
|
214
|
+
session.uiDesign === undefined
|
|
215
|
+
? copyModelRef(defaults.uiDesign)
|
|
216
|
+
: session.uiDesign === null
|
|
217
|
+
? undefined
|
|
218
|
+
: copyModelRef(session.uiDesign);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
intensity: session.intensity,
|
|
222
|
+
preference: session.preference ?? defaults.preference ?? "standard",
|
|
223
|
+
small: copyModelRef(session.small ?? defaults.small),
|
|
224
|
+
medium: copyModelRef(session.medium ?? defaults.medium),
|
|
225
|
+
large: copyModelRef(session.large ?? defaults.large),
|
|
226
|
+
...(uiDesign ? { uiDesign } : {}),
|
|
227
|
+
source: {
|
|
228
|
+
intensity: "session",
|
|
229
|
+
preference: sourceFor(session.preference, defaults.preference),
|
|
230
|
+
small: sourceFor(session.small, defaults.small),
|
|
231
|
+
medium: sourceFor(session.medium, defaults.medium),
|
|
232
|
+
large: sourceFor(session.large, defaults.large),
|
|
233
|
+
uiDesign: sourceFor(session.uiDesign, defaults.uiDesign),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function defaultsFromEffectiveState(state: EffectiveDelegateState): GlobalDefaults {
|
|
239
|
+
return {
|
|
240
|
+
schemaVersion: 2,
|
|
241
|
+
preference: state.preference,
|
|
242
|
+
...(state.small ? { small: { ...state.small } } : {}),
|
|
243
|
+
...(state.medium ? { medium: { ...state.medium } } : {}),
|
|
244
|
+
...(state.large ? { large: { ...state.large } } : {}),
|
|
245
|
+
...(state.uiDesign ? { uiDesign: { ...state.uiDesign } } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
7
|
+
import { buildDelegationPolicy } from "./prompt.ts";
|
|
8
|
+
import {
|
|
9
|
+
hasRuntimeError,
|
|
10
|
+
loadRuntime,
|
|
11
|
+
sessionEntry,
|
|
12
|
+
statusLabel,
|
|
13
|
+
type RuntimeState,
|
|
14
|
+
} from "./runtime.ts";
|
|
15
|
+
import { openDelegateEditor } from "./ui.ts";
|
|
16
|
+
import { INTENSITIES, type Intensity } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
const STATUS_KEY = "pi-delegation-policy";
|
|
19
|
+
|
|
20
|
+
export type CommandAction =
|
|
21
|
+
| { kind: "open" }
|
|
22
|
+
| { kind: "intensity"; intensity: Intensity }
|
|
23
|
+
| { kind: "status" }
|
|
24
|
+
| { kind: "reset" }
|
|
25
|
+
| { kind: "invalid" };
|
|
26
|
+
|
|
27
|
+
export function parseCommand(args: string): CommandAction {
|
|
28
|
+
const parts = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
29
|
+
if (parts.length === 0) return { kind: "open" };
|
|
30
|
+
if (parts[0] === "status" && parts.length === 1) return { kind: "status" };
|
|
31
|
+
if (parts[0] === "reset" && parts.length === 1) return { kind: "reset" };
|
|
32
|
+
if (parts.length === 1 && INTENSITIES.includes(parts[0] as Intensity)) {
|
|
33
|
+
return { kind: "intensity", intensity: parts[0] as Intensity };
|
|
34
|
+
}
|
|
35
|
+
return { kind: "invalid" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
39
|
+
const options = [...INTENSITIES, "status", "reset"];
|
|
40
|
+
const matches = options.filter((option) => option.startsWith(prefix.toLowerCase()));
|
|
41
|
+
return matches.length ? matches.map((value) => ({ value, label: value })) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function refresh(ctx: ExtensionContext): Promise<RuntimeState> {
|
|
45
|
+
return loadRuntime(ctx);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function updateStatus(ctx: ExtensionContext, state: RuntimeState): void {
|
|
49
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", statusLabel(state)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function configuredLabel(value: unknown): string {
|
|
53
|
+
return value ? "configured" : "unset";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function statusText(state: RuntimeState): string {
|
|
57
|
+
const { effective } = state;
|
|
58
|
+
const details = [
|
|
59
|
+
`${statusLabel(state)} intensity=${effective.intensity}`,
|
|
60
|
+
`preference=${effective.preference} (${effective.source.preference})`,
|
|
61
|
+
`small=${configuredLabel(effective.small)} (${effective.source.small})`,
|
|
62
|
+
`medium=${configuredLabel(effective.medium)} (${effective.source.medium})`,
|
|
63
|
+
`large=${configuredLabel(effective.large)} (${effective.source.large})`,
|
|
64
|
+
`ui-design=${configuredLabel(effective.uiDesign)} (${effective.source.uiDesign})`,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
if (effective.intensity !== "off" && state.runtimeErrors.length > 0) {
|
|
68
|
+
details.push(`details=${state.runtimeErrors.join("; ")}`);
|
|
69
|
+
}
|
|
70
|
+
return details.join(" | ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function setSessionIntensity(
|
|
74
|
+
pi: ExtensionAPI,
|
|
75
|
+
ctx: ExtensionCommandContext,
|
|
76
|
+
intensity: Intensity,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const state = await refresh(ctx);
|
|
79
|
+
state.session = { ...state.session, schemaVersion: 2, intensity };
|
|
80
|
+
sessionEntry(pi, state);
|
|
81
|
+
const updated = await refresh(ctx);
|
|
82
|
+
updateStatus(ctx, updated);
|
|
83
|
+
ctx.ui.notify(`Session delegation intensity: ${intensity}.`, "info");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function resetSession(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
87
|
+
const state = await refresh(ctx);
|
|
88
|
+
state.session = { schemaVersion: 2, intensity: "off" };
|
|
89
|
+
sessionEntry(pi, state);
|
|
90
|
+
const updated = await refresh(ctx);
|
|
91
|
+
updateStatus(ctx, updated);
|
|
92
|
+
ctx.ui.notify("Session delegation settings reset to off.", "info");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function piDelegationPolicy(pi: ExtensionAPI): void {
|
|
96
|
+
pi.registerCommand("delegate", {
|
|
97
|
+
description: "Configure delegation intensity and exact role model references",
|
|
98
|
+
getArgumentCompletions,
|
|
99
|
+
handler: async (args, ctx) => {
|
|
100
|
+
const action = parseCommand(args);
|
|
101
|
+
if (action.kind === "open") {
|
|
102
|
+
await openDelegateEditor(ctx, pi);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (action.kind === "status") {
|
|
106
|
+
const state = await refresh(ctx);
|
|
107
|
+
updateStatus(ctx, state);
|
|
108
|
+
ctx.ui.notify(statusText(state), hasRuntimeError(state) ? "error" : "info");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (action.kind === "reset") {
|
|
112
|
+
await resetSession(pi, ctx);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (action.kind === "intensity") {
|
|
116
|
+
await setSessionIntensity(pi, ctx, action.intensity);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
ctx.ui.notify("Usage: /delegate, /delegate off|normal|aggressive|status|reset", "error");
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.registerShortcut("ctrl+shift+d", {
|
|
124
|
+
description: "Open delegation policy",
|
|
125
|
+
handler: async (ctx) => openDelegateEditor(ctx, pi),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
129
|
+
updateStatus(ctx, await refresh(ctx));
|
|
130
|
+
});
|
|
131
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
132
|
+
updateStatus(ctx, await refresh(ctx));
|
|
133
|
+
});
|
|
134
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
135
|
+
const state = await refresh(ctx);
|
|
136
|
+
updateStatus(ctx, state);
|
|
137
|
+
const policy = buildDelegationPolicy(state);
|
|
138
|
+
return policy ? { systemPrompt: `${event.systemPrompt}\n\n${policy}` } : undefined;
|
|
139
|
+
});
|
|
140
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
141
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
142
|
+
});
|
|
143
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { hasRuntimeError, type RuntimeState } from "./runtime.ts";
|
|
2
|
+
import type { ModelRef, Preference } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const NORMAL_POLICY =
|
|
5
|
+
"Delegate substantial, bounded, and independent work when doing so reduces effort without losing essential context. Keep architecture, global strategy, coordination, integration, final review, and work whose context is costly or risky to transfer in the main agent.";
|
|
6
|
+
const AGGRESSIVE_POLICY =
|
|
7
|
+
"Default to delegating substantial work with a clear objective and acceptance criteria. Keep global decisions, coordination, integration, final review, and work whose context is costly or risky to transfer in the main agent.";
|
|
8
|
+
|
|
9
|
+
function preferenceGuidance(preference: Preference): string {
|
|
10
|
+
if (preference === "efficient") {
|
|
11
|
+
return "Favor Small for routine delegated work. Medium and Large remain available when the task warrants them.";
|
|
12
|
+
}
|
|
13
|
+
if (preference === "intensive") {
|
|
14
|
+
return "Favor Medium for substantial delegated work. Small and Large remain available when the task warrants them.";
|
|
15
|
+
}
|
|
16
|
+
return "Use Small for routine delegated work, Medium for planning, ambiguity, or broad synthesis, and Large only for exceptional blockers.";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function promptString(value: string): string {
|
|
20
|
+
return JSON.stringify(value)
|
|
21
|
+
.replaceAll("<", "\\u003c")
|
|
22
|
+
.replaceAll(">", "\\u003e")
|
|
23
|
+
.replaceAll("&", "\\u0026");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatReference(reference: ModelRef): string {
|
|
27
|
+
return `provider=${promptString(reference.provider)} model=${promptString(reference.model)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildDelegationPolicy(state: RuntimeState): string | undefined {
|
|
31
|
+
if (state.effective.intensity === "off" || hasRuntimeError(state)) return undefined;
|
|
32
|
+
|
|
33
|
+
const { effective } = state;
|
|
34
|
+
if (!effective.small || !effective.medium || !effective.large) return undefined;
|
|
35
|
+
|
|
36
|
+
const intensityPolicy = effective.intensity === "normal" ? NORMAL_POLICY : AGGRESSIVE_POLICY;
|
|
37
|
+
const uiDesign = effective.uiDesign
|
|
38
|
+
? `\n- UI Design: ${formatReference(effective.uiDesign)}. Use this role only for visual design direction, exploration, or review. Never use it to implement an interface, write code, or run tests.`
|
|
39
|
+
: "";
|
|
40
|
+
|
|
41
|
+
return `<delegation_policy>
|
|
42
|
+
Intensity: ${effective.intensity}.
|
|
43
|
+
${intensityPolicy}
|
|
44
|
+
|
|
45
|
+
Model preference: ${effective.preference}. ${preferenceGuidance(effective.preference)}
|
|
46
|
+
|
|
47
|
+
Use the exact provider and model for the selected role. The references below use JSON string syntax; interpret escaped characters as JSON before use. Do not invent a fallback model or role. Choose thinking dynamically for each delegation from the task, difficulty, volume, and the selected model's capabilities. Do not treat thinking as persisted configuration.
|
|
48
|
+
|
|
49
|
+
Roles:
|
|
50
|
+
- Small: ${formatReference(effective.small)}
|
|
51
|
+
- Medium: ${formatReference(effective.medium)}
|
|
52
|
+
- Large: ${formatReference(effective.large)}${uiDesign}
|
|
53
|
+
|
|
54
|
+
This is guidance for the main agent. It does not create, execute, route, supervise, or enforce delegated work.
|
|
55
|
+
</delegation_policy>`;
|
|
56
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import {
|
|
4
|
+
getGlobalConfigPath,
|
|
5
|
+
readConfig,
|
|
6
|
+
SESSION_ENTRY_TYPE,
|
|
7
|
+
resolveDelegateState,
|
|
8
|
+
restoreSessionState,
|
|
9
|
+
type ConfigDiagnostic,
|
|
10
|
+
} from "./config.ts";
|
|
11
|
+
import {
|
|
12
|
+
MODEL_ROLES,
|
|
13
|
+
ROLE_LABELS,
|
|
14
|
+
type EffectiveDelegateState,
|
|
15
|
+
type GlobalDefaults,
|
|
16
|
+
type ModelConfigKey,
|
|
17
|
+
type ModelRef,
|
|
18
|
+
type ModelRole,
|
|
19
|
+
type ModelStatus,
|
|
20
|
+
type SessionDelegateState,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
|
|
23
|
+
export type RuntimeState = {
|
|
24
|
+
effective: EffectiveDelegateState;
|
|
25
|
+
global: GlobalDefaults;
|
|
26
|
+
session: SessionDelegateState;
|
|
27
|
+
diagnostics: ConfigDiagnostic[];
|
|
28
|
+
modelStatuses: Map<ModelConfigKey, ModelStatus>;
|
|
29
|
+
runtimeErrors: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function matchesReference(model: Model<Api>, reference: ModelRef): boolean {
|
|
33
|
+
return model.provider === reference.provider && model.id === reference.model;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function uniqueModels(models: readonly Model<Api>[]): Model<Api>[] {
|
|
37
|
+
const seen = new Set<string>();
|
|
38
|
+
return models.filter((model) => {
|
|
39
|
+
const key = `${model.provider}\u0000${model.id}`;
|
|
40
|
+
if (seen.has(key)) return false;
|
|
41
|
+
seen.add(key);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function modelCandidates(ctx: ExtensionContext): Model<Api>[] {
|
|
47
|
+
const scopedModels = ctx.scopedModels ?? [];
|
|
48
|
+
const models = scopedModels.length
|
|
49
|
+
? scopedModels.map(({ model }) => model)
|
|
50
|
+
: ctx.modelRegistry.getAvailable();
|
|
51
|
+
return uniqueModels(models).filter((model) => ctx.modelRegistry.hasConfiguredAuth(model));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateModelReference(ctx: ExtensionContext, reference: ModelRef): ModelStatus {
|
|
55
|
+
const registered = ctx.modelRegistry.find(reference.provider, reference.model);
|
|
56
|
+
if (!registered) return { kind: "missing-model" };
|
|
57
|
+
|
|
58
|
+
const scopedModels = ctx.scopedModels ?? [];
|
|
59
|
+
if (scopedModels.length > 0) {
|
|
60
|
+
const scoped = scopedModels
|
|
61
|
+
.map(({ model }) => model)
|
|
62
|
+
.find((model) => matchesReference(model, reference));
|
|
63
|
+
if (!scoped) return { kind: "outside-scope" };
|
|
64
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(scoped)) return { kind: "no-credentials" };
|
|
65
|
+
return { kind: "available", model: scoped };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const available = ctx.modelRegistry
|
|
69
|
+
.getAvailable()
|
|
70
|
+
.find((model) => matchesReference(model, reference));
|
|
71
|
+
if (!available) return { kind: "unavailable" };
|
|
72
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(available)) return { kind: "no-credentials" };
|
|
73
|
+
return { kind: "available", model: available };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function statusDetail(status: ModelStatus): string {
|
|
77
|
+
switch (status.kind) {
|
|
78
|
+
case "missing-model":
|
|
79
|
+
return "is not registered in Pi";
|
|
80
|
+
case "outside-scope":
|
|
81
|
+
return "is outside the current model scope";
|
|
82
|
+
case "unavailable":
|
|
83
|
+
return "is not available";
|
|
84
|
+
case "no-credentials":
|
|
85
|
+
return "has no configured authentication";
|
|
86
|
+
case "available":
|
|
87
|
+
return "is available";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function referenceFor(state: RuntimeState, role: ModelRole): ModelRef | undefined {
|
|
92
|
+
return state.effective[role];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateRole(ctx: ExtensionContext, state: RuntimeState, role: ModelConfigKey): void {
|
|
96
|
+
const reference = role === "uiDesign" ? state.effective.uiDesign : referenceFor(state, role);
|
|
97
|
+
if (!reference) {
|
|
98
|
+
state.runtimeErrors.push(`${ROLE_LABELS[role]} model is not configured.`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const status = validateModelReference(ctx, reference);
|
|
103
|
+
state.modelStatuses.set(role, status);
|
|
104
|
+
if (status.kind !== "available") {
|
|
105
|
+
state.runtimeErrors.push(`${ROLE_LABELS[role]} model ${statusDetail(status)}.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function loadRuntime(ctx: ExtensionContext): Promise<RuntimeState> {
|
|
110
|
+
const loaded = await readConfig(getGlobalConfigPath());
|
|
111
|
+
const session = restoreSessionState(ctx.sessionManager.getBranch());
|
|
112
|
+
const state: RuntimeState = {
|
|
113
|
+
effective: resolveDelegateState(loaded.defaults, session),
|
|
114
|
+
global: loaded.defaults,
|
|
115
|
+
session,
|
|
116
|
+
diagnostics: loaded.diagnostics,
|
|
117
|
+
modelStatuses: new Map(),
|
|
118
|
+
runtimeErrors: [],
|
|
119
|
+
};
|
|
120
|
+
validateRuntime(ctx, state);
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validateRuntime(ctx: ExtensionContext, state: RuntimeState): void {
|
|
125
|
+
state.effective = resolveDelegateState(state.global, state.session);
|
|
126
|
+
state.modelStatuses.clear();
|
|
127
|
+
state.runtimeErrors = [];
|
|
128
|
+
|
|
129
|
+
if (state.effective.intensity === "off") return;
|
|
130
|
+
|
|
131
|
+
for (const diagnostic of state.diagnostics) state.runtimeErrors.push(diagnostic.message);
|
|
132
|
+
for (const role of MODEL_ROLES) validateRole(ctx, state, role);
|
|
133
|
+
if (state.effective.uiDesign) validateRole(ctx, state, "uiDesign");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function hasRuntimeError(state: RuntimeState): boolean {
|
|
137
|
+
return state.effective.intensity !== "off" && state.runtimeErrors.length > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function statusLabel(state: RuntimeState): string {
|
|
141
|
+
if (state.effective.intensity === "off") return "D:OFF";
|
|
142
|
+
if (hasRuntimeError(state)) return "D:ERR";
|
|
143
|
+
return state.effective.intensity === "normal" ? "D:NORM" : "D:AGG";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function formatModelRef(reference: ModelRef | undefined): string {
|
|
147
|
+
return reference ? `${reference.provider}/${reference.model}` : "not configured";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function sessionEntry(
|
|
151
|
+
pi: { appendEntry: (type: string, data?: unknown) => void },
|
|
152
|
+
state: RuntimeState,
|
|
153
|
+
): void {
|
|
154
|
+
pi.appendEntry(SESSION_ENTRY_TYPE, state.session);
|
|
155
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const INTENSITIES = ["off", "normal", "aggressive"] as const;
|
|
4
|
+
export type Intensity = (typeof INTENSITIES)[number];
|
|
5
|
+
|
|
6
|
+
export const PREFERENCES = ["efficient", "standard", "intensive"] as const;
|
|
7
|
+
export type Preference = (typeof PREFERENCES)[number];
|
|
8
|
+
|
|
9
|
+
export const MODEL_ROLES = ["small", "medium", "large"] as const;
|
|
10
|
+
export type ModelRole = (typeof MODEL_ROLES)[number];
|
|
11
|
+
export type ModelConfigKey = ModelRole | "uiDesign";
|
|
12
|
+
|
|
13
|
+
export const ROLE_LABELS: Record<ModelConfigKey, string> = {
|
|
14
|
+
small: "Small",
|
|
15
|
+
medium: "Medium",
|
|
16
|
+
large: "Large",
|
|
17
|
+
uiDesign: "UI Design",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ModelRef = {
|
|
21
|
+
provider: string;
|
|
22
|
+
model: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type GlobalDefaults = {
|
|
26
|
+
schemaVersion: 2;
|
|
27
|
+
preference?: Preference;
|
|
28
|
+
small?: ModelRef;
|
|
29
|
+
medium?: ModelRef;
|
|
30
|
+
large?: ModelRef;
|
|
31
|
+
uiDesign?: ModelRef;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SessionDelegateState = {
|
|
35
|
+
schemaVersion: 2;
|
|
36
|
+
intensity: Intensity;
|
|
37
|
+
preference?: Preference;
|
|
38
|
+
small?: ModelRef;
|
|
39
|
+
medium?: ModelRef;
|
|
40
|
+
large?: ModelRef;
|
|
41
|
+
// Null explicitly disables a global UI Design role for this session branch.
|
|
42
|
+
uiDesign?: ModelRef | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ValueSource = "default" | "global" | "session";
|
|
46
|
+
|
|
47
|
+
export type EffectiveDelegateState = {
|
|
48
|
+
intensity: Intensity;
|
|
49
|
+
preference: Preference;
|
|
50
|
+
small?: ModelRef;
|
|
51
|
+
medium?: ModelRef;
|
|
52
|
+
large?: ModelRef;
|
|
53
|
+
uiDesign?: ModelRef;
|
|
54
|
+
source: {
|
|
55
|
+
intensity: "session";
|
|
56
|
+
preference: ValueSource;
|
|
57
|
+
small: ValueSource;
|
|
58
|
+
medium: ValueSource;
|
|
59
|
+
large: ValueSource;
|
|
60
|
+
uiDesign: ValueSource;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ModelStatus =
|
|
65
|
+
| { kind: "available"; model: Model<Api> }
|
|
66
|
+
| { kind: "missing-model" }
|
|
67
|
+
| { kind: "outside-scope" }
|
|
68
|
+
| { kind: "unavailable" }
|
|
69
|
+
| { kind: "no-credentials" };
|
|
70
|
+
|
|
71
|
+
export function emptyGlobalDefaults(): GlobalDefaults {
|
|
72
|
+
return { schemaVersion: 2 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function emptySessionState(): SessionDelegateState {
|
|
76
|
+
return { schemaVersion: 2, intensity: "off" };
|
|
77
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
defaultsFromEffectiveState,
|
|
4
|
+
getGlobalConfigPath,
|
|
5
|
+
resolveDelegateState,
|
|
6
|
+
writeConfig,
|
|
7
|
+
} from "./config.ts";
|
|
8
|
+
import {
|
|
9
|
+
formatModelRef,
|
|
10
|
+
loadRuntime,
|
|
11
|
+
modelCandidates,
|
|
12
|
+
sessionEntry,
|
|
13
|
+
type RuntimeState,
|
|
14
|
+
} from "./runtime.ts";
|
|
15
|
+
import {
|
|
16
|
+
emptySessionState,
|
|
17
|
+
INTENSITIES,
|
|
18
|
+
MODEL_ROLES,
|
|
19
|
+
PREFERENCES,
|
|
20
|
+
ROLE_LABELS,
|
|
21
|
+
type Intensity,
|
|
22
|
+
type ModelRef,
|
|
23
|
+
type ModelRole,
|
|
24
|
+
type Preference,
|
|
25
|
+
type SessionDelegateState,
|
|
26
|
+
type ValueSource,
|
|
27
|
+
} from "./types.ts";
|
|
28
|
+
|
|
29
|
+
const USE_GLOBAL_DEFAULT = "Use global default";
|
|
30
|
+
const DISABLE_FOR_SESSION = "Disable for this session";
|
|
31
|
+
const APPLY_TO_SESSION = "Apply changes to this session";
|
|
32
|
+
const SAVE_AS_DEFAULTS = "Save effective configuration as defaults";
|
|
33
|
+
const RESET_SESSION = "Reset session to off";
|
|
34
|
+
const CANCEL = "Cancel";
|
|
35
|
+
|
|
36
|
+
type ModelSelection =
|
|
37
|
+
{ kind: "global" } | { kind: "disabled" } | { kind: "model"; reference: ModelRef } | undefined;
|
|
38
|
+
|
|
39
|
+
function clone<T>(value: T): T {
|
|
40
|
+
return structuredClone(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sourceLabel(source: ValueSource): string {
|
|
44
|
+
if (source === "session") return "session";
|
|
45
|
+
if (source === "global") return "global";
|
|
46
|
+
return "built-in";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function modelOption(reference: ModelRef): string {
|
|
50
|
+
return `${reference.provider}/${reference.model}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function selectOrCancel(
|
|
54
|
+
ctx: ExtensionContext,
|
|
55
|
+
title: string,
|
|
56
|
+
options: string[],
|
|
57
|
+
): Promise<string | undefined> {
|
|
58
|
+
if (!ctx.hasUI) return undefined;
|
|
59
|
+
return ctx.ui.select(title, options);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function selectModel(
|
|
63
|
+
ctx: ExtensionContext,
|
|
64
|
+
title: string,
|
|
65
|
+
options: { includeDisable: boolean },
|
|
66
|
+
): Promise<ModelSelection> {
|
|
67
|
+
const candidateOptions = new Map<string, ModelRef>();
|
|
68
|
+
for (const [index, model] of modelCandidates(ctx)
|
|
69
|
+
.sort((left, right) => {
|
|
70
|
+
const provider = left.provider.localeCompare(right.provider);
|
|
71
|
+
return provider === 0 ? left.id.localeCompare(right.id) : provider;
|
|
72
|
+
})
|
|
73
|
+
.entries()) {
|
|
74
|
+
const option = `${index + 1}. ${model.provider}/${model.id}${
|
|
75
|
+
model.name ? ` (${model.name})` : ""
|
|
76
|
+
}`;
|
|
77
|
+
candidateOptions.set(option, { provider: model.provider, model: model.id });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const selected = await selectOrCancel(ctx, title, [
|
|
81
|
+
USE_GLOBAL_DEFAULT,
|
|
82
|
+
...(options.includeDisable ? [DISABLE_FOR_SESSION] : []),
|
|
83
|
+
...candidateOptions.keys(),
|
|
84
|
+
]);
|
|
85
|
+
if (!selected) return undefined;
|
|
86
|
+
if (selected === USE_GLOBAL_DEFAULT) return { kind: "global" };
|
|
87
|
+
if (selected === DISABLE_FOR_SESSION) return { kind: "disabled" };
|
|
88
|
+
const reference = candidateOptions.get(selected);
|
|
89
|
+
return reference ? { kind: "model", reference } : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function selectRole(
|
|
93
|
+
ctx: ExtensionContext,
|
|
94
|
+
draft: SessionDelegateState,
|
|
95
|
+
role: ModelRole,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const selection = await selectModel(ctx, `${ROLE_LABELS[role]} model`, { includeDisable: false });
|
|
98
|
+
if (!selection) return;
|
|
99
|
+
if (selection.kind === "global") delete draft[role];
|
|
100
|
+
else if (selection.kind === "model") draft[role] = selection.reference;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function selectUiDesign(ctx: ExtensionContext, draft: SessionDelegateState): Promise<void> {
|
|
104
|
+
const selection = await selectModel(ctx, "UI Design model", { includeDisable: true });
|
|
105
|
+
if (!selection) return;
|
|
106
|
+
if (selection.kind === "global") delete draft.uiDesign;
|
|
107
|
+
else if (selection.kind === "disabled") draft.uiDesign = null;
|
|
108
|
+
else draft.uiDesign = selection.reference;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function selectIntensity(ctx: ExtensionContext, draft: SessionDelegateState): Promise<void> {
|
|
112
|
+
const selected = await selectOrCancel(ctx, "Delegation intensity", [...INTENSITIES]);
|
|
113
|
+
if (selected) draft.intensity = selected as Intensity;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function selectPreference(ctx: ExtensionContext, draft: SessionDelegateState): Promise<void> {
|
|
117
|
+
const selected = await selectOrCancel(ctx, "Model preference", [
|
|
118
|
+
USE_GLOBAL_DEFAULT,
|
|
119
|
+
...PREFERENCES,
|
|
120
|
+
]);
|
|
121
|
+
if (!selected) return;
|
|
122
|
+
if (selected === USE_GLOBAL_DEFAULT) delete draft.preference;
|
|
123
|
+
else draft.preference = selected as Preference;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function menuOptions(state: RuntimeState, draft: SessionDelegateState): string[] {
|
|
127
|
+
const effective = resolveDelegateState(state.global, draft);
|
|
128
|
+
const options = [
|
|
129
|
+
`Intensity: ${effective.intensity} (${sourceLabel(effective.source.intensity)})`,
|
|
130
|
+
`Preference: ${effective.preference} (${sourceLabel(effective.source.preference)})`,
|
|
131
|
+
...MODEL_ROLES.map(
|
|
132
|
+
(role) =>
|
|
133
|
+
`${ROLE_LABELS[role]}: ${formatModelRef(effective[role])} (${sourceLabel(
|
|
134
|
+
effective.source[role],
|
|
135
|
+
)})`,
|
|
136
|
+
),
|
|
137
|
+
`UI Design: ${effective.uiDesign ? "on" : "off"} (${sourceLabel(effective.source.uiDesign)})`,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
if (effective.uiDesign) {
|
|
141
|
+
options.push(
|
|
142
|
+
`UI Design model: ${modelOption(effective.uiDesign)} (${sourceLabel(
|
|
143
|
+
effective.source.uiDesign,
|
|
144
|
+
)}; visual design only)`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [...options, APPLY_TO_SESSION, SAVE_AS_DEFAULTS, RESET_SESSION, CANCEL];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startsWithOption(choice: string, name: string): boolean {
|
|
152
|
+
return choice.startsWith(`${name}:`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function openDelegateEditor(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
|
|
156
|
+
if (!ctx.hasUI) return;
|
|
157
|
+
|
|
158
|
+
const state = await loadRuntime(ctx);
|
|
159
|
+
let draft = clone(state.session);
|
|
160
|
+
|
|
161
|
+
while (true) {
|
|
162
|
+
const effective = resolveDelegateState(state.global, draft);
|
|
163
|
+
const selected = await selectOrCancel(ctx, "Delegation policy", menuOptions(state, draft));
|
|
164
|
+
if (!selected || selected === CANCEL) return;
|
|
165
|
+
|
|
166
|
+
if (startsWithOption(selected, "Intensity")) {
|
|
167
|
+
await selectIntensity(ctx, draft);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (startsWithOption(selected, "Preference")) {
|
|
171
|
+
await selectPreference(ctx, draft);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const role = MODEL_ROLES.find((candidate) =>
|
|
175
|
+
startsWithOption(selected, ROLE_LABELS[candidate]),
|
|
176
|
+
);
|
|
177
|
+
if (role) {
|
|
178
|
+
await selectRole(ctx, draft, role);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (startsWithOption(selected, "UI Design model") || startsWithOption(selected, "UI Design")) {
|
|
182
|
+
await selectUiDesign(ctx, draft);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (selected === APPLY_TO_SESSION) {
|
|
186
|
+
state.session = clone(draft);
|
|
187
|
+
sessionEntry(pi, state);
|
|
188
|
+
ctx.ui.notify("Saved delegation settings for this session branch.", "info");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (selected === SAVE_AS_DEFAULTS) {
|
|
192
|
+
try {
|
|
193
|
+
const defaults = defaultsFromEffectiveState(effective);
|
|
194
|
+
await writeConfig(getGlobalConfigPath(), defaults);
|
|
195
|
+
state.global = defaults;
|
|
196
|
+
state.diagnostics = [];
|
|
197
|
+
ctx.ui.notify("Saved effective role settings as global defaults.", "info");
|
|
198
|
+
} catch {
|
|
199
|
+
ctx.ui.notify(
|
|
200
|
+
"Could not save global defaults. Session settings were not changed.",
|
|
201
|
+
"error",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (selected === RESET_SESSION) {
|
|
206
|
+
draft = emptySessionState();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|