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 CHANGED
@@ -1,11 +1,15 @@
1
1
  # pi-landstrip
2
2
 
3
- `pi-landstrip` is an extension for [pi](https://pi.dev/) providing a sandbox
4
- defined with a policy compatible with Anthropic's JSON format. It uses
5
- [`landstrip`](https://github.com/landstrip/landstrip) to implement the sandbox.
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 default policy [sandbox.json](./sandbox.json), and allows
8
- the define either or both global or project specific policies.
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, which
19
- includes platform-specific native binaries for Linux, macOS, and Windows.
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 the sandbox
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
- Project config overrides global config. The `/sandbox` UI updates the project
50
- config when present, otherwise the global config.
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
- When pi runs a bash command, the extension wraps it in `landstrip`. This
63
- applies to both the AI `bash` tool calls and manually typed shell-mode commands
64
- (`!` and `!!`). Network traffic is routed through an allowlist proxy when
65
- network access is off, and read/write tool access is blocked outside the
66
- configured filesystem allowlists. The default policy is strict: network access
67
- is off unless domains are allowed, reads are limited to the project,
68
- `~/.gitconfig`, and `/dev/null`, and writes are limited to the project and
69
- `/dev/null`.
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
- Use `/sandbox` inside pi to show the active config and toggle sandboxing.
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
+ }