pi-landstrip 0.17.21 → 0.17.23
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 +63 -0
- package/api.ts +198 -0
- package/index.ts +197 -28
- package/package.json +8 -2
- package/subagents.ts +41 -0
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# pi-landstrip
|
|
2
2
|
|
|
3
|
+
```
|
|
4
|
+
Subagents 3 active
|
|
5
|
+
├─ ● running @explore Design Pi entry parsing
|
|
6
|
+
├─ ● running @general Design trigger serialization
|
|
7
|
+
└─ ● running @general Design bounded summaries
|
|
8
|
+
```
|
|
9
|
+
|
|
3
10
|
`pi-landstrip` is a [Pi](https://pi.dev/) extension that provides sandboxed Bash
|
|
4
11
|
execution, OpenCode-compatible primary agents, and sandbox-aware subagents. It
|
|
5
12
|
uses an Anthropic-compatible policy and delegates OS-level enforcement to
|
|
@@ -239,6 +246,62 @@ follows, then remove the old fields:
|
|
|
239
246
|
Do not put these fields in Pi's `settings.json`; `pi-landstrip` does not read
|
|
240
247
|
them there.
|
|
241
248
|
|
|
249
|
+
## Plugin API
|
|
250
|
+
|
|
251
|
+
Pi extensions can discover the active Landstrip runtime through the versioned
|
|
252
|
+
API exported by `pi-landstrip/api`:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
256
|
+
import { useLandstrip } from 'pi-landstrip/api';
|
|
257
|
+
|
|
258
|
+
export default function (pi: ExtensionAPI) {
|
|
259
|
+
const dispose = useLandstrip(pi, (landstrip) => {
|
|
260
|
+
const context = landstrip.getContext();
|
|
261
|
+
// Register Landstrip-aware behavior here.
|
|
262
|
+
});
|
|
263
|
+
pi.on('session_shutdown', dispose);
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Discovery is callback-based so it works regardless of extension load order.
|
|
268
|
+
Do not wait for discovery from an extension factory: Pi initializes extension
|
|
269
|
+
factories sequentially. The returned function stops future notifications.
|
|
270
|
+
|
|
271
|
+
The runtime exposes:
|
|
272
|
+
|
|
273
|
+
- `getContext()` for the host, role, sandbox state, task identity, agent and
|
|
274
|
+
nesting depth.
|
|
275
|
+
- `createBashTool()` for extensions that need Landstrip execution with their
|
|
276
|
+
own tool presentation.
|
|
277
|
+
- `prepareProcess()` for single-use process launches under the effective
|
|
278
|
+
sandbox policy.
|
|
279
|
+
- `registerWorkerExtension()` to load an absolute extension entry in Landstrip
|
|
280
|
+
subagents. The returned function removes the registration.
|
|
281
|
+
- `on()` for typed `sandbox.changed`, `subagent.start` and `subagent.end`
|
|
282
|
+
lifecycle events.
|
|
283
|
+
|
|
284
|
+
Registered worker extensions are trusted code. Landstrip adds their canonical
|
|
285
|
+
directories and dependency roots to worker read access, loads them with Pi's
|
|
286
|
+
`--extension` option, and propagates them to nested tasks.
|
|
287
|
+
|
|
288
|
+
Workers receive a base64url JSON `LANDSTRIP_CONTEXT` environment variable.
|
|
289
|
+
Extensions can read it without runtime discovery:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import { contextFromEnvironment } from 'pi-landstrip/api';
|
|
293
|
+
|
|
294
|
+
const context = contextFromEnvironment();
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
This public context is informational and contains no permission rules, policy,
|
|
298
|
+
credentials or proxy secrets. Environment values can be spoofed outside a
|
|
299
|
+
Landstrip worker and must not be treated as authorization.
|
|
300
|
+
|
|
301
|
+
Generic process preparation fails closed on Windows when sandboxing is disabled.
|
|
302
|
+
Windows cannot reliably reclaim an unsandboxed descendant tree after its leader
|
|
303
|
+
has exited; enabled Landstrip launches use an AppContainer job object instead.
|
|
304
|
+
|
|
242
305
|
## Limits
|
|
243
306
|
|
|
244
307
|
`pi-landstrip` grants at most `maxSubagents` scheduler permits to active
|
package/api.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
|
+
|
|
4
|
+
import type { ChildProcess, SpawnOptions } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createBashToolDefinition,
|
|
8
|
+
type ExtensionAPI,
|
|
9
|
+
type ExtensionContext,
|
|
10
|
+
} from '@earendil-works/pi-coding-agent';
|
|
11
|
+
|
|
12
|
+
import type { RpcChildProcess } from './rpc-process.ts';
|
|
13
|
+
|
|
14
|
+
export const LANDSTRIP_CONTEXT_ENV = 'LANDSTRIP_CONTEXT';
|
|
15
|
+
export const LANDSTRIP_RUNTIME_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
const RUNTIME_REGISTER_EVENT = 'landstrip:runtime:register:v1';
|
|
18
|
+
const RUNTIME_DISCOVER_EVENT = 'landstrip:runtime:discover:v1';
|
|
19
|
+
|
|
20
|
+
export type LandstripSandboxState = 'enabled' | 'disabled' | 'unavailable';
|
|
21
|
+
export type LandstripBashTool = ReturnType<typeof createBashToolDefinition>;
|
|
22
|
+
|
|
23
|
+
export interface LandstripContextV1 {
|
|
24
|
+
readonly version: 1;
|
|
25
|
+
readonly host: 'pi';
|
|
26
|
+
readonly role: 'primary' | 'subagent';
|
|
27
|
+
readonly sandbox: LandstripSandboxState;
|
|
28
|
+
readonly cwd: string;
|
|
29
|
+
readonly sessionId?: string;
|
|
30
|
+
readonly taskId?: string;
|
|
31
|
+
readonly parentTaskId?: string;
|
|
32
|
+
readonly agent?: string;
|
|
33
|
+
readonly depth: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LandstripWorkerExtension {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly entry: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LandstripProcessOptions {
|
|
42
|
+
readonly command: string;
|
|
43
|
+
readonly args: readonly string[];
|
|
44
|
+
readonly cwd: string;
|
|
45
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
46
|
+
readonly ctx: ExtensionContext;
|
|
47
|
+
readonly readPaths?: readonly string[];
|
|
48
|
+
readonly writePaths?: readonly string[];
|
|
49
|
+
readonly signal?: AbortSignal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LandstripPreparedProcess {
|
|
53
|
+
readonly command: string;
|
|
54
|
+
readonly args: readonly string[];
|
|
55
|
+
readonly cwd: string;
|
|
56
|
+
readonly env: NodeJS.ProcessEnv;
|
|
57
|
+
readonly spawn: LandstripSpawn;
|
|
58
|
+
dispose(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type LandstripSpawn = (
|
|
62
|
+
command: string,
|
|
63
|
+
args: readonly string[],
|
|
64
|
+
options: SpawnOptions,
|
|
65
|
+
) => ChildProcess | RpcChildProcess;
|
|
66
|
+
|
|
67
|
+
export type LandstripEvent =
|
|
68
|
+
| { readonly type: 'sandbox.changed'; readonly context: LandstripContextV1 }
|
|
69
|
+
| { readonly type: 'subagent.start'; readonly context: LandstripContextV1 }
|
|
70
|
+
| {
|
|
71
|
+
readonly type: 'subagent.end';
|
|
72
|
+
readonly context: LandstripContextV1;
|
|
73
|
+
readonly status: 'completed' | 'cancelled' | 'error';
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface PiLandstripRuntimeV1 {
|
|
77
|
+
readonly version: 1;
|
|
78
|
+
getContext(ctx?: ExtensionContext): LandstripContextV1;
|
|
79
|
+
createBashTool(cwd: string, ctx?: ExtensionContext): LandstripBashTool;
|
|
80
|
+
prepareProcess(options: LandstripProcessOptions): Promise<LandstripPreparedProcess>;
|
|
81
|
+
registerWorkerExtension(extension: LandstripWorkerExtension): () => void;
|
|
82
|
+
getWorkerExtensions(): readonly LandstripWorkerExtension[];
|
|
83
|
+
on<T extends LandstripEvent['type']>(
|
|
84
|
+
type: T,
|
|
85
|
+
handler: (event: Extract<LandstripEvent, { type: T }>) => void,
|
|
86
|
+
): () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface RuntimeRegistration {
|
|
90
|
+
readonly version: 1;
|
|
91
|
+
readonly runtime: PiLandstripRuntimeV1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface RuntimeDiscovery {
|
|
95
|
+
readonly version: 1;
|
|
96
|
+
register(runtime: PiLandstripRuntimeV1): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface EventBusLike {
|
|
100
|
+
emit(channel: string, data: unknown): void;
|
|
101
|
+
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function eventBus(pi: ExtensionAPI): EventBusLike | undefined {
|
|
105
|
+
return (pi as ExtensionAPI & { events?: EventBusLike }).events;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
109
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isRuntime(value: unknown): value is PiLandstripRuntimeV1 {
|
|
113
|
+
return (
|
|
114
|
+
isRecord(value) &&
|
|
115
|
+
value.version === LANDSTRIP_RUNTIME_VERSION &&
|
|
116
|
+
typeof value.getContext === 'function' &&
|
|
117
|
+
typeof value.createBashTool === 'function' &&
|
|
118
|
+
typeof value.prepareProcess === 'function' &&
|
|
119
|
+
typeof value.registerWorkerExtension === 'function' &&
|
|
120
|
+
typeof value.getWorkerExtensions === 'function' &&
|
|
121
|
+
typeof value.on === 'function'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function useLandstrip(
|
|
126
|
+
pi: ExtensionAPI,
|
|
127
|
+
callback: (runtime: PiLandstripRuntimeV1) => void,
|
|
128
|
+
): () => void {
|
|
129
|
+
const events = eventBus(pi);
|
|
130
|
+
if (!events) return () => undefined;
|
|
131
|
+
|
|
132
|
+
let current: PiLandstripRuntimeV1 | undefined;
|
|
133
|
+
const register = (runtime: PiLandstripRuntimeV1): void => {
|
|
134
|
+
if (runtime === current) return;
|
|
135
|
+
current = runtime;
|
|
136
|
+
callback(runtime);
|
|
137
|
+
};
|
|
138
|
+
const unsubscribe = events.on(RUNTIME_REGISTER_EVENT, (value) => {
|
|
139
|
+
if (!isRecord(value) || value.version !== LANDSTRIP_RUNTIME_VERSION) return;
|
|
140
|
+
if (isRuntime(value.runtime)) register(value.runtime);
|
|
141
|
+
});
|
|
142
|
+
events.emit(RUNTIME_DISCOVER_EVENT, {
|
|
143
|
+
version: LANDSTRIP_RUNTIME_VERSION,
|
|
144
|
+
register,
|
|
145
|
+
} satisfies RuntimeDiscovery);
|
|
146
|
+
return unsubscribe;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function publishLandstripRuntime(
|
|
150
|
+
pi: ExtensionAPI,
|
|
151
|
+
runtime: PiLandstripRuntimeV1,
|
|
152
|
+
): () => void {
|
|
153
|
+
const events = eventBus(pi);
|
|
154
|
+
if (!events) return () => undefined;
|
|
155
|
+
|
|
156
|
+
const unsubscribe = events.on(RUNTIME_DISCOVER_EVENT, (value) => {
|
|
157
|
+
if (!isRecord(value) || value.version !== LANDSTRIP_RUNTIME_VERSION) return;
|
|
158
|
+
if (typeof value.register === 'function') value.register(runtime);
|
|
159
|
+
});
|
|
160
|
+
events.emit(RUNTIME_REGISTER_EVENT, {
|
|
161
|
+
version: LANDSTRIP_RUNTIME_VERSION,
|
|
162
|
+
runtime,
|
|
163
|
+
} satisfies RuntimeRegistration);
|
|
164
|
+
return unsubscribe;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function encodeLandstripContext(context: LandstripContextV1): string {
|
|
168
|
+
return Buffer.from(JSON.stringify(context)).toString('base64url');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function contextFromEnvironment(
|
|
172
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
173
|
+
): LandstripContextV1 | undefined {
|
|
174
|
+
const encoded = env[LANDSTRIP_CONTEXT_ENV];
|
|
175
|
+
if (!encoded) return undefined;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const value: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
179
|
+
if (
|
|
180
|
+
!isRecord(value) ||
|
|
181
|
+
value.version !== LANDSTRIP_RUNTIME_VERSION ||
|
|
182
|
+
value.host !== 'pi' ||
|
|
183
|
+
value.role !== 'subagent' ||
|
|
184
|
+
!['enabled', 'disabled', 'unavailable'].includes(String(value.sandbox)) ||
|
|
185
|
+
typeof value.cwd !== 'string' ||
|
|
186
|
+
!Number.isInteger(value.depth) ||
|
|
187
|
+
Number(value.depth) < 0 ||
|
|
188
|
+
['sessionId', 'taskId', 'parentTaskId', 'agent'].some(
|
|
189
|
+
(key) => value[key] !== undefined && typeof value[key] !== 'string',
|
|
190
|
+
)
|
|
191
|
+
) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
return value as unknown as LandstripContextV1;
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/index.ts
CHANGED
|
@@ -53,6 +53,18 @@ import {
|
|
|
53
53
|
type LandstripNetworkTrap,
|
|
54
54
|
type LandstripTrap,
|
|
55
55
|
} from '@landstrip/landstrip';
|
|
56
|
+
import {
|
|
57
|
+
type LandstripBashTool,
|
|
58
|
+
type LandstripContextV1,
|
|
59
|
+
type LandstripEvent,
|
|
60
|
+
type LandstripPreparedProcess,
|
|
61
|
+
type LandstripProcessOptions,
|
|
62
|
+
type LandstripSandboxState,
|
|
63
|
+
type LandstripWorkerExtension,
|
|
64
|
+
LANDSTRIP_RUNTIME_VERSION,
|
|
65
|
+
type PiLandstripRuntimeV1,
|
|
66
|
+
publishLandstripRuntime,
|
|
67
|
+
} from './api.ts';
|
|
56
68
|
import type { RpcSpawn } from './rpc-process.ts';
|
|
57
69
|
import {
|
|
58
70
|
type SubagentRuntime,
|
|
@@ -766,6 +778,24 @@ function patchProcessGroupKill(child: ChildProcess): void {
|
|
|
766
778
|
};
|
|
767
779
|
}
|
|
768
780
|
|
|
781
|
+
async function stopPreparedChild(child: ChildProcess | undefined): Promise<void> {
|
|
782
|
+
if (!child) return;
|
|
783
|
+
|
|
784
|
+
child.kill('SIGKILL');
|
|
785
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
786
|
+
|
|
787
|
+
await new Promise<void>((resolve) => {
|
|
788
|
+
let settleTimer: NodeJS.Timeout | undefined;
|
|
789
|
+
const done = (): void => {
|
|
790
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
791
|
+
child.removeListener('exit', done);
|
|
792
|
+
resolve();
|
|
793
|
+
};
|
|
794
|
+
child.once('exit', done);
|
|
795
|
+
settleTimer = setTimeout(done, 1_000);
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
769
799
|
const permissionPromptQueue = new AsyncQueue();
|
|
770
800
|
|
|
771
801
|
async function showPermissionPrompt(
|
|
@@ -976,8 +1006,6 @@ function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): vo
|
|
|
976
1006
|
upstream.pipe(client);
|
|
977
1007
|
}
|
|
978
1008
|
|
|
979
|
-
type LandstripBashTool = ReturnType<typeof createBashToolDefinition>;
|
|
980
|
-
|
|
981
1009
|
/** Options for creating a landstrip sandbox integration. */
|
|
982
1010
|
export interface LandstripIntegrationOptions {
|
|
983
1011
|
/** Register a sandboxed bash tool when the integration is registered. */
|
|
@@ -987,35 +1015,36 @@ export interface LandstripIntegrationOptions {
|
|
|
987
1015
|
}
|
|
988
1016
|
|
|
989
1017
|
/** Landstrip sandbox integration hooks for Pi. */
|
|
990
|
-
export interface LandstripIntegration {
|
|
991
|
-
/** Create a bash tool definition that runs commands through landstrip when enabled. */
|
|
992
|
-
createBashTool(cwd: string, ctx?: ExtensionContext): LandstripBashTool;
|
|
1018
|
+
export interface LandstripIntegration extends PiLandstripRuntimeV1 {
|
|
993
1019
|
/** Prepare a full Pi RPC process constrained by the effective sandbox policy. */
|
|
994
1020
|
prepareRpcWorker(options: LandstripRpcWorkerOptions): Promise<LandstripRpcWorkerLaunch>;
|
|
995
1021
|
/** Register the integration's tools, events, flags, and commands with Pi. */
|
|
996
1022
|
register(pi: ExtensionAPI, runtime?: SubagentRuntime): void;
|
|
1023
|
+
/** Publish an integration lifecycle event from an embedded runtime. */
|
|
1024
|
+
emit(event: LandstripEvent): void;
|
|
997
1025
|
}
|
|
998
1026
|
|
|
999
|
-
export interface LandstripRpcWorkerOptions {
|
|
1000
|
-
readonly command: string;
|
|
1001
|
-
readonly args: readonly string[];
|
|
1002
|
-
readonly cwd: string;
|
|
1027
|
+
export interface LandstripRpcWorkerOptions extends LandstripProcessOptions {
|
|
1003
1028
|
readonly env: NodeJS.ProcessEnv;
|
|
1004
|
-
readonly ctx: ExtensionContext;
|
|
1005
1029
|
readonly readPaths: readonly string[];
|
|
1006
1030
|
readonly writePaths: readonly string[];
|
|
1007
|
-
readonly signal?: AbortSignal;
|
|
1008
1031
|
}
|
|
1009
1032
|
|
|
1010
|
-
export interface LandstripRpcWorkerLaunch {
|
|
1011
|
-
readonly command: string;
|
|
1012
|
-
readonly args: readonly string[];
|
|
1013
|
-
readonly cwd: string;
|
|
1014
|
-
readonly env: NodeJS.ProcessEnv;
|
|
1033
|
+
export interface LandstripRpcWorkerLaunch extends LandstripPreparedProcess {
|
|
1015
1034
|
readonly spawn: RpcSpawn;
|
|
1016
|
-
dispose(): Promise<void>;
|
|
1017
1035
|
}
|
|
1018
1036
|
|
|
1037
|
+
export type {
|
|
1038
|
+
LandstripBashTool,
|
|
1039
|
+
LandstripContextV1,
|
|
1040
|
+
LandstripEvent,
|
|
1041
|
+
LandstripPreparedProcess,
|
|
1042
|
+
LandstripProcessOptions,
|
|
1043
|
+
LandstripSandboxState,
|
|
1044
|
+
LandstripWorkerExtension,
|
|
1045
|
+
PiLandstripRuntimeV1,
|
|
1046
|
+
} from './api.ts';
|
|
1047
|
+
|
|
1019
1048
|
/** Register the landstrip extension with Pi. */
|
|
1020
1049
|
export default function (pi: ExtensionAPI) {
|
|
1021
1050
|
const workerConfig = workerConfigFromEnvironment();
|
|
@@ -1048,12 +1077,92 @@ export function createLandstripIntegration(
|
|
|
1048
1077
|
|
|
1049
1078
|
let sandboxEnabled = false;
|
|
1050
1079
|
let sandboxReady = false;
|
|
1080
|
+
let sandboxState: LandstripSandboxState = 'unavailable';
|
|
1051
1081
|
let unsandboxedWorkerWarningShown = false;
|
|
1082
|
+
let activeContext: ExtensionContext | undefined;
|
|
1083
|
+
let unpublishRuntime: (() => void) | undefined;
|
|
1084
|
+
const eventHandlers = new Set<(event: LandstripEvent) => void>();
|
|
1085
|
+
const workerExtensions = new Map<string, { entry: string; registrations: number }>();
|
|
1052
1086
|
const sessionAllowedDomains: string[] = [];
|
|
1053
1087
|
const sessionAllowedReadPaths: string[] = [];
|
|
1054
1088
|
const sessionAllowedWritePaths: string[] = [];
|
|
1055
1089
|
const sessionAllowedTargets: string[] = [];
|
|
1056
1090
|
|
|
1091
|
+
function getContext(ctx = activeContext): LandstripContextV1 {
|
|
1092
|
+
return {
|
|
1093
|
+
version: LANDSTRIP_RUNTIME_VERSION,
|
|
1094
|
+
host: 'pi',
|
|
1095
|
+
role: 'primary',
|
|
1096
|
+
sandbox: sandboxState,
|
|
1097
|
+
cwd: ctx?.cwd ?? localCwd,
|
|
1098
|
+
sessionId: ctx?.sessionManager?.getSessionId(),
|
|
1099
|
+
depth: 0,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function emitEvent(event: LandstripEvent): void {
|
|
1104
|
+
for (const handler of eventHandlers) {
|
|
1105
|
+
try {
|
|
1106
|
+
handler(event);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
console.error(`pi-landstrip: lifecycle listener failed: ${formatError(error)}`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function setSandboxState(state: LandstripSandboxState, ctx: ExtensionContext): void {
|
|
1114
|
+
if (sandboxState === state) return;
|
|
1115
|
+
sandboxState = state;
|
|
1116
|
+
emitEvent({ type: 'sandbox.changed', context: getContext(ctx) });
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function on<T extends LandstripEvent['type']>(
|
|
1120
|
+
type: T,
|
|
1121
|
+
handler: (event: Extract<LandstripEvent, { type: T }>) => void,
|
|
1122
|
+
): () => void {
|
|
1123
|
+
const listener = (event: LandstripEvent): void => {
|
|
1124
|
+
if (event.type === type) handler(event as Extract<LandstripEvent, { type: T }>);
|
|
1125
|
+
};
|
|
1126
|
+
eventHandlers.add(listener);
|
|
1127
|
+
return () => eventHandlers.delete(listener);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function registerWorkerExtension(extension: LandstripWorkerExtension): () => void {
|
|
1131
|
+
const id = extension.id.trim();
|
|
1132
|
+
if (!id) throw new Error('Worker extension id must not be empty');
|
|
1133
|
+
const requestedEntry = extension.entry.startsWith('file:')
|
|
1134
|
+
? fileURLToPath(extension.entry)
|
|
1135
|
+
: extension.entry;
|
|
1136
|
+
if (!isAbsolute(requestedEntry)) {
|
|
1137
|
+
throw new Error('Worker extension entry must be an absolute path or file URL');
|
|
1138
|
+
}
|
|
1139
|
+
if (!existsSync(requestedEntry)) {
|
|
1140
|
+
throw new Error(`Worker extension entry does not exist: ${requestedEntry}`);
|
|
1141
|
+
}
|
|
1142
|
+
const entry = realpathSync(requestedEntry);
|
|
1143
|
+
|
|
1144
|
+
const existing = workerExtensions.get(id);
|
|
1145
|
+
if (existing && existing.entry !== entry) {
|
|
1146
|
+
throw new Error(`Worker extension id is already registered with another entry: ${id}`);
|
|
1147
|
+
}
|
|
1148
|
+
if (existing) existing.registrations += 1;
|
|
1149
|
+
else workerExtensions.set(id, { entry, registrations: 1 });
|
|
1150
|
+
|
|
1151
|
+
let disposed = false;
|
|
1152
|
+
return () => {
|
|
1153
|
+
if (disposed) return;
|
|
1154
|
+
disposed = true;
|
|
1155
|
+
const registration = workerExtensions.get(id);
|
|
1156
|
+
if (!registration || registration.entry !== entry) return;
|
|
1157
|
+
registration.registrations -= 1;
|
|
1158
|
+
if (registration.registrations === 0) workerExtensions.delete(id);
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function getWorkerExtensions(): readonly LandstripWorkerExtension[] {
|
|
1163
|
+
return [...workerExtensions].map(([id, extension]) => ({ id, entry: extension.entry }));
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1057
1166
|
function resetSessionAllowances(): void {
|
|
1058
1167
|
sessionAllowedDomains.length = 0;
|
|
1059
1168
|
sessionAllowedReadPaths.length = 0;
|
|
@@ -1546,8 +1655,12 @@ export function createLandstripIntegration(
|
|
|
1546
1655
|
options: LandstripRpcWorkerOptions,
|
|
1547
1656
|
): LandstripRpcWorkerLaunch {
|
|
1548
1657
|
let spawned = false;
|
|
1658
|
+
let disposed = false;
|
|
1659
|
+
let disposePromise: Promise<void> | undefined;
|
|
1660
|
+
let preparedChild: ChildProcess | undefined;
|
|
1549
1661
|
const spawnWorker: RpcSpawn = (command, args, spawnOptions) => {
|
|
1550
|
-
if (
|
|
1662
|
+
if (disposed) throw new Error('Prepared process has been disposed');
|
|
1663
|
+
if (spawned) throw new Error('Prepared process can only be spawned once');
|
|
1551
1664
|
spawned = true;
|
|
1552
1665
|
const child = spawn(command, [...args], {
|
|
1553
1666
|
...spawnOptions,
|
|
@@ -1556,6 +1669,7 @@ export function createLandstripIntegration(
|
|
|
1556
1669
|
detached: true,
|
|
1557
1670
|
});
|
|
1558
1671
|
patchProcessGroupKill(child);
|
|
1672
|
+
preparedChild = child;
|
|
1559
1673
|
return child as ReturnType<RpcSpawn>;
|
|
1560
1674
|
};
|
|
1561
1675
|
|
|
@@ -1565,7 +1679,13 @@ export function createLandstripIntegration(
|
|
|
1565
1679
|
cwd: options.cwd,
|
|
1566
1680
|
env: options.env,
|
|
1567
1681
|
spawn: spawnWorker,
|
|
1568
|
-
|
|
1682
|
+
dispose() {
|
|
1683
|
+
disposePromise ??= (async () => {
|
|
1684
|
+
disposed = true;
|
|
1685
|
+
await stopPreparedChild(preparedChild);
|
|
1686
|
+
})();
|
|
1687
|
+
return disposePromise;
|
|
1688
|
+
},
|
|
1569
1689
|
};
|
|
1570
1690
|
}
|
|
1571
1691
|
|
|
@@ -1630,8 +1750,11 @@ export function createLandstripIntegration(
|
|
|
1630
1750
|
workerEnv.no_proxy = '';
|
|
1631
1751
|
}
|
|
1632
1752
|
let spawned = false;
|
|
1753
|
+
let disposePromise: Promise<void> | undefined;
|
|
1754
|
+
let preparedChild: ChildProcess | undefined;
|
|
1633
1755
|
const spawnWorker: RpcSpawn = (_command, _args, spawnOptions) => {
|
|
1634
|
-
if (
|
|
1756
|
+
if (disposed) throw new Error('Prepared process has been disposed');
|
|
1757
|
+
if (spawned) throw new Error('Prepared process can only be spawned once');
|
|
1635
1758
|
spawned = true;
|
|
1636
1759
|
const child = spawn(
|
|
1637
1760
|
binaryPath(),
|
|
@@ -1646,6 +1769,7 @@ export function createLandstripIntegration(
|
|
|
1646
1769
|
);
|
|
1647
1770
|
workerChildEnd.destroy();
|
|
1648
1771
|
patchProcessGroupKill(child);
|
|
1772
|
+
preparedChild = child;
|
|
1649
1773
|
return child as ReturnType<RpcSpawn>;
|
|
1650
1774
|
};
|
|
1651
1775
|
|
|
@@ -1655,17 +1779,34 @@ export function createLandstripIntegration(
|
|
|
1655
1779
|
cwd: options.cwd,
|
|
1656
1780
|
env: workerEnv,
|
|
1657
1781
|
spawn: spawnWorker,
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1782
|
+
dispose() {
|
|
1783
|
+
disposePromise ??= (async () => {
|
|
1784
|
+
disposed = true;
|
|
1785
|
+
await stopPreparedChild(preparedChild);
|
|
1786
|
+
trapSocket.destroy();
|
|
1787
|
+
workerChildEnd.destroy();
|
|
1788
|
+
rmSync(workerPolicy.dir, { recursive: true, force: true });
|
|
1789
|
+
await proxy?.stop();
|
|
1790
|
+
})();
|
|
1791
|
+
return disposePromise;
|
|
1665
1792
|
},
|
|
1666
1793
|
};
|
|
1667
1794
|
}
|
|
1668
1795
|
|
|
1796
|
+
async function prepareProcess(
|
|
1797
|
+
options: LandstripProcessOptions,
|
|
1798
|
+
): Promise<LandstripPreparedProcess> {
|
|
1799
|
+
if (process.platform === 'win32' && (noSandboxFlag || !loadConfig(options.cwd).enabled)) {
|
|
1800
|
+
throw new Error('Generic process preparation requires sandboxing on Windows');
|
|
1801
|
+
}
|
|
1802
|
+
return prepareRpcWorker({
|
|
1803
|
+
...options,
|
|
1804
|
+
env: options.env ?? process.env,
|
|
1805
|
+
readPaths: options.readPaths ?? [],
|
|
1806
|
+
writePaths: options.writePaths ?? [],
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1669
1810
|
function createLandstripBashOps(
|
|
1670
1811
|
ctx: ExtensionContext,
|
|
1671
1812
|
callbacks: LandstripBashCallbacks = {},
|
|
@@ -2135,6 +2276,7 @@ export function createLandstripIntegration(
|
|
|
2135
2276
|
if (!SUPPORTED_PLATFORMS.has(process.platform)) {
|
|
2136
2277
|
sandboxEnabled = false;
|
|
2137
2278
|
sandboxReady = false;
|
|
2279
|
+
setSandboxState('unavailable', ctx);
|
|
2138
2280
|
warnUnsandboxed(
|
|
2139
2281
|
ctx,
|
|
2140
2282
|
`landstrip sandboxing is not supported on ${process.platform}`,
|
|
@@ -2146,6 +2288,7 @@ export function createLandstripIntegration(
|
|
|
2146
2288
|
if (!landstripAvailable()) {
|
|
2147
2289
|
sandboxEnabled = false;
|
|
2148
2290
|
sandboxReady = false;
|
|
2291
|
+
setSandboxState('unavailable', ctx);
|
|
2149
2292
|
warnUnsandboxed(
|
|
2150
2293
|
ctx,
|
|
2151
2294
|
`landstrip was not found. Reinstall with: npm install @landstrip/landstrip`,
|
|
@@ -2156,6 +2299,7 @@ export function createLandstripIntegration(
|
|
|
2156
2299
|
|
|
2157
2300
|
sandboxEnabled = true;
|
|
2158
2301
|
sandboxReady = true;
|
|
2302
|
+
setSandboxState('enabled', ctx);
|
|
2159
2303
|
warnIfAllDomainsAllowed(ctx, config);
|
|
2160
2304
|
enableStatus(ctx, config);
|
|
2161
2305
|
return true;
|
|
@@ -2165,6 +2309,7 @@ export function createLandstripIntegration(
|
|
|
2165
2309
|
function disableSandbox(ctx: ExtensionContext): void {
|
|
2166
2310
|
sandboxEnabled = false;
|
|
2167
2311
|
sandboxReady = false;
|
|
2312
|
+
setSandboxState('disabled', ctx);
|
|
2168
2313
|
setTuiStatus(ctx, 'sandbox', undefined);
|
|
2169
2314
|
}
|
|
2170
2315
|
|
|
@@ -2219,6 +2364,9 @@ export function createLandstripIntegration(
|
|
|
2219
2364
|
default: false,
|
|
2220
2365
|
});
|
|
2221
2366
|
|
|
2367
|
+
unpublishRuntime?.();
|
|
2368
|
+
unpublishRuntime = publishLandstripRuntime(pi, integration);
|
|
2369
|
+
|
|
2222
2370
|
if (shouldRegisterBashTool) pi.registerTool(createBashTool(localCwd));
|
|
2223
2371
|
|
|
2224
2372
|
pi.on('user_bash', async (_event, ctx) => {
|
|
@@ -2228,6 +2376,7 @@ export function createLandstripIntegration(
|
|
|
2228
2376
|
});
|
|
2229
2377
|
|
|
2230
2378
|
pi.on('session_start', async (_event, ctx) => {
|
|
2379
|
+
activeContext = ctx;
|
|
2231
2380
|
resetSessionAllowances();
|
|
2232
2381
|
const trustContext = ctx as ExtensionContext & { isProjectTrusted?: () => boolean };
|
|
2233
2382
|
projectConfigTrusted = trustContext.isProjectTrusted?.() ?? false;
|
|
@@ -2236,6 +2385,7 @@ export function createLandstripIntegration(
|
|
|
2236
2385
|
if (noSandboxFlag) {
|
|
2237
2386
|
disableSandbox(ctx);
|
|
2238
2387
|
notify(ctx, 'Sandbox disabled via --no-sandbox', 'warning');
|
|
2388
|
+
if (!unpublishRuntime) unpublishRuntime = publishLandstripRuntime(pi, integration);
|
|
2239
2389
|
return;
|
|
2240
2390
|
}
|
|
2241
2391
|
|
|
@@ -2243,10 +2393,17 @@ export function createLandstripIntegration(
|
|
|
2243
2393
|
if (!config.enabled) {
|
|
2244
2394
|
disableSandbox(ctx);
|
|
2245
2395
|
notify(ctx, 'Sandbox disabled via config', 'info');
|
|
2396
|
+
if (!unpublishRuntime) unpublishRuntime = publishLandstripRuntime(pi, integration);
|
|
2246
2397
|
return;
|
|
2247
2398
|
}
|
|
2248
2399
|
|
|
2249
2400
|
enableSandbox(ctx);
|
|
2401
|
+
if (!unpublishRuntime) unpublishRuntime = publishLandstripRuntime(pi, integration);
|
|
2402
|
+
});
|
|
2403
|
+
pi.on('session_shutdown', () => {
|
|
2404
|
+
activeContext = undefined;
|
|
2405
|
+
unpublishRuntime?.();
|
|
2406
|
+
unpublishRuntime = undefined;
|
|
2250
2407
|
});
|
|
2251
2408
|
maybePi.registerCommand?.('sandbox', {
|
|
2252
2409
|
description: 'Show config and toggle the sandbox',
|
|
@@ -2641,5 +2798,17 @@ export function createLandstripIntegration(
|
|
|
2641
2798
|
});
|
|
2642
2799
|
}
|
|
2643
2800
|
|
|
2644
|
-
|
|
2801
|
+
const integration: LandstripIntegration = {
|
|
2802
|
+
version: LANDSTRIP_RUNTIME_VERSION,
|
|
2803
|
+
getContext,
|
|
2804
|
+
createBashTool,
|
|
2805
|
+
prepareProcess,
|
|
2806
|
+
prepareRpcWorker,
|
|
2807
|
+
registerWorkerExtension,
|
|
2808
|
+
getWorkerExtensions,
|
|
2809
|
+
on,
|
|
2810
|
+
register,
|
|
2811
|
+
emit: emitEvent,
|
|
2812
|
+
};
|
|
2813
|
+
return integration;
|
|
2645
2814
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-landstrip",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.23",
|
|
4
4
|
"description": "Sandbox-aware Pi subagents and interactive command permissions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landstrip",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"url": "git+https://github.com/landstrip/landstrip.git"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
|
+
"api.ts",
|
|
18
19
|
"agents.ts",
|
|
19
20
|
"box.ts",
|
|
20
21
|
"config.ts",
|
|
@@ -27,6 +28,11 @@
|
|
|
27
28
|
"sandbox.json"
|
|
28
29
|
],
|
|
29
30
|
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.ts",
|
|
33
|
+
"./api": "./api.ts",
|
|
34
|
+
"./*": "./*"
|
|
35
|
+
},
|
|
30
36
|
"scripts": {
|
|
31
37
|
"fmt": "oxfmt *.ts package.json tsconfig.json subagents.json sandbox.json .oxfmtrc.json README.md",
|
|
32
38
|
"lint": "oxlint *.ts",
|
|
@@ -40,7 +46,7 @@
|
|
|
40
46
|
},
|
|
41
47
|
"dependencies": {
|
|
42
48
|
"@earendil-works/pi-tui": "^0.80.6",
|
|
43
|
-
"@landstrip/landstrip": "^0.17.
|
|
49
|
+
"@landstrip/landstrip": "^0.17.23",
|
|
44
50
|
"minimatch": "^10.2.5"
|
|
45
51
|
},
|
|
46
52
|
"devDependencies": {
|
package/subagents.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui';
|
|
30
30
|
import { Type } from 'typebox';
|
|
31
31
|
|
|
32
|
+
import { encodeLandstripContext, type LandstripContextV1, LANDSTRIP_CONTEXT_ENV } from './api.ts';
|
|
32
33
|
import {
|
|
33
34
|
type AgentCatalog,
|
|
34
35
|
type AgentDefinition,
|
|
@@ -1296,6 +1297,7 @@ export class SubagentRuntime {
|
|
|
1296
1297
|
update: (text: string) => void,
|
|
1297
1298
|
): Promise<string> {
|
|
1298
1299
|
let worker: WorkerHandle | undefined;
|
|
1300
|
+
let started = false;
|
|
1299
1301
|
try {
|
|
1300
1302
|
const release = await this.semaphore.acquire(signal);
|
|
1301
1303
|
this.leases.set(task.id, { release });
|
|
@@ -1303,6 +1305,8 @@ export class SubagentRuntime {
|
|
|
1303
1305
|
task.startedAt = Date.now();
|
|
1304
1306
|
task.finishedAt = undefined;
|
|
1305
1307
|
task.error = undefined;
|
|
1308
|
+
started = true;
|
|
1309
|
+
this.integration.emit?.({ type: 'subagent.start', context: this.taskContext(task, ctx) });
|
|
1306
1310
|
this.persist(task);
|
|
1307
1311
|
this.updateTaskWidget(ctx);
|
|
1308
1312
|
const rules = mergePermissionRules(catalog.permissions, agent.permissions);
|
|
@@ -1411,6 +1415,13 @@ export class SubagentRuntime {
|
|
|
1411
1415
|
if (task.error === message) throw error;
|
|
1412
1416
|
throw new Error(task.error);
|
|
1413
1417
|
} finally {
|
|
1418
|
+
if (started && ['completed', 'cancelled', 'error'].includes(task.state)) {
|
|
1419
|
+
this.integration.emit?.({
|
|
1420
|
+
type: 'subagent.end',
|
|
1421
|
+
context: this.taskContext(task, ctx),
|
|
1422
|
+
status: task.state as 'completed' | 'cancelled' | 'error',
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1414
1425
|
this.running.delete(task.id);
|
|
1415
1426
|
this.pendingPrompts.delete(task.id);
|
|
1416
1427
|
await worker?.rpc.stop().catch(() => undefined);
|
|
@@ -1456,12 +1467,14 @@ export class SubagentRuntime {
|
|
|
1456
1467
|
const tools = taskEnabled
|
|
1457
1468
|
? [...new Set([...this.pi.getActiveTools(), 'task'])]
|
|
1458
1469
|
: this.pi.getActiveTools().filter((tool) => tool !== 'task');
|
|
1470
|
+
const workerExtensions = this.integration.getWorkerExtensions?.() ?? [];
|
|
1459
1471
|
const args = [
|
|
1460
1472
|
...invocation.args,
|
|
1461
1473
|
'--mode',
|
|
1462
1474
|
'rpc',
|
|
1463
1475
|
'--extension',
|
|
1464
1476
|
join(packageDir, 'index.ts'),
|
|
1477
|
+
...workerExtensions.flatMap((extension) => ['--extension', extension.entry]),
|
|
1465
1478
|
...(task.sessionFile
|
|
1466
1479
|
? ['--session', task.sessionFile]
|
|
1467
1480
|
: task.sessionDir
|
|
@@ -1478,6 +1491,7 @@ export class SubagentRuntime {
|
|
|
1478
1491
|
tools.join(','),
|
|
1479
1492
|
];
|
|
1480
1493
|
const config: WorkerConfig = { rules, task, taskEnabled, steps: agent.steps };
|
|
1494
|
+
const publicContext = this.taskContext(task, ctx);
|
|
1481
1495
|
const temp = mkdtempSync(join(tmpdir(), `pi-landstrip-task-${task.id}-`));
|
|
1482
1496
|
const agentDir = getAgentDir();
|
|
1483
1497
|
let launch: LandstripRpcWorkerLaunch | undefined;
|
|
@@ -1497,6 +1511,7 @@ export class SubagentRuntime {
|
|
|
1497
1511
|
env: {
|
|
1498
1512
|
...process.env,
|
|
1499
1513
|
[WORKER_ENV]: Buffer.from(JSON.stringify(config)).toString('base64url'),
|
|
1514
|
+
[LANDSTRIP_CONTEXT_ENV]: encodeLandstripContext(publicContext),
|
|
1500
1515
|
JITI_FS_CACHE: 'false',
|
|
1501
1516
|
TMPDIR: temp,
|
|
1502
1517
|
TMP: temp,
|
|
@@ -1514,6 +1529,11 @@ export class SubagentRuntime {
|
|
|
1514
1529
|
invocation.command,
|
|
1515
1530
|
cliRoot,
|
|
1516
1531
|
extensionRoot,
|
|
1532
|
+
...workerExtensions.flatMap((extension) => [
|
|
1533
|
+
extension.entry,
|
|
1534
|
+
dirname(extension.entry),
|
|
1535
|
+
dependencyRoot(extension.entry),
|
|
1536
|
+
]),
|
|
1517
1537
|
task.sessionDir,
|
|
1518
1538
|
task.sessionFile,
|
|
1519
1539
|
temp,
|
|
@@ -1562,6 +1582,27 @@ export class SubagentRuntime {
|
|
|
1562
1582
|
}
|
|
1563
1583
|
}
|
|
1564
1584
|
|
|
1585
|
+
private taskContext(task: TaskRecord, ctx: ExtensionContext): LandstripContextV1 {
|
|
1586
|
+
const context = this.integration.getContext?.(ctx) ?? {
|
|
1587
|
+
version: 1,
|
|
1588
|
+
host: 'pi',
|
|
1589
|
+
role: 'primary',
|
|
1590
|
+
sandbox: 'unavailable',
|
|
1591
|
+
cwd: ctx.cwd,
|
|
1592
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
1593
|
+
depth: 0,
|
|
1594
|
+
};
|
|
1595
|
+
return {
|
|
1596
|
+
...context,
|
|
1597
|
+
role: 'subagent',
|
|
1598
|
+
sessionId: undefined,
|
|
1599
|
+
taskId: task.id,
|
|
1600
|
+
parentTaskId: task.parentTaskId,
|
|
1601
|
+
agent: task.agent,
|
|
1602
|
+
depth: task.depth,
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1565
1606
|
private piInvocation(): { command: string; args: string[] } {
|
|
1566
1607
|
const argvEntry = process.argv[1];
|
|
1567
1608
|
if (argvEntry && /(?:^|[/\\])cli\.(?:js|mjs|cjs|ts)$/.test(argvEntry)) {
|