pi-landstrip 0.17.6 → 0.17.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -20
- package/agents.ts +258 -0
- package/config.ts +116 -0
- package/index.ts +817 -377
- package/landstrip.json +49 -0
- package/package.json +21 -14
- package/rpc-process.ts +520 -0
- package/subagents.ts +1581 -0
package/README.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# pi-landstrip
|
|
2
2
|
|
|
3
|
-
`pi-landstrip` is an extension for [pi](https://pi.dev/) providing
|
|
4
|
-
defined with a policy compatible with Anthropic's JSON
|
|
5
|
-
[`landstrip`](https://github.com/landstrip/landstrip) to
|
|
3
|
+
`pi-landstrip` is an extension for [pi](https://pi.dev/) providing sandbox-aware
|
|
4
|
+
subagents and a sandbox defined with a policy compatible with Anthropic's JSON
|
|
5
|
+
format. It uses [`landstrip`](https://github.com/landstrip/landstrip) to
|
|
6
|
+
implement the sandbox.
|
|
6
7
|
|
|
7
|
-
`pi-landstrip` has a
|
|
8
|
-
|
|
8
|
+
`pi-landstrip` has a shared sandbox policy [sandbox.json](./sandbox.json) and a
|
|
9
|
+
separate [landstrip.json](./landstrip.json) for primary-agent and subagent
|
|
10
|
+
configuration. Global and trusted project files can override both.
|
|
11
|
+
|
|
12
|
+
Pi >=0.80.6 <0.81.0 and Node.js >=22.19.0 are required for process-backed subagents.
|
|
9
13
|
|
|
10
14
|
## Installing the extension
|
|
11
15
|
|
|
@@ -15,8 +19,8 @@ the define either or both global or project specific policies.
|
|
|
15
19
|
pi install npm:pi-landstrip
|
|
16
20
|
```
|
|
17
21
|
|
|
18
|
-
This installs `pi-landstrip` and its `@landstrip/landstrip` dependency
|
|
19
|
-
|
|
22
|
+
This installs `pi-landstrip` and its `@landstrip/landstrip` dependency. Native
|
|
23
|
+
binaries are currently published for Linux x64, Windows x64, and macOS x64/arm64.
|
|
20
24
|
|
|
21
25
|
### Manual install
|
|
22
26
|
|
|
@@ -37,8 +41,7 @@ On unsupported platforms the extension loads but leaves sandboxing disabled.
|
|
|
37
41
|
|
|
38
42
|
## Disable
|
|
39
43
|
|
|
40
|
-
Use the `--no-sandbox` flag, or set `enabled` to `false` in
|
|
41
|
-
config:
|
|
44
|
+
Use the `--no-sandbox` flag, or set `enabled` to `false` in `sandbox.json`:
|
|
42
45
|
|
|
43
46
|
```json
|
|
44
47
|
{
|
|
@@ -46,8 +49,14 @@ config:
|
|
|
46
49
|
}
|
|
47
50
|
```
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
When sandboxing is explicitly disabled, subagents still run as separate Pi RPC
|
|
53
|
+
processes, but without the outer Landstrip OS sandbox. The extension warns once
|
|
54
|
+
per session. Agent tool permissions still apply, but they are not an OS isolation
|
|
55
|
+
boundary.
|
|
56
|
+
|
|
57
|
+
Trusted project config overrides global config. `/landstrip` updates a trusted
|
|
58
|
+
project sandbox file when present, otherwise the global file. Pi versions
|
|
59
|
+
without a project-trust API use only global configuration.
|
|
51
60
|
|
|
52
61
|
## Behavior
|
|
53
62
|
|
|
@@ -59,16 +68,184 @@ reject. The dialog shows the exact path or domain being approved.
|
|
|
59
68
|
Project approvals are written to `.pi/sandbox.json`; global approvals are
|
|
60
69
|
written to `~/.pi/agent/sandbox.json`.
|
|
61
70
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
The main agent remains a normal Pi process. Pi performs its usual filesystem
|
|
72
|
+
tool and plugin composition; `pi-landstrip` replaces Bash execution, including
|
|
73
|
+
AI `bash` calls and manually typed shell-mode commands (`!` and `!!`), with a
|
|
74
|
+
Landstrip-wrapped implementation. Network traffic is routed through an
|
|
75
|
+
allowlist proxy when network access is off. Main-agent filesystem tools and
|
|
76
|
+
plugin callbacks remain trusted Pi code and are not confined by Landstrip. The
|
|
77
|
+
default policy for Bash and subagent processes is strict: network access is off
|
|
78
|
+
unless domains are allowed, reads are limited to the project, `~/.gitconfig`,
|
|
79
|
+
and `/dev/null`, and writes are limited to the project and `/dev/null`.
|
|
80
|
+
|
|
81
|
+
Subagent startup adds read-only bootstrap access to the selected Pi runtime,
|
|
82
|
+
global settings, model/auth configuration, installed plugins, skills, and the
|
|
83
|
+
task's dedicated session directory. These paths are required to construct a
|
|
84
|
+
normal Pi worker and are not persisted into `sandbox.json`. The worker receives
|
|
85
|
+
write access only to its own session and temporary directories.
|
|
86
|
+
|
|
87
|
+
Use `/landstrip` inside Pi. Its Sandbox tab shows the active policy, its Agents
|
|
88
|
+
tab selects the primary role, and its Settings tab controls sandboxing and the
|
|
89
|
+
maximum number of concurrent subagents.
|
|
90
|
+
|
|
91
|
+
## Primary agents
|
|
92
|
+
|
|
93
|
+
The Agents tab provides OpenCode-compatible `build` and `plan` roles by default.
|
|
94
|
+
Build has normal development access; plan asks before shell commands and file
|
|
95
|
+
changes. The selection controls the root system prompt and permissions and is
|
|
96
|
+
restored with the session.
|
|
97
|
+
|
|
98
|
+
## Subagents
|
|
99
|
+
|
|
100
|
+
Landstrip provides an OpenCode-compatible `task` tool. Each active task is a
|
|
101
|
+
full `pi --mode rpc` process under one outer Landstrip instance. The sandbox
|
|
102
|
+
covers Pi itself, its plugins and tools, model requests, and every descendant
|
|
103
|
+
process. The root Pi supervises RPC, permissions, nesting, persistence, and
|
|
104
|
+
completion delivery.
|
|
105
|
+
|
|
106
|
+
The worker uses normal Pi resource discovery and plugin loading from the Pi
|
|
107
|
+
filesystem, plus the pi-landstrip worker extension. Its requested active tool
|
|
108
|
+
names come from the parent, but their implementations are composed normally in
|
|
109
|
+
the worker: plugins can add or replace tools, including replacements such as
|
|
110
|
+
`pi-readseek`. Every task run starts a fresh Pi process and fresh plugin
|
|
111
|
+
instances. Continuing a `task_id` restores its persisted Pi session in a new
|
|
112
|
+
worker process.
|
|
113
|
+
|
|
114
|
+
The tool accepts the OpenCode task fields:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"description": "Review sandbox boundary",
|
|
119
|
+
"prompt": "Review the sandbox implementation and report concrete issues.",
|
|
120
|
+
"subagent_type": "review",
|
|
121
|
+
"task_id": "optional-session-id",
|
|
122
|
+
"command": "optional-originating-command",
|
|
123
|
+
"background": false
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Foreground tasks return the child result directly. Background tasks return a
|
|
128
|
+
running result while the worker continues, then deliver completion to the
|
|
129
|
+
parent automatically. The process exits when the run settles; its Pi session
|
|
130
|
+
remains on disk and can be continued with `task_id`. While tasks are active, Pi
|
|
131
|
+
shows their parent-child tree automatically above the editor.
|
|
132
|
+
Session switching or shutdown stops live workers. After an unclean restart,
|
|
133
|
+
unfinished work is marked interrupted rather than silently rerun; a completed
|
|
134
|
+
but undelivered background result is delivered when the root session resumes.
|
|
135
|
+
|
|
136
|
+
OpenCode-style worker permissions are enforced around the worker's composed
|
|
137
|
+
tools: `deny` blocks a call, `ask` suspends that worker and asks in the root UI,
|
|
138
|
+
and `allow` lets the tool run. Other workers may continue while a prompt is
|
|
139
|
+
open. An "Allow for this session" worker-permission decision applies to the
|
|
140
|
+
root session and its descendants. These permissions are separate from the
|
|
141
|
+
outer Landstrip policy and cannot widen it. Sandbox approvals continue to use
|
|
142
|
+
`.pi/sandbox.json` and `~/.pi/agent/sandbox.json`.
|
|
143
|
+
|
|
144
|
+
Subagents fail closed when sandboxing is expected but unavailable, unsupported,
|
|
145
|
+
or missing. An explicit `--no-sandbox` flag or `enabled: false` configuration is
|
|
146
|
+
treated as an intentional opt-out and uses the warned unsandboxed process path.
|
|
147
|
+
Unsupported Pi versions still fail task startup.
|
|
148
|
+
|
|
149
|
+
### Platform behavior
|
|
150
|
+
|
|
151
|
+
- **Linux**: seccomp query traps can grant a blocked filesystem or network
|
|
152
|
+
operation dynamically, so an approved worker can continue without restart.
|
|
153
|
+
- **macOS**: Seatbelt policy is fixed when the worker starts. Update
|
|
154
|
+
`sandbox.json`, then restart or continue the task in a fresh worker to apply
|
|
155
|
+
additional filesystem access. Main-agent Bash can retry a command, but there
|
|
156
|
+
is no live worker-policy update.
|
|
157
|
+
- **Windows**: AppContainer cannot permit only the loopback proxy port, so
|
|
158
|
+
domain-proxied worker networking is unavailable and the worker has no network.
|
|
159
|
+
Setting `network.allowNetwork` to `true` is a degraded mode that permits model
|
|
160
|
+
access but gives the entire worker unrestricted network access; domain lists
|
|
161
|
+
are then not a boundary.
|
|
162
|
+
|
|
163
|
+
### Credentials
|
|
164
|
+
|
|
165
|
+
Model requests originate inside each worker. Pi's `auth.json` is readable but
|
|
166
|
+
not writable by workers. Inherited credential environment variables also cross
|
|
167
|
+
the sandbox boundary, and plugins loaded in that worker share access to these
|
|
168
|
+
credentials. Landstrip does not provide a credential broker: load only trusted
|
|
169
|
+
plugins and use credentials appropriate for the sandboxed process. An OAuth
|
|
170
|
+
refresh that needs to rewrite `auth.json` must be completed in the root Pi
|
|
171
|
+
first.
|
|
172
|
+
|
|
173
|
+
## Configuration
|
|
174
|
+
|
|
175
|
+
Subagent configuration is read from `~/.pi/agent/landstrip.json` and, for
|
|
176
|
+
trusted projects, `.pi/landstrip.json`. Project values override global values;
|
|
177
|
+
both are merged over the packaged [default](./landstrip.json). Sandbox policy
|
|
178
|
+
continues to use `~/.pi/agent/sandbox.json` and `.pi/sandbox.json`. Pi's
|
|
179
|
+
`settings.json` contains only normal Pi settings such as the `pi-landstrip`
|
|
180
|
+
package entry. `landstrip.json` accepts only top-level `maxSubagents` and
|
|
181
|
+
`subagents`; sandbox fields belong only in `sandbox.json`.
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"maxSubagents": 2,
|
|
186
|
+
"subagents": {
|
|
187
|
+
"agent": {
|
|
188
|
+
"review": {
|
|
189
|
+
"description": "Review code without modifying it",
|
|
190
|
+
"mode": "subagent",
|
|
191
|
+
"prompt": "Review the requested code and report concrete findings.",
|
|
192
|
+
"permission": {
|
|
193
|
+
"edit": "deny",
|
|
194
|
+
"bash": "ask"
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"permission": {
|
|
199
|
+
"task": {
|
|
200
|
+
"*": "deny",
|
|
201
|
+
"review": "allow"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`maxSubagents` is an integer from 0 through 16 controlling concurrent worker
|
|
209
|
+
processes. Setting it to zero removes the `task` tool while retaining the Agents
|
|
210
|
+
tab and primary roles. There is no separate subagent enable switch. The Settings
|
|
211
|
+
tab in `/landstrip` edits this limit and the persisted sandbox enable checkbox;
|
|
212
|
+
the Sandbox tab only reports effective policy and status.
|
|
213
|
+
|
|
214
|
+
Agent modes, hidden/disabled agents, prompts, and ordered `allow`/`ask`/`deny`
|
|
215
|
+
permissions apply to primary agents and subagents. Subagent workers also honor
|
|
216
|
+
model selection, supported Pi thinking-level variants, and step limits. Primary
|
|
217
|
+
agent activation currently changes the prompt and permissions only. Later
|
|
218
|
+
matching permission rules win. Put provider-specific values under `options`;
|
|
219
|
+
unknown agent fields are rejected. Agent permissions cannot weaken an enabled
|
|
220
|
+
OS sandbox, grant filesystem access, or grant network access.
|
|
221
|
+
|
|
222
|
+
## Migrating configuration
|
|
223
|
+
|
|
224
|
+
There is no compatibility loader for previous subagent configuration sources.
|
|
225
|
+
Legacy keys in `settings.json` produce a migration warning. Move configuration as
|
|
226
|
+
follows, then remove the old fields:
|
|
227
|
+
|
|
228
|
+
- Move `agent` and `permission` from Pi `settings.json` or OpenCode JSON under
|
|
229
|
+
`subagents.agent` and `subagents.permission`.
|
|
230
|
+
- Convert legacy `tools` booleans to explicit `permission` rules.
|
|
231
|
+
- Move Markdown agent prompts into each `subagents.agent.<name>.prompt` string.
|
|
232
|
+
- Put the worker limit in top-level `maxSubagents`; zero disables delegation.
|
|
233
|
+
- Use `~/.pi/agent/landstrip.json` for global configuration and
|
|
234
|
+
`.pi/landstrip.json` for trusted project configuration.
|
|
235
|
+
- Leave sandbox policy in `~/.pi/agent/sandbox.json` and `.pi/sandbox.json`;
|
|
236
|
+
those files are unchanged.
|
|
237
|
+
|
|
238
|
+
Do not put `subagents`, `agent`, `permission`, or `maxSubagents` in Pi's
|
|
239
|
+
`settings.json`; Pi-Landstrip does not read them there.
|
|
240
|
+
|
|
241
|
+
## Limits
|
|
70
242
|
|
|
71
|
-
|
|
243
|
+
Pi-Landstrip runs at most `maxSubagents` worker processes concurrently and
|
|
244
|
+
allows nesting to three levels. A foreground parent hands its scheduler permit to a child
|
|
245
|
+
while waiting, preventing nested-task deadlocks. A worker receives a nested
|
|
246
|
+
`task` tool only when its agent has an explicit `task` permission. Nested tasks
|
|
247
|
+
are separate Pi processes supervised by the root and Landstrip-wrapped unless
|
|
248
|
+
sandboxing is explicitly disabled. Persisted sessions remain resumable by `task_id`.
|
|
72
249
|
|
|
73
250
|
## License
|
|
74
251
|
|
package/agents.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { minimatch } from 'minimatch';
|
|
9
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
10
|
+
|
|
11
|
+
import { loadLandstripConfig, type ConfigObject } from './config.ts';
|
|
12
|
+
|
|
13
|
+
export type PermissionAction = 'allow' | 'ask' | 'deny';
|
|
14
|
+
|
|
15
|
+
export interface PermissionRule {
|
|
16
|
+
readonly permission: string;
|
|
17
|
+
readonly pattern: string;
|
|
18
|
+
readonly action: PermissionAction;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PermissionRules = readonly PermissionRule[];
|
|
22
|
+
|
|
23
|
+
export interface AgentDefinition {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly description?: string;
|
|
26
|
+
readonly prompt: string;
|
|
27
|
+
readonly mode: 'primary' | 'subagent' | 'all';
|
|
28
|
+
readonly model?: string;
|
|
29
|
+
readonly variant?: string;
|
|
30
|
+
readonly hidden: boolean;
|
|
31
|
+
readonly steps?: number;
|
|
32
|
+
readonly permissions: PermissionRules;
|
|
33
|
+
readonly providerOptions: Readonly<Record<string, unknown>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AgentCatalog {
|
|
37
|
+
readonly agents: ReadonlyMap<string, AgentDefinition>;
|
|
38
|
+
readonly permissions: PermissionRules;
|
|
39
|
+
readonly diagnostics: readonly string[];
|
|
40
|
+
readonly warnings: readonly string[];
|
|
41
|
+
readonly maxSubagents: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const AGENT_FIELDS = new Set([
|
|
45
|
+
'name',
|
|
46
|
+
'description',
|
|
47
|
+
'prompt',
|
|
48
|
+
'mode',
|
|
49
|
+
'model',
|
|
50
|
+
'variant',
|
|
51
|
+
'hidden',
|
|
52
|
+
'disable',
|
|
53
|
+
'steps',
|
|
54
|
+
'permission',
|
|
55
|
+
'temperature',
|
|
56
|
+
'top_p',
|
|
57
|
+
'options',
|
|
58
|
+
'color',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function isObject(value: unknown): value is ConfigObject {
|
|
62
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeAbsolutePath(value: string): string {
|
|
66
|
+
return /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) ? value.replaceAll('\\', '/') : value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function expandPattern(pattern: string): string {
|
|
70
|
+
if (pattern === '~' || pattern === '$HOME') return normalizeAbsolutePath(homedir());
|
|
71
|
+
if (pattern.startsWith('~/')) {
|
|
72
|
+
return normalizeAbsolutePath(join(homedir(), pattern.slice(2)));
|
|
73
|
+
}
|
|
74
|
+
if (pattern.startsWith('$HOME/')) {
|
|
75
|
+
return normalizeAbsolutePath(join(homedir(), pattern.slice(6)));
|
|
76
|
+
}
|
|
77
|
+
return normalizeAbsolutePath(pattern);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function permissionEntries(permission: string, value: unknown): PermissionRule[] {
|
|
81
|
+
if (value === 'allow' || value === 'ask' || value === 'deny') {
|
|
82
|
+
return [{ permission, pattern: '*', action: value }];
|
|
83
|
+
}
|
|
84
|
+
if (!isObject(value)) {
|
|
85
|
+
throw new Error(`permission ${permission} must be allow, ask, deny, or a map`);
|
|
86
|
+
}
|
|
87
|
+
return Object.entries(value).map(([pattern, action]) => {
|
|
88
|
+
if (action !== 'allow' && action !== 'ask' && action !== 'deny') {
|
|
89
|
+
throw new Error(`permission ${permission} pattern ${pattern} has an invalid action`);
|
|
90
|
+
}
|
|
91
|
+
return { permission, pattern: expandPattern(pattern), action };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizePermissions(value: unknown): PermissionRules {
|
|
96
|
+
if (value === 'allow' || value === 'ask' || value === 'deny') {
|
|
97
|
+
return [{ permission: '*', pattern: '*', action: value }];
|
|
98
|
+
}
|
|
99
|
+
if (value === undefined) return [];
|
|
100
|
+
if (!isObject(value)) throw new Error('permission must be allow, ask, deny, or a map');
|
|
101
|
+
return Object.entries(value).flatMap(([permission, rules]) =>
|
|
102
|
+
permissionEntries(permission, rules),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function legacyConfigWarnings(piAgentDir: string): string[] {
|
|
107
|
+
const path = join(piAgentDir, 'settings.json');
|
|
108
|
+
if (!existsSync(path)) return [];
|
|
109
|
+
try {
|
|
110
|
+
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
111
|
+
if (!isObject(value)) return [];
|
|
112
|
+
const fields = ['agent', 'permission', 'subagents', 'maxSubagents'].filter(
|
|
113
|
+
(field) => value[field] !== undefined,
|
|
114
|
+
);
|
|
115
|
+
if (fields.length === 0) return [];
|
|
116
|
+
return [
|
|
117
|
+
`${path}: legacy ${fields.join(', ')} configuration is ignored; move it to landstrip.json`,
|
|
118
|
+
];
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeAgent(name: string, raw: ConfigObject): AgentDefinition | undefined {
|
|
125
|
+
for (const field of ['description', 'prompt', 'model', 'variant'] as const) {
|
|
126
|
+
if (raw[field] !== undefined && typeof raw[field] !== 'string') {
|
|
127
|
+
throw new Error(`agent ${name} ${field} must be a string`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const field of ['hidden', 'disable'] as const) {
|
|
131
|
+
if (raw[field] !== undefined && typeof raw[field] !== 'boolean') {
|
|
132
|
+
throw new Error(`agent ${name} ${field} must be a boolean`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const key of Object.keys(raw)) {
|
|
136
|
+
if (!AGENT_FIELDS.has(key)) throw new Error(`agent ${name} has an unknown field ${key}`);
|
|
137
|
+
}
|
|
138
|
+
if (raw.options !== undefined && !isObject(raw.options)) {
|
|
139
|
+
throw new Error(`agent ${name} options must be an object`);
|
|
140
|
+
}
|
|
141
|
+
for (const field of ['temperature', 'top_p'] as const) {
|
|
142
|
+
if (raw[field] !== undefined && typeof raw[field] !== 'number') {
|
|
143
|
+
throw new Error(`agent ${name} ${field} must be a number`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (raw.disable === true) return undefined;
|
|
147
|
+
if (
|
|
148
|
+
raw.mode !== undefined &&
|
|
149
|
+
raw.mode !== 'primary' &&
|
|
150
|
+
raw.mode !== 'subagent' &&
|
|
151
|
+
raw.mode !== 'all'
|
|
152
|
+
) {
|
|
153
|
+
throw new Error(`agent ${name} has an invalid mode`);
|
|
154
|
+
}
|
|
155
|
+
const providerOptions: Record<string, unknown> = isObject(raw.options) ? { ...raw.options } : {};
|
|
156
|
+
if (typeof raw.temperature === 'number') providerOptions.temperature = raw.temperature;
|
|
157
|
+
if (typeof raw.top_p === 'number') providerOptions.top_p = raw.top_p;
|
|
158
|
+
if (raw.steps !== undefined && (!Number.isInteger(raw.steps) || (raw.steps as number) <= 0)) {
|
|
159
|
+
throw new Error(`agent ${name} steps must be a positive number`);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
name,
|
|
163
|
+
description: typeof raw.description === 'string' ? raw.description : undefined,
|
|
164
|
+
prompt: typeof raw.prompt === 'string' ? raw.prompt : '',
|
|
165
|
+
mode: raw.mode ?? 'all',
|
|
166
|
+
model: typeof raw.model === 'string' ? raw.model : undefined,
|
|
167
|
+
variant: typeof raw.variant === 'string' ? raw.variant : undefined,
|
|
168
|
+
hidden: raw.hidden === true,
|
|
169
|
+
steps: typeof raw.steps === 'number' ? raw.steps : undefined,
|
|
170
|
+
permissions: normalizePermissions(raw.permission),
|
|
171
|
+
providerOptions,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function loadAgentCatalog(
|
|
176
|
+
cwd: string,
|
|
177
|
+
piAgentDir = getAgentDir(),
|
|
178
|
+
includeProject = true,
|
|
179
|
+
): AgentCatalog {
|
|
180
|
+
const warnings = [
|
|
181
|
+
...legacyConfigWarnings(piAgentDir),
|
|
182
|
+
...(includeProject ? legacyConfigWarnings(join(cwd, '.pi')) : []),
|
|
183
|
+
];
|
|
184
|
+
const diagnostics: string[] = [];
|
|
185
|
+
let maxSubagents = 0;
|
|
186
|
+
let subagents: ConfigObject = {};
|
|
187
|
+
try {
|
|
188
|
+
const config = loadLandstripConfig(cwd, includeProject, piAgentDir);
|
|
189
|
+
maxSubagents = config.maxSubagents;
|
|
190
|
+
subagents = config.subagents;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
diagnostics.push(error instanceof Error ? error.message : String(error));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const normalized = new Map<string, AgentDefinition>();
|
|
196
|
+
if (subagents.agent !== undefined && !isObject(subagents.agent)) {
|
|
197
|
+
diagnostics.push('subagents.agent must be an object');
|
|
198
|
+
} else if (isObject(subagents.agent)) {
|
|
199
|
+
for (const name of Object.keys(subagents.agent).sort()) {
|
|
200
|
+
const value = subagents.agent[name];
|
|
201
|
+
if (!isObject(value)) {
|
|
202
|
+
diagnostics.push(`agent ${name} must be an object`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const agent = normalizeAgent(name, value);
|
|
207
|
+
if (agent) normalized.set(name, agent);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
diagnostics.push(error instanceof Error ? error.message : String(error));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let permissions: PermissionRules = [];
|
|
215
|
+
for (const key of Object.keys(subagents)) {
|
|
216
|
+
if (key !== 'agent' && key !== 'permission') {
|
|
217
|
+
diagnostics.push(`subagents has an unknown field ${key}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
permissions = normalizePermissions(subagents.permission);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
diagnostics.push(error instanceof Error ? error.message : String(error));
|
|
224
|
+
}
|
|
225
|
+
return { agents: normalized, permissions, diagnostics, warnings, maxSubagents };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function permissionDecision(
|
|
229
|
+
rules: PermissionRules,
|
|
230
|
+
permission: string,
|
|
231
|
+
resource = '*',
|
|
232
|
+
): PermissionAction {
|
|
233
|
+
let decision: PermissionAction = 'ask';
|
|
234
|
+
for (const rule of rules) {
|
|
235
|
+
const matchesPermission =
|
|
236
|
+
rule.permission === '*' || minimatch(permission, rule.permission, { dot: true });
|
|
237
|
+
const matchesResource =
|
|
238
|
+
rule.pattern === '*' ||
|
|
239
|
+
minimatch(normalizeAbsolutePath(resource), normalizeAbsolutePath(rule.pattern), {
|
|
240
|
+
dot: true,
|
|
241
|
+
matchBase: false,
|
|
242
|
+
});
|
|
243
|
+
if (matchesPermission && matchesResource) decision = rule.action;
|
|
244
|
+
}
|
|
245
|
+
return decision;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function mergePermissionRules(...values: PermissionRules[]): PermissionRules {
|
|
249
|
+
return values.flatMap((value) => value);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function availableSubagents(catalog: AgentCatalog): AgentDefinition[] {
|
|
253
|
+
return [...catalog.agents.values()].filter((agent) => agent.mode !== 'primary');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function availablePrimaryAgents(catalog: AgentCatalog): AgentDefinition[] {
|
|
257
|
+
return [...catalog.agents.values()].filter((agent) => agent.mode !== 'subagent' && !agent.hidden);
|
|
258
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { getAgentDir, withFileMutationQueue } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
|
|
10
|
+
export type ConfigObject = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
export const MAX_SUBAGENTS = 16;
|
|
13
|
+
|
|
14
|
+
export interface LandstripConfigFile {
|
|
15
|
+
maxSubagents?: number;
|
|
16
|
+
subagents?: ConfigObject;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LandstripConfig extends LandstripConfigFile {
|
|
20
|
+
maxSubagents: number;
|
|
21
|
+
subagents: ConfigObject;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const packageDir = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
|
|
26
|
+
function isObject(value: unknown): value is ConfigObject {
|
|
27
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readConfig(path: string): LandstripConfigFile {
|
|
31
|
+
if (!existsSync(path)) return {};
|
|
32
|
+
let value: unknown;
|
|
33
|
+
try {
|
|
34
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
throw new Error(`${path}: ${message}`);
|
|
38
|
+
}
|
|
39
|
+
if (!isObject(value)) throw new Error(`${path} must contain a JSON object`);
|
|
40
|
+
for (const key of Object.keys(value)) {
|
|
41
|
+
if (key !== 'maxSubagents' && key !== 'subagents') {
|
|
42
|
+
throw new Error(`${path}: unknown top-level field ${key}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mergeValue(base: unknown, override: unknown): unknown {
|
|
49
|
+
if (override === undefined) return base;
|
|
50
|
+
if (Array.isArray(override)) return [...override];
|
|
51
|
+
if (isObject(base) && isObject(override)) {
|
|
52
|
+
const result: ConfigObject = { ...base };
|
|
53
|
+
for (const [key, value] of Object.entries(override)) {
|
|
54
|
+
const merged = mergeValue(result[key], value);
|
|
55
|
+
delete result[key];
|
|
56
|
+
result[key] = merged;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
return override;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getLandstripConfigPaths(
|
|
64
|
+
cwd: string,
|
|
65
|
+
agentDir = getAgentDir(),
|
|
66
|
+
): { globalPath: string; projectPath: string } {
|
|
67
|
+
return {
|
|
68
|
+
globalPath: join(agentDir, 'landstrip.json'),
|
|
69
|
+
projectPath: join(cwd, '.pi', 'landstrip.json'),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function setMaxSubagentsConfig(
|
|
74
|
+
cwd: string,
|
|
75
|
+
maxSubagents: number,
|
|
76
|
+
includeProject = true,
|
|
77
|
+
agentDir = getAgentDir(),
|
|
78
|
+
): Promise<'global' | 'project'> {
|
|
79
|
+
if (!Number.isInteger(maxSubagents) || maxSubagents < 0 || maxSubagents > MAX_SUBAGENTS) {
|
|
80
|
+
throw new Error(`maxSubagents must be an integer from 0 to ${MAX_SUBAGENTS}`);
|
|
81
|
+
}
|
|
82
|
+
const { globalPath, projectPath } = getLandstripConfigPaths(cwd, agentDir);
|
|
83
|
+
const target =
|
|
84
|
+
includeProject && existsSync(projectPath)
|
|
85
|
+
? { scope: 'project' as const, path: projectPath }
|
|
86
|
+
: { scope: 'global' as const, path: globalPath };
|
|
87
|
+
await withFileMutationQueue(target.path, async () => {
|
|
88
|
+
const config = readConfig(target.path);
|
|
89
|
+
config.maxSubagents = maxSubagents;
|
|
90
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
91
|
+
writeFileSync(target.path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
92
|
+
});
|
|
93
|
+
return target.scope;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function loadLandstripConfig(
|
|
97
|
+
cwd: string,
|
|
98
|
+
includeProject = true,
|
|
99
|
+
agentDir = getAgentDir(),
|
|
100
|
+
): LandstripConfig {
|
|
101
|
+
const { globalPath, projectPath } = getLandstripConfigPaths(cwd, agentDir);
|
|
102
|
+
let config = readConfig(join(packageDir, 'landstrip.json'));
|
|
103
|
+
config = mergeValue(config, readConfig(globalPath)) as LandstripConfigFile;
|
|
104
|
+
if (includeProject) {
|
|
105
|
+
config = mergeValue(config, readConfig(projectPath)) as LandstripConfigFile;
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
!Number.isInteger(config.maxSubagents) ||
|
|
109
|
+
(config.maxSubagents ?? -1) < 0 ||
|
|
110
|
+
(config.maxSubagents ?? MAX_SUBAGENTS + 1) > MAX_SUBAGENTS
|
|
111
|
+
) {
|
|
112
|
+
throw new Error(`maxSubagents must be an integer from 0 to ${MAX_SUBAGENTS}`);
|
|
113
|
+
}
|
|
114
|
+
if (!isObject(config.subagents)) throw new Error('subagents must be an object');
|
|
115
|
+
return config as LandstripConfig;
|
|
116
|
+
}
|