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 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 (spawned) throw new Error('RPC worker launch can only be spawned once');
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
- async dispose() {},
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 (spawned) throw new Error('RPC worker launch can only be spawned once');
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
- async dispose() {
1659
- if (disposed) return;
1660
- disposed = true;
1661
- trapSocket.destroy();
1662
- workerChildEnd.destroy();
1663
- rmSync(workerPolicy.dir, { recursive: true, force: true });
1664
- await proxy?.stop();
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
- return { createBashTool, prepareRpcWorker, register };
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.21",
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.21",
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)) {