pi-landstrip 0.17.34 → 0.17.35

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
@@ -16,7 +16,7 @@ The extension includes a shared [sandbox policy](./sandbox.json) and separate
16
16
  [agent configuration](./subagents.json). Global and trusted-project files can
17
17
  override both.
18
18
 
19
- Process-backed subagents require Pi >= 0.80.6 and < 0.82.0, and Node.js >= 22.19.0.
19
+ Process-backed subagents require Pi >= 0.80.6, and Node.js >= 22.19.0.
20
20
 
21
21
  ## Installation
22
22
 
@@ -46,6 +46,16 @@ Alternatively, place the extension under `~/.pi/agent/extensions/` (global) or
46
46
 
47
47
  On unsupported platforms the extension loads but leaves sandboxing disabled.
48
48
 
49
+ On Windows, Pi's Git Bash/MSYS shell cannot run under LPAC. The packaged policy
50
+ therefore selects standard AppContainer explicitly. This is weaker than LPAC because
51
+ resources granted to `ALL APPLICATION PACKAGES` remain visible; `/sandbox` and the
52
+ status line report the active backend and mode. There is no silent
53
+ LPAC-to-standard fallback.
54
+
55
+ An optional restricted-user backend supports Git Bash without AppContainer. It
56
+ requires one-time elevated host setup and persistent local accounts and WFP rules;
57
+ see [Windows restricted-user setup](#windows-restricted-user-setup).
58
+
49
59
  ## Disabling
50
60
 
51
61
  Use the `--no-sandbox` flag, or set `enabled` to `false` in `sandbox.json`:
@@ -61,9 +71,10 @@ processes, but without the outer Landstrip OS sandbox. The extension warns once
61
71
  per session. Agent tool permissions still apply, but they are not an OS isolation
62
72
  boundary.
63
73
 
64
- Trusted project config overrides global config. `/sandbox` updates a trusted
65
- project sandbox file when present, otherwise the global file. Pi versions
66
- without a project-trust API use only global configuration.
74
+ Trusted project config overrides global config except for the Windows backend,
75
+ which can be selected only in the trusted global file. `/sandbox` updates a trusted
76
+ project sandbox file when present, otherwise the global file. Pi versions without a
77
+ project-trust API use only global configuration.
67
78
 
68
79
  ## Behavior
69
80
 
@@ -75,11 +86,12 @@ being approved.
75
86
  Project approvals are written to `.pi/sandbox.json`; global approvals are
76
87
  written to `~/.pi/agent/sandbox.json`.
77
88
 
78
- The main agent remains a normal Pi process. `pi-landstrip` replaces Bash
79
- execution, including AI `bash` calls and manually typed shell commands (`!` and
80
- `!!`), with a Landstrip-wrapped implementation. Network traffic uses an
81
- allowlist proxy when direct access is disabled. Pi's filesystem tools and plugin
82
- callbacks remain trusted code outside the Landstrip sandbox.
89
+ The main agent remains a normal Pi process. `pi-landstrip` replaces Bash execution,
90
+ including AI `bash` calls and manually typed shell commands (`!` and `!!`), with a
91
+ Landstrip-wrapped implementation. Network traffic uses an allowlist proxy when
92
+ direct access is disabled and the platform policy permits loopback; otherwise it is
93
+ blocked. Pi's filesystem tools and plugin callbacks remain trusted code outside the
94
+ Landstrip sandbox.
83
95
 
84
96
  By default, Bash and subagent processes have no direct network access. Reads are
85
97
  limited to the project, Git configuration, and `/dev/null`; writes are limited
@@ -132,9 +144,11 @@ The tool accepts the OpenCode task fields:
132
144
  Foreground tasks return the child result directly. Background tasks return a
133
145
  queued result and deliver completion automatically. Task rows show lifecycle
134
146
  state, current activity, tool-call count, elapsed time, and expandable output.
135
- Use `/subagents` or the task row's `/subagents <id>` command to inspect a child
136
- transcript. Completed and failed task metadata remains available after reload,
137
- and persisted sessions can be continued with `task_id`.
147
+ Use `/subagents` to open an inspector with **Agents** and **Sessions** tabs. The
148
+ Agents tab lists every available subagent type and its `built-in`, `global`, or
149
+ `local` source. The Sessions tab inspects child transcripts; task rows can open a
150
+ specific one with `/subagents <id>`. Completed and failed task metadata remains
151
+ available after reload, and persisted sessions can be continued with `task_id`.
138
152
 
139
153
  Session switching or shutdown stops live workers. After an unclean restart,
140
154
  unfinished work is marked interrupted; completed but undelivered background
@@ -162,11 +176,19 @@ Unsupported Pi versions still fail task startup.
162
176
  `sandbox.json`, then restart or continue the task in a fresh worker to apply
163
177
  additional filesystem access. Main-agent Bash can retry a command, but there
164
178
  is no live worker-policy update.
165
- - **Windows**: AppContainer cannot permit only the loopback proxy port, so
166
- domain-proxied worker networking is unavailable and the worker has no network.
167
- Setting `network.allowNetwork` to `true` is a degraded mode that permits model
168
- access but gives the entire worker unrestricted network access; domain lists
169
- are then not a boundary.
179
+ - **Windows AppContainer**: the packaged policy selects standard AppContainer for
180
+ Pi's Git Bash/MSYS runtime. With `windows.allowLoopback: false` (the default), the
181
+ extension does not start an unreachable proxy and the worker has no network.
182
+ Setting `windows.allowLoopback` to `true` enables the domain proxy, but also gives
183
+ the AppContainer access to every local loopback service, not only the proxy port.
184
+ Setting `network.allowNetwork` to `true` instead gives the entire worker
185
+ unrestricted network access; domain lists are then not a boundary. Both opt-ins
186
+ are explicit security downgrades and produce warnings.
187
+ - **Windows restricted user**: WFP blocks restricted accounts except for the
188
+ installed proxy port range, so domain-filtered proxying works without a broad
189
+ loopback exemption. `network.allowNetwork: true` leases a separately provisioned
190
+ unrestricted account. Filesystem grants are journaled and revoked after every
191
+ process tree exits.
170
192
 
171
193
  ### Credentials
172
194
 
@@ -182,13 +204,59 @@ first.
182
204
 
183
205
  Subagent configuration is read from `~/.pi/agent/subagents.json` and, for trusted
184
206
  projects, `.pi/subagents.json`. Project values override global values; both are
185
- merged over the packaged [defaults](./subagents.json). Sandbox policy remains in
186
- `~/.pi/agent/sandbox.json` and `.pi/sandbox.json`. Pi's `settings.json` contains
187
- only normal Pi settings, such as the `pi-landstrip` package entry.
207
+ merged over the packaged [defaults](./subagents.json). The packaged subagent types
208
+ are `explore`, `general`, and the OpenCode-compatible `scout` reconnaissance agent.
209
+
210
+ Sandbox policy remains in `~/.pi/agent/sandbox.json` and `.pi/sandbox.json`. Pi's
211
+ `settings.json` contains only normal Pi settings, such as the `pi-landstrip` package
212
+ entry.
188
213
 
189
214
  `subagents.json` accepts only top-level `maxSubagents` and `subagents`; sandbox
190
215
  fields belong in `sandbox.json`.
191
216
 
217
+ The Windows sandbox fields are:
218
+
219
+ ```json
220
+ {
221
+ "windows": {
222
+ "backend": "appContainer",
223
+ "appContainerMode": "standard",
224
+ "allowLoopback": false
225
+ }
226
+ }
227
+ ```
228
+
229
+ `backend` is `"appContainer"` or `"restrictedUser"`. It is a trusted host setting:
230
+ only `~/.pi/agent/sandbox.json` can change it, and project configuration cannot
231
+ override it. `appContainerMode` is `"lpac"` or `"standard"`; core Landstrip defaults
232
+ to LPAC, while the Pi package defaults to standard mode for Git Bash compatibility.
233
+ `allowLoopback` is independently opt-in and applies only to AppContainer.
234
+
235
+ ### Windows restricted-user setup
236
+
237
+ Provision the backend once before selecting it in global configuration:
238
+
239
+ ```powershell
240
+ npx @landstrip/landstrip windows setup
241
+ npx @landstrip/landstrip windows status
242
+ ```
243
+
244
+ Setup requests UAC elevation and defaults to eight restricted-network accounts, two
245
+ unrestricted-network accounts, and proxy ports 60080-60111. Run
246
+ `npx @landstrip/landstrip windows setup --help` for pool and port options. Then set
247
+ `"backend": "restrictedUser"` in `~/.pi/agent/sandbox.json` and restart Pi.
248
+
249
+ The extension checks installation health and reads the configured proxy range before
250
+ launching a restricted worker. Restricted-user mode does not support
251
+ `windows.allowLoopback` or `network.allowLocalBinding`. Remove the persistent
252
+ accounts, WFP rules, runner, and recovered per-run ACL grants with:
253
+
254
+ ```powershell
255
+ npx @landstrip/landstrip windows uninstall
256
+ ```
257
+
258
+ A subagent configuration example follows:
259
+
192
260
  ```json
193
261
  {
194
262
  "maxSubagents": 2,
package/dist/index.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  Socket as NetSocket
20
20
  } from "node:net";
21
21
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
22
- import { basename as basename2, dirname as dirname3, isAbsolute as isAbsolute2, join as join5, resolve as resolve2 } from "node:path";
22
+ import { basename as basename2, dirname as dirname3, isAbsolute as isAbsolute2, join as join5, parse, resolve as resolve2 } from "node:path";
23
23
  import { StringDecoder as StringDecoder2 } from "node:string_decoder";
24
24
  import { fileURLToPath as fileURLToPath3, URL } from "node:url";
25
25
  import {
@@ -195,6 +195,12 @@ function mergeValue(base, override) {
195
195
  }
196
196
  return override;
197
197
  }
198
+ function recordAgentSources(sources, config, source) {
199
+ if (!isRecord2(config.subagents) || !isRecord2(config.subagents.agent))
200
+ return;
201
+ for (const name of Object.keys(config.subagents.agent))
202
+ sources.set(name, source);
203
+ }
198
204
  function getPiConfigPaths(cwd, fileName, agentDir = getAgentDir()) {
199
205
  return {
200
206
  globalPath: join2(agentDir, fileName),
@@ -217,17 +223,23 @@ async function setMaxSubagentsConfigForScope(cwd, maxSubagents, scope, agentDir
217
223
  }
218
224
  function loadLandstripConfig(cwd, includeProject = true, agentDir = getAgentDir()) {
219
225
  const { globalPath, projectPath } = getPiConfigPaths(cwd, "subagents.json", agentDir);
220
- let config = readConfig(join2(packageDir, "subagents.json"));
221
- config = mergeValue(config, readConfig(globalPath));
222
- if (includeProject) {
223
- config = mergeValue(config, readConfig(projectPath));
224
- }
226
+ const builtInConfig = readConfig(join2(packageDir, "subagents.json"));
227
+ const globalConfig = readConfig(globalPath);
228
+ const projectConfig = includeProject ? readConfig(projectPath) : undefined;
229
+ const agentSources = new Map;
230
+ recordAgentSources(agentSources, builtInConfig, "built-in");
231
+ recordAgentSources(agentSources, globalConfig, "global");
232
+ if (projectConfig)
233
+ recordAgentSources(agentSources, projectConfig, "local");
234
+ let config = mergeValue(builtInConfig, globalConfig);
235
+ if (projectConfig)
236
+ config = mergeValue(config, projectConfig);
225
237
  if (!Number.isInteger(config.maxSubagents) || (config.maxSubagents ?? -1) < 0 || (config.maxSubagents ?? MAX_SUBAGENTS + 1) > MAX_SUBAGENTS) {
226
238
  throw new Error(`maxSubagents must be an integer from 0 to ${MAX_SUBAGENTS}`);
227
239
  }
228
240
  if (!isRecord2(config.subagents))
229
241
  throw new Error("subagents must be an object");
230
- return config;
242
+ return { ...config, agentSources };
231
243
  }
232
244
 
233
245
  // agents.ts
@@ -295,7 +307,7 @@ function legacyConfigWarnings(piAgentDir) {
295
307
  return [];
296
308
  }
297
309
  }
298
- function normalizeAgent(name, raw) {
310
+ function normalizeAgent(name, raw, source) {
299
311
  for (const field of ["description", "prompt", "model", "variant"]) {
300
312
  if (raw[field] !== undefined && typeof raw[field] !== "string") {
301
313
  throw new Error(`agent ${name} ${field} must be a string`);
@@ -333,6 +345,7 @@ function normalizeAgent(name, raw) {
333
345
  }
334
346
  return {
335
347
  name,
348
+ source,
336
349
  description: typeof raw.description === "string" ? raw.description : undefined,
337
350
  prompt: typeof raw.prompt === "string" ? raw.prompt : "",
338
351
  mode: raw.mode ?? "all",
@@ -352,10 +365,12 @@ function loadAgentCatalog(cwd, piAgentDir = getAgentDir2(), includeProject = tru
352
365
  const diagnostics = [];
353
366
  let maxSubagents = 0;
354
367
  let subagents = {};
368
+ let agentSources = new Map;
355
369
  try {
356
370
  const config = loadLandstripConfig(cwd, includeProject, piAgentDir);
357
371
  maxSubagents = config.maxSubagents;
358
372
  subagents = config.subagents;
373
+ agentSources = new Map(config.agentSources);
359
374
  } catch (error) {
360
375
  diagnostics.push(formatError(error));
361
376
  }
@@ -370,7 +385,7 @@ function loadAgentCatalog(cwd, piAgentDir = getAgentDir2(), includeProject = tru
370
385
  continue;
371
386
  }
372
387
  try {
373
- const agent = normalizeAgent(name, value);
388
+ const agent = normalizeAgent(name, value, agentSources.get(name) ?? "built-in");
374
389
  if (agent)
375
390
  normalized.set(name, agent);
376
391
  } catch (error) {
@@ -866,12 +881,11 @@ var INSPECTOR_BODY_LINES = 16;
866
881
  var SUPPORTED_PI_MAJOR = 0;
867
882
  var MIN_SUPPORTED_PI_MINOR = 80;
868
883
  var MIN_SUPPORTED_PI_PATCH = 6;
869
- var MAX_SUPPORTED_PI_MINOR = 81;
870
884
  var cachedPiPackage;
871
885
  var piPackageResolved = false;
872
886
  function isSupportedPiVersion(version) {
873
887
  const [major, minor = -1, patch = -1] = version;
874
- return version.length === 3 && Number.isInteger(major) && Number.isInteger(minor) && Number.isInteger(patch) && major === SUPPORTED_PI_MAJOR && minor >= MIN_SUPPORTED_PI_MINOR && minor <= MAX_SUPPORTED_PI_MINOR && (minor > MIN_SUPPORTED_PI_MINOR || patch >= MIN_SUPPORTED_PI_PATCH);
888
+ return version.length === 3 && Number.isInteger(major) && Number.isInteger(minor) && Number.isInteger(patch) && major === SUPPORTED_PI_MAJOR && (minor > MIN_SUPPORTED_PI_MINOR || minor === MIN_SUPPORTED_PI_MINOR && patch >= MIN_SUPPORTED_PI_PATCH);
875
889
  }
876
890
  function resolvePiPackage() {
877
891
  if (piPackageResolved)
@@ -1483,14 +1497,18 @@ ${this.primaryAgent.prompt}` };
1483
1497
  ctx.ui.notify("Subagent inspection is available in TUI mode", "warning");
1484
1498
  return;
1485
1499
  }
1500
+ const catalog = this.getAgentCatalog(ctx);
1501
+ for (const warning of catalog.warnings)
1502
+ ctx.ui.notify(warning, "warning");
1503
+ for (const diagnostic of catalog.diagnostics)
1504
+ ctx.ui.notify(diagnostic, "warning");
1505
+ const agents = availableSubagents(catalog).sort((a, b) => a.name.localeCompare(b.name));
1486
1506
  const tasks = [...this.tasks.values()];
1487
- if (tasks.length === 0) {
1488
- ctx.ui.notify("No subagent sessions in this session", "info");
1489
- return;
1490
- }
1491
1507
  const requested = args.trim();
1492
- let selected = requested ? tasks.findIndex((task) => task.id === requested || task.id.startsWith(requested)) : 0;
1493
- if (selected < 0) {
1508
+ let tab = requested ? "sessions" : "agents";
1509
+ let selectedAgent = 0;
1510
+ let selectedTask = requested ? tasks.findIndex((task) => task.id === requested || task.id.startsWith(requested)) : 0;
1511
+ if (selectedTask < 0) {
1494
1512
  ctx.ui.notify(`Unknown subagent task: ${requested}`, "error");
1495
1513
  return;
1496
1514
  }
@@ -1499,27 +1517,54 @@ ${this.primaryAgent.prompt}` };
1499
1517
  await ctx.ui.custom((tui, theme, _keybindings, done) => ({
1500
1518
  render: (width) => {
1501
1519
  const contentWidth = Math.max(1, width - 4);
1502
- const box = (title, lines2) => [
1503
- boxTop(theme, width, title),
1520
+ const box = (lines2) => [
1521
+ boxTop(theme, width, "Subagents"),
1504
1522
  ...lines2.map((line) => boxRow(theme, width, line)),
1505
1523
  boxBottom(theme, width)
1506
1524
  ];
1507
- if (!detail) {
1508
- const start = Math.max(0, Math.min(selected - 6, tasks.length - 12));
1509
- const shown2 = tasks.slice(start, start + 12);
1510
- const lines2 = [];
1511
- for (const [offset, task2] of shown2.entries()) {
1525
+ const tabLabel = (label, active) => active ? theme.fg("accent", theme.bold(`[${label}]`)) : theme.fg("muted", label);
1526
+ const tabs = `${tabLabel("Agents", tab === "agents")} ${tabLabel(`Sessions ${tasks.length}`, tab === "sessions")}`;
1527
+ if (tab === "agents") {
1528
+ const start = Math.max(0, Math.min(selectedAgent - 5, agents.length - 11));
1529
+ const shown2 = agents.slice(start, start + 11);
1530
+ const nameWidth = Math.min(24, Math.max(5, ...agents.map((agent) => agent.name.length + 1)));
1531
+ const lines2 = [
1532
+ tabs,
1533
+ "",
1534
+ ` ${"AGENT".padEnd(nameWidth)} ${"SOURCE".padEnd(8)} DESCRIPTION`
1535
+ ];
1536
+ for (const [offset, agent] of shown2.entries()) {
1512
1537
  const index = start + offset;
1513
- const cursor = index === selected ? theme.fg("accent", "›") : " ";
1514
- const indent = " ".repeat(Math.max(0, task2.depth - 1));
1515
- lines2.push(`${cursor} ${indent}${taskState(theme, task2)} ${theme.fg("accent", `@${task2.agent}`)} ${theme.fg("text", task2.description)} ${theme.fg("dim", task2.id.slice(0, 8))}`);
1538
+ const cursor = index === selectedAgent ? theme.fg("accent", "›") : " ";
1539
+ const name = `@${agent.name}`.padEnd(nameWidth);
1540
+ const description = `${agent.description ?? "No description"}${agent.hidden ? " [hidden]" : ""}`;
1541
+ lines2.push(`${cursor} ${theme.fg("accent", name)} ${theme.fg("dim", agent.source.padEnd(8))} ${theme.fg("text", description)}`);
1542
+ }
1543
+ if (agents.length === 0)
1544
+ lines2.push(" No subagent types are configured.");
1545
+ lines2.push("", theme.fg("dim", "↑↓ select tab/→ sessions esc close"));
1546
+ return box(lines2);
1547
+ }
1548
+ if (!detail) {
1549
+ const lines2 = [tabs, ""];
1550
+ if (tasks.length === 0) {
1551
+ lines2.push("No subagent sessions in this session.");
1552
+ } else {
1553
+ const start = Math.max(0, Math.min(selectedTask - 5, tasks.length - 11));
1554
+ const shown2 = tasks.slice(start, start + 11);
1555
+ for (const [offset, task2] of shown2.entries()) {
1556
+ const index = start + offset;
1557
+ const cursor = index === selectedTask ? theme.fg("accent", "›") : " ";
1558
+ const indent = " ".repeat(Math.max(0, task2.depth - 1));
1559
+ lines2.push(`${cursor} ${indent}${taskState(theme, task2)} ${theme.fg("accent", `@${task2.agent}`)} ${theme.fg("text", task2.description)} ${theme.fg("dim", task2.id.slice(0, 8))}`);
1560
+ }
1516
1561
  }
1517
- lines2.push("", theme.fg("dim", "↑↓ select enter inspect esc close"));
1518
- return box("Subagent Sessions", lines2);
1562
+ lines2.push("", theme.fg("dim", "↑↓ select enter inspect tab/← agents esc close"));
1563
+ return box(lines2);
1519
1564
  }
1520
- const task = tasks[selected];
1565
+ const task = tasks[selectedTask];
1521
1566
  if (!task)
1522
- return [];
1567
+ return box([tabs, "", "No subagent sessions in this session."]);
1523
1568
  const siblings = tasks.filter((candidate) => candidate.parentTaskId === task.parentTaskId);
1524
1569
  const siblingIndex = siblings.findIndex((candidate) => candidate.id === task.id);
1525
1570
  const transcript = taskTranscript(task).flatMap((line) => line.split(`
@@ -1535,28 +1580,53 @@ ${this.primaryAgent.prompt}` };
1535
1580
  duration
1536
1581
  ].filter(Boolean);
1537
1582
  const lines = [
1583
+ tabs,
1584
+ "",
1538
1585
  `${theme.fg("accent", theme.bold(`@${task.agent}`))} ${theme.fg("text", task.description)}`,
1539
1586
  `${taskState(theme, task)} ${theme.fg("dim", metrics.join(" · "))}`,
1540
1587
  "",
1541
1588
  ...shown.map((line) => theme.fg("toolOutput", line))
1542
1589
  ];
1543
- while (lines.length < INSPECTOR_BODY_LINES + 3)
1590
+ while (lines.length < INSPECTOR_BODY_LINES + 5)
1544
1591
  lines.push("");
1545
1592
  if (transcript.length > INSPECTOR_BODY_LINES) {
1546
1593
  lines.push(theme.fg("dim", `${scroll + 1}-${scroll + shown.length} of ${transcript.length}`));
1547
1594
  }
1548
- lines.push(theme.fg("dim", "Parent p/backspace Prev ← Next → ↑↓ scroll r refresh esc back"));
1549
- return box("Subagent Session", lines.map((line) => truncateToWidth2(line, contentWidth)));
1595
+ lines.push(theme.fg("dim", "Parent p/backspace Prev ← Next → ↑↓ scroll tab agents esc back"));
1596
+ return box(lines.map((line) => truncateToWidth2(line, contentWidth)));
1550
1597
  },
1551
1598
  handleInput: (data) => {
1599
+ if (matchesKey(data, "tab")) {
1600
+ tab = tab === "agents" ? "sessions" : "agents";
1601
+ detail = false;
1602
+ scroll = 0;
1603
+ tui.requestRender();
1604
+ return;
1605
+ }
1606
+ if (tab === "agents") {
1607
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
1608
+ done();
1609
+ else if (matchesKey(data, "up"))
1610
+ selectedAgent = Math.max(0, selectedAgent - 1);
1611
+ else if (matchesKey(data, "down") && agents.length > 0) {
1612
+ selectedAgent = Math.min(agents.length - 1, selectedAgent + 1);
1613
+ } else if (matchesKey(data, "right")) {
1614
+ tab = "sessions";
1615
+ } else
1616
+ return;
1617
+ tui.requestRender();
1618
+ return;
1619
+ }
1552
1620
  if (!detail) {
1553
1621
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
1554
1622
  done();
1623
+ else if (matchesKey(data, "left"))
1624
+ tab = "agents";
1555
1625
  else if (matchesKey(data, "up"))
1556
- selected = Math.max(0, selected - 1);
1557
- else if (matchesKey(data, "down"))
1558
- selected = Math.min(tasks.length - 1, selected + 1);
1559
- else if (matchesKey(data, "return")) {
1626
+ selectedTask = Math.max(0, selectedTask - 1);
1627
+ else if (matchesKey(data, "down") && tasks.length > 0) {
1628
+ selectedTask = Math.min(tasks.length - 1, selectedTask + 1);
1629
+ } else if (matchesKey(data, "return") && tasks.length > 0) {
1560
1630
  detail = true;
1561
1631
  scroll = 0;
1562
1632
  } else
@@ -1564,7 +1634,7 @@ ${this.primaryAgent.prompt}` };
1564
1634
  tui.requestRender();
1565
1635
  return;
1566
1636
  }
1567
- const task = tasks[selected];
1637
+ const task = tasks[selectedTask];
1568
1638
  if (!task)
1569
1639
  return;
1570
1640
  if (matchesKey(data, "escape")) {
@@ -1579,7 +1649,7 @@ ${this.primaryAgent.prompt}` };
1579
1649
  } else if (data === "p" || matchesKey(data, "backspace")) {
1580
1650
  const parentIndex = task.parentTaskId ? tasks.findIndex((candidate) => candidate.id === task.parentTaskId) : -1;
1581
1651
  if (parentIndex >= 0)
1582
- selected = parentIndex;
1652
+ selectedTask = parentIndex;
1583
1653
  else
1584
1654
  detail = false;
1585
1655
  scroll = 0;
@@ -1589,7 +1659,7 @@ ${this.primaryAgent.prompt}` };
1589
1659
  const offset = matchesKey(data, "left") ? -1 : 1;
1590
1660
  const sibling = siblings[siblingIndex + offset];
1591
1661
  if (sibling)
1592
- selected = tasks.indexOf(sibling);
1662
+ selectedTask = tasks.indexOf(sibling);
1593
1663
  scroll = 0;
1594
1664
  } else if (data !== "r")
1595
1665
  return;
@@ -2188,7 +2258,7 @@ ${catalog.diagnostics.join(`
2188
2258
  throw new Error("Unable to resolve the running Pi package; process-backed subagents are unavailable");
2189
2259
  }
2190
2260
  if (!isSupportedPiVersion(pkg.version)) {
2191
- throw new Error(`Process-backed subagents require Pi >=0.80.6 <0.82.0; found ${pkg.version.join(".")}`);
2261
+ throw new Error(`Process-backed subagents require Pi >=0.80.6; found ${pkg.version.join(".")}`);
2192
2262
  }
2193
2263
  }
2194
2264
  async handleWorkerRequest(task, rules, ctx, request) {
@@ -2575,7 +2645,9 @@ function loadSandboxConfig(cwd, includeProject) {
2575
2645
  if (includeProject && existsSync4(projectConfigPath)) {
2576
2646
  try {
2577
2647
  const projectConfig = JSON.parse(readFileSync4(projectConfigPath, "utf-8"));
2578
- return deepMerge(globalConfig, projectConfig);
2648
+ const merged = deepMerge(globalConfig, projectConfig);
2649
+ merged.windows.backend = globalConfig.windows.backend;
2650
+ return merged;
2579
2651
  } catch (error) {
2580
2652
  console.error(`Warning: Could not parse ${projectConfigPath}: ${error}`);
2581
2653
  }
@@ -2590,6 +2662,7 @@ function mergeArray(base, override) {
2590
2662
  function deepMerge(base, overrides) {
2591
2663
  const network = overrides.network;
2592
2664
  const filesystem = overrides.filesystem;
2665
+ const windows = overrides.windows;
2593
2666
  return {
2594
2667
  enabled: overrides.enabled ?? base.enabled,
2595
2668
  network: {
@@ -2605,9 +2678,43 @@ function deepMerge(base, overrides) {
2605
2678
  allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
2606
2679
  allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
2607
2680
  denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite)
2681
+ },
2682
+ windows: {
2683
+ backend: windows?.backend ?? base.windows.backend,
2684
+ appContainerMode: windows?.appContainerMode ?? base.windows.appContainerMode,
2685
+ allowLoopback: windows?.allowLoopback ?? base.windows.allowLoopback
2608
2686
  }
2609
2687
  };
2610
2688
  }
2689
+ function windowsBackendArgs(config) {
2690
+ if (process.platform !== "win32")
2691
+ return [];
2692
+ const backend = config.windows.backend === "restrictedUser" ? "restricted-user" : "app-container";
2693
+ return ["--windows-backend", backend];
2694
+ }
2695
+ function shouldStartProxy(config) {
2696
+ if (config.network.allowNetwork)
2697
+ return false;
2698
+ return process.platform !== "win32" || config.windows.backend === "restrictedUser" || config.windows.allowLoopback;
2699
+ }
2700
+ function restrictedUserProxyPortRange(config) {
2701
+ if (process.platform !== "win32" || config.windows.backend !== "restrictedUser")
2702
+ return;
2703
+ const result = spawnSync(binaryPath(), ["windows", "status"], {
2704
+ encoding: "utf-8",
2705
+ windowsHide: true
2706
+ });
2707
+ if (result.error)
2708
+ throw result.error;
2709
+ if (result.status !== 0) {
2710
+ throw new Error(result.stderr.trim() || "Restricted-user backend status check failed");
2711
+ }
2712
+ const status = JSON.parse(result.stdout);
2713
+ if (status.installed !== true || status.healthy !== true || !Number.isInteger(status.proxyPortLow) || !Number.isInteger(status.proxyPortHigh) || status.proxyPortLow < 1 || status.proxyPortHigh < status.proxyPortLow) {
2714
+ throw new Error('The Windows restricted-user backend is not installed or healthy. Run "landstrip windows setup".');
2715
+ }
2716
+ return { low: status.proxyPortLow, high: status.proxyPortHigh };
2717
+ }
2611
2718
  function getConfigPaths(cwd) {
2612
2719
  return getPiConfigPaths(cwd, "sandbox.json");
2613
2720
  }
@@ -2717,10 +2824,13 @@ function canonicalizePath(filePath, cwd) {
2717
2824
  }
2718
2825
  }
2719
2826
  }
2827
+ function normalizePathSeparators(path) {
2828
+ return process.platform === "win32" ? path.replaceAll("\\", "/") : path;
2829
+ }
2720
2830
  function matchesPattern(filePath, patterns, cwd) {
2721
- const abs = canonicalizePath(filePath, cwd);
2831
+ const abs = normalizePathSeparators(canonicalizePath(filePath, cwd));
2722
2832
  return patterns.some((pattern) => {
2723
- const absPattern = pattern.includes("*") ? expandPath(pattern, cwd) : canonicalizePath(pattern, cwd);
2833
+ const absPattern = normalizePathSeparators(pattern.includes("*") ? expandPath(pattern, cwd) : canonicalizePath(pattern, cwd));
2724
2834
  if (pattern.includes("*")) {
2725
2835
  const escaped = absPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/|\*\*|\*/g, (token) => token === "**/" ? "(?:.*/)?" : token === "**" ? ".*" : "[^/]*");
2726
2836
  return new RegExp(`^${escaped}$`).test(abs);
@@ -2730,16 +2840,20 @@ function matchesPattern(filePath, patterns, cwd) {
2730
2840
  });
2731
2841
  }
2732
2842
  function normalizeBlockedPath(path, cwd) {
2733
- return canonicalizePath(isAbsolute2(path) ? path : join5(cwd, path), cwd);
2843
+ const nativePath = process.platform === "win32" && /^\/[a-zA-Z](?:\/|$)/.test(path) ? `${path[1]}:${path.slice(2)}` : path;
2844
+ return canonicalizePath(isAbsolute2(nativePath) ? nativePath : join5(cwd, nativePath), cwd);
2734
2845
  }
2735
2846
  function pathUnderDirectory(filePath, dir) {
2736
- if (filePath === dir)
2847
+ const normalizedFilePath = normalizePathSeparators(filePath);
2848
+ const normalizedDir = normalizePathSeparators(dir);
2849
+ if (normalizedFilePath === normalizedDir)
2737
2850
  return true;
2738
- const sep2 = dir.endsWith("/") ? "" : "/";
2739
- return filePath.startsWith(dir + sep2);
2851
+ const sep2 = normalizedDir.endsWith("/") ? "" : "/";
2852
+ return normalizedFilePath.startsWith(normalizedDir + sep2);
2740
2853
  }
2741
2854
  function sessionScopeFor(filePath, baseDirectory) {
2742
2855
  const dir = dirname3(filePath);
2856
+ const normalizedDir = normalizePathSeparators(dir);
2743
2857
  const home = homedir3();
2744
2858
  const boundaries = new Set;
2745
2859
  if (home)
@@ -2750,12 +2864,13 @@ function sessionScopeFor(filePath, baseDirectory) {
2750
2864
  boundaries.add(realHome);
2751
2865
  } catch {}
2752
2866
  for (const boundary of boundaries) {
2867
+ const normalizedBoundary = normalizePathSeparators(boundary);
2753
2868
  if (pathUnderDirectory(dir, boundary)) {
2754
- const rest = dir.slice(boundary.length).replace(/^\/+/, "");
2869
+ const rest = normalizedDir.slice(normalizedBoundary.length).replace(/^\/+/, "");
2755
2870
  const first = rest.split("/")[0];
2756
2871
  if (!first)
2757
2872
  return filePath;
2758
- return boundary.endsWith("/") ? boundary + first : `${boundary}/${first}`;
2873
+ return join5(boundary, first);
2759
2874
  }
2760
2875
  }
2761
2876
  if (pathUnderDirectory(dir, baseDirectory))
@@ -2781,7 +2896,7 @@ function readAllowed(path, allowRead, denyRead, cwd) {
2781
2896
  }
2782
2897
  function isPathLike(value) {
2783
2898
  const trimmed = value.trim();
2784
- return trimmed === "~" || trimmed.startsWith("/") || trimmed.startsWith("~/") || trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith(".") || trimmed.includes("/");
2899
+ return trimmed === "~" || trimmed.startsWith("/") || trimmed.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(trimmed) || trimmed.startsWith("~/") || trimmed.startsWith("~\\") || trimmed.startsWith("./") || trimmed.startsWith(".\\") || trimmed.startsWith("../") || trimmed.startsWith("..\\") || trimmed.startsWith(".") || trimmed.includes("/") || trimmed.includes("\\");
2785
2900
  }
2786
2901
  function normalizePathMatch(value, cwd) {
2787
2902
  return isPathLike(value) ? normalizeBlockedPath(value, cwd) : null;
@@ -2803,28 +2918,27 @@ function extractBlockedPath(trapOutput, cwd) {
2803
2918
  return null;
2804
2919
  }
2805
2920
  function extractNativeDeniedPath(output, cwd) {
2806
- let match = output.match(/['"]([^'"\n]+)['"]:\s+(?:Operation not permitted|Permission denied)/);
2921
+ const denial = String.raw`(?:Access is denied\.?|Operation not permitted|Permission denied)`;
2922
+ let match = output.match(new RegExp(String.raw`['"]([^'"\n]+)['"][^\r\n]{0,120}${denial}`, "i"));
2807
2923
  if (match)
2808
2924
  return normalizePathMatch(match[1], cwd);
2809
- match = output.match(/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/);
2925
+ match = output.match(new RegExp(String.raw`(?:^|:\s+)((?:[a-zA-Z]:[\\/]|\\\\|/|\.{1,2}[\\/])[^\r\n]*?):\s+${denial}$`, "im"));
2810
2926
  if (match)
2811
2927
  return normalizePathMatch(match[1], cwd);
2812
- match = output.match(/^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: (?:Operation not permitted|Permission denied)$/m);
2928
+ match = output.match(new RegExp(String.raw`^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: ${denial}$`, "im"));
2813
2929
  if (match)
2814
2930
  return normalizePathMatch(match[1], cwd);
2815
- match = output.match(/^[a-zA-Z0-9_-]+: (\/[^:\n]+): (?:Operation not permitted|Permission denied)$/m);
2816
- if (match)
2817
- return normalizeBlockedPath(match[1], cwd);
2818
2931
  return null;
2819
2932
  }
2820
2933
  function extractNativeWriteDeniedPath(output, cwd) {
2821
- let match = output.match(/(?:[Uu]nable to create|cannot (?:create|touch|mkdir|remove|unlink|rename)|for writing)[^'"\n]*['"]([^'"\n]+)['"]:\s+(?:Operation not permitted|Permission denied)/m);
2934
+ const denial = String.raw`(?:Access is denied\.?|Operation not permitted|Permission denied)`;
2935
+ let match = output.match(new RegExp(String.raw`(?:[Uu]nable to create|cannot (?:create|touch|mkdir|remove|unlink|rename)|for writing)[^'"\n]*['"]([^'"\n]+)['"][^\r\n]{0,120}${denial}`, "im"));
2822
2936
  if (match)
2823
2937
  return normalizePathMatch(match[1], cwd);
2824
- match = output.match(/^[a-zA-Z0-9_-]+: cannot create(?: directory)? '?([^'\n]+?)'?(?: for writing)?: (?:Operation not permitted|Permission denied)$/m);
2938
+ match = output.match(new RegExp(String.raw`^[a-zA-Z0-9_-]+: cannot create(?: directory)? '?([^'\n]+?)'?(?: for writing)?: ${denial}$`, "im"));
2825
2939
  if (match)
2826
2940
  return normalizePathMatch(match[1], cwd);
2827
- match = output.match(/^[a-zA-Z0-9_-]+: couldn't open temporary file (\/[^:\n]+): (?:Operation not permitted|Permission denied)$/m);
2941
+ match = output.match(new RegExp(String.raw`^[a-zA-Z0-9_-]+: couldn't open temporary file ((?:[a-zA-Z]:[\\/]|\\\\|/)[^\r\n]*?): ${denial}$`, "im"));
2828
2942
  if (match)
2829
2943
  return normalizePathMatch(match[1], cwd);
2830
2944
  return null;
@@ -3010,7 +3124,7 @@ function landstripDisplayPath() {
3010
3124
  return "unavailable";
3011
3125
  }
3012
3126
  }
3013
- function writeEnvFile(env, proxyPort) {
3127
+ function writeEnvFile(env, proxyPort, proxyToken) {
3014
3128
  const lines = [];
3015
3129
  for (const [key, value] of Object.entries(env)) {
3016
3130
  if (value === undefined)
@@ -3021,7 +3135,8 @@ function writeEnvFile(env, proxyPort) {
3021
3135
  lines.push(`export ${key}='${escaped}'`);
3022
3136
  }
3023
3137
  if (proxyPort !== null) {
3024
- const url = `http://127.0.0.1:${proxyPort}`;
3138
+ const credentials = proxyToken === undefined ? "" : `landstrip:${proxyToken}@`;
3139
+ const url = `http://${credentials}127.0.0.1:${proxyPort}`;
3025
3140
  for (const name of PROXY_ENVIRONMENT_VARIABLES) {
3026
3141
  lines.push(`export ${name}='${url}'`);
3027
3142
  }
@@ -3208,11 +3323,45 @@ function createLandstripIntegration(options = {}) {
3208
3323
  function getEffectiveAllowedDomains(config, allowances) {
3209
3324
  return mergeAllowances(config.network.allowedDomains, sessionAllowedDomains, allowances?.domains);
3210
3325
  }
3211
- function getEffectiveAllowRead(config, allowances) {
3212
- return mergeAllowances(config.filesystem.allowRead, sessionAllowedReadPaths, allowances?.readPaths);
3326
+ function getEffectiveAllowRead(config, cwd, allowances) {
3327
+ const paths = mergeAllowances(config.filesystem.allowRead, sessionAllowedReadPaths, allowances?.readPaths);
3328
+ if (process.platform !== "win32")
3329
+ return paths;
3330
+ return paths.filter((path) => !path.startsWith("/") && (path.includes("*") || existsSync4(expandPath(path, cwd))));
3213
3331
  }
3214
3332
  function getEffectiveAllowWrite(config, allowances) {
3215
- return mergeAllowances(config.filesystem.allowWrite, sessionAllowedWritePaths, allowances?.writePaths);
3333
+ const paths = mergeAllowances(config.filesystem.allowWrite, sessionAllowedWritePaths, allowances?.writePaths);
3334
+ return process.platform === "win32" ? paths.filter((path) => !path.startsWith("/")) : paths;
3335
+ }
3336
+ function getEffectiveDenyRead(config, cwd) {
3337
+ if (process.platform !== "win32")
3338
+ return config.filesystem.denyRead;
3339
+ const volumeRoot = parse(resolve2(cwd)).root;
3340
+ return [...new Set([...config.filesystem.denyRead, volumeRoot])];
3341
+ }
3342
+ function hasBaselineWindowsApplicationAccess(filePath) {
3343
+ const normalizedPath = normalizePathSeparators(resolve2(filePath)).toLowerCase();
3344
+ const systemRoots = [
3345
+ process.env.ProgramFiles,
3346
+ process.env["ProgramFiles(x86)"],
3347
+ process.env.ProgramW6432,
3348
+ process.env.SystemRoot
3349
+ ];
3350
+ return systemRoots.some((root) => {
3351
+ if (!root)
3352
+ return false;
3353
+ return pathUnderDirectory(normalizedPath, normalizePathSeparators(resolve2(root)).toLowerCase());
3354
+ });
3355
+ }
3356
+ function withWindowsProcessReadAccess(allowances, command, cwd, additionalPaths = []) {
3357
+ if (process.platform !== "win32")
3358
+ return allowances;
3359
+ const executable = resolve2(cwd, command);
3360
+ const executablePaths = hasBaselineWindowsApplicationAccess(executable) ? [] : [executable];
3361
+ return {
3362
+ ...allowances,
3363
+ readPaths: [...new Set([...allowances.readPaths, ...executablePaths, ...additionalPaths])]
3364
+ };
3216
3365
  }
3217
3366
  async function applyDomainChoice(choice, domain, cwd, allowances) {
3218
3367
  const { globalPath, projectPath } = getConfigPaths(cwd);
@@ -3275,11 +3424,12 @@ function createLandstripIntegration(options = {}) {
3275
3424
  ...proxyPort !== null ? { httpProxyPort: proxyPort } : {}
3276
3425
  },
3277
3426
  filesystem: {
3278
- denyRead: config.filesystem.denyRead,
3279
- allowRead: getEffectiveAllowRead(config, allowances),
3427
+ denyRead: getEffectiveDenyRead(config, cwd),
3428
+ allowRead: getEffectiveAllowRead(config, cwd, allowances),
3280
3429
  allowWrite: getEffectiveAllowWrite(config, allowances),
3281
3430
  denyWrite: config.filesystem.denyWrite
3282
- }
3431
+ },
3432
+ windows: config.windows
3283
3433
  };
3284
3434
  }
3285
3435
  function writePolicyFile(cwd, proxyPort, allowances) {
@@ -3289,7 +3439,7 @@ function createLandstripIntegration(options = {}) {
3289
3439
  `, "utf-8");
3290
3440
  return { dir, path };
3291
3441
  }
3292
- function startProxy(ctx, allowances, signal, authorization) {
3442
+ function startProxy(ctx, allowances, signal, authorization, portRange) {
3293
3443
  const sockets = new Set;
3294
3444
  async function domainAllowed(domain) {
3295
3445
  const config = loadConfig(ctx.cwd);
@@ -3421,24 +3571,33 @@ function createLandstripIntegration(options = {}) {
3421
3571
  const server = createServer(handleClient);
3422
3572
  let stopped = false;
3423
3573
  return new Promise((resolve3, reject) => {
3424
- server.on("error", reject);
3425
- server.listen(0, "127.0.0.1", () => {
3426
- server.removeListener("error", reject);
3427
- const address = server.address();
3428
- resolve3({
3429
- port: address.port,
3430
- stop: () => new Promise((done) => {
3431
- if (stopped) {
3432
- done();
3433
- return;
3434
- }
3435
- stopped = true;
3436
- for (const socket of sockets)
3437
- socket.destroy();
3438
- server.close(() => done());
3439
- })
3574
+ const listen = (port) => {
3575
+ server.once("error", (error) => {
3576
+ if (error.code === "EADDRINUSE" && portRange && port < portRange.high) {
3577
+ listen(port + 1);
3578
+ return;
3579
+ }
3580
+ reject(error);
3440
3581
  });
3441
- });
3582
+ server.listen(port, "127.0.0.1", () => {
3583
+ server.removeAllListeners("error");
3584
+ const address = server.address();
3585
+ resolve3({
3586
+ port: address.port,
3587
+ stop: () => new Promise((done) => {
3588
+ if (stopped) {
3589
+ done();
3590
+ return;
3591
+ }
3592
+ stopped = true;
3593
+ for (const socket of sockets)
3594
+ socket.destroy();
3595
+ server.close(() => done());
3596
+ })
3597
+ });
3598
+ });
3599
+ };
3600
+ listen(portRange?.low ?? 0);
3442
3601
  });
3443
3602
  }
3444
3603
  function createSocketPair() {
@@ -3479,7 +3638,7 @@ function createLandstripIntegration(options = {}) {
3479
3638
  }
3480
3639
  const path = normalizeBlockedPath(trap.path, cwd);
3481
3640
  const config = loadConfig(cwd);
3482
- const allowed = trap.operation === "read" ? readAllowed(path, getEffectiveAllowRead(config, allowances), config.filesystem.denyRead, cwd) : !matchesPattern(path, config.filesystem.denyWrite, cwd) && !shouldPromptForWrite(path, getEffectiveAllowWrite(config, allowances), cwd);
3641
+ const allowed = trap.operation === "read" ? readAllowed(path, getEffectiveAllowRead(config, cwd, allowances), config.filesystem.denyRead, cwd) : !matchesPattern(path, config.filesystem.denyWrite, cwd) && !shouldPromptForWrite(path, getEffectiveAllowWrite(config, allowances), cwd);
3483
3642
  if (allowed)
3484
3643
  return { action: "allow" };
3485
3644
  if (trap.operation === "write" && matchesPattern(path, config.filesystem.denyWrite, cwd)) {
@@ -3599,10 +3758,11 @@ function createLandstripIntegration(options = {}) {
3599
3758
  writePaths: [...options2.writePaths],
3600
3759
  targets: []
3601
3760
  };
3761
+ const processAllowances = withWindowsProcessReadAccess(allowances, options2.command, options2.cwd);
3602
3762
  const config = loadConfig(options2.cwd);
3603
3763
  const proxyToken = randomBytes(32).toString("base64url");
3604
3764
  const proxyAuthorization = `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString("base64")}`;
3605
- const proxy = config.network.allowNetwork ? null : await startProxy(options2.ctx, allowances, options2.signal, proxyAuthorization);
3765
+ const proxy = shouldStartProxy(config) ? await startProxy(options2.ctx, processAllowances, options2.signal, proxyAuthorization, restrictedUserProxyPortRange(config)) : null;
3606
3766
  let policy;
3607
3767
  let trapSocket;
3608
3768
  let childEnd;
@@ -3610,11 +3770,13 @@ function createLandstripIntegration(options = {}) {
3610
3770
  try {
3611
3771
  if (options2.signal?.aborted)
3612
3772
  throw new Error("Task cancelled");
3613
- policy = writePolicyFile(options2.cwd, proxy?.port ?? null, allowances);
3614
- [trapSocket, childEnd] = await createSocketPair();
3615
- if (options2.signal?.aborted)
3616
- throw new Error("Task cancelled");
3617
- attachWorkerTrap(trapSocket, options2.ctx, options2.cwd, allowances, options2.signal);
3773
+ policy = writePolicyFile(options2.cwd, proxy?.port ?? null, processAllowances);
3774
+ if (process.platform !== "win32") {
3775
+ [trapSocket, childEnd] = await createSocketPair();
3776
+ if (options2.signal?.aborted)
3777
+ throw new Error("Task cancelled");
3778
+ attachWorkerTrap(trapSocket, options2.ctx, options2.cwd, processAllowances, options2.signal);
3779
+ }
3618
3780
  } catch (error) {
3619
3781
  trapSocket?.destroy();
3620
3782
  childEnd?.destroy();
@@ -3624,6 +3786,7 @@ function createLandstripIntegration(options = {}) {
3624
3786
  throw error;
3625
3787
  }
3626
3788
  const workerPolicy = policy;
3789
+ const workerTrapSocket = trapSocket;
3627
3790
  const workerChildEnd = childEnd;
3628
3791
  const workerEnv = { ...options2.env };
3629
3792
  if (proxy) {
@@ -3643,14 +3806,24 @@ function createLandstripIntegration(options = {}) {
3643
3806
  if (spawned)
3644
3807
  throw new Error("Prepared process can only be spawned once");
3645
3808
  spawned = true;
3646
- const child = spawn2(binaryPath(), ["--trap-fd", "3", "-p", workerPolicy.path, options2.command, ...options2.args], {
3809
+ const landstripArgs = [
3810
+ ...windowsBackendArgs(config),
3811
+ "-p",
3812
+ workerPolicy.path,
3813
+ options2.command,
3814
+ ...options2.args
3815
+ ];
3816
+ const stdio = workerChildEnd ? ["pipe", "pipe", "pipe", workerChildEnd] : ["pipe", "pipe", "pipe"];
3817
+ if (workerChildEnd)
3818
+ landstripArgs.unshift("--trap-fd", "3");
3819
+ const child = spawn2(binaryPath(), landstripArgs, {
3647
3820
  ...spawnOptions,
3648
3821
  cwd: options2.cwd,
3649
3822
  env: workerEnv,
3650
3823
  detached: true,
3651
- stdio: ["pipe", "pipe", "pipe", workerChildEnd]
3824
+ stdio
3652
3825
  });
3653
- workerChildEnd.destroy();
3826
+ workerChildEnd?.destroy();
3654
3827
  patchProcessGroupKill(child);
3655
3828
  preparedChild = child;
3656
3829
  return child;
@@ -3665,8 +3838,8 @@ function createLandstripIntegration(options = {}) {
3665
3838
  disposePromise ??= (async () => {
3666
3839
  disposed = true;
3667
3840
  await stopPreparedChild(preparedChild);
3668
- trapSocket.destroy();
3669
- workerChildEnd.destroy();
3841
+ workerTrapSocket?.destroy();
3842
+ workerChildEnd?.destroy();
3670
3843
  rmSync2(workerPolicy.dir, { recursive: true, force: true });
3671
3844
  await proxy?.stop();
3672
3845
  })();
@@ -3692,10 +3865,12 @@ function createLandstripIntegration(options = {}) {
3692
3865
  throw new Error(`Working directory does not exist: ${cwd}`);
3693
3866
  const { shell, args } = getShellConfig(SettingsManager.create(cwd).getShellPath());
3694
3867
  const config = loadConfig(cwd);
3695
- const allowNetwork = config.network.allowNetwork;
3696
- const proxy = allowNetwork ? null : await startProxy(ctx, allowances, signal);
3868
+ const proxyToken = randomBytes(32).toString("base64url");
3869
+ const proxyAuthorization = `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString("base64")}`;
3870
+ const proxy = shouldStartProxy(config) ? await startProxy(ctx, allowances, signal, proxyAuthorization, restrictedUserProxyPortRange(config)) : null;
3697
3871
  let policy;
3698
3872
  let envFile;
3873
+ let processAllowances = allowances;
3699
3874
  let teardownPromise;
3700
3875
  const teardownResources = () => {
3701
3876
  teardownPromise ??= (async () => {
@@ -3709,10 +3884,20 @@ function createLandstripIntegration(options = {}) {
3709
3884
  };
3710
3885
  let landstripArgs;
3711
3886
  try {
3712
- policy = writePolicyFile(cwd, proxy?.port ?? null, allowances);
3713
- envFile = writeEnvFile({ ...process.env, ...env, PWD: resolve2(cwd) }, proxy?.port ?? null);
3887
+ envFile = writeEnvFile({ ...process.env, ...env, PWD: resolve2(cwd) }, proxy?.port ?? null, proxy ? proxyToken : undefined);
3888
+ processAllowances = withWindowsProcessReadAccess(allowances, shell, cwd, [envFile.path]);
3889
+ policy = writePolicyFile(cwd, proxy?.port ?? null, processAllowances);
3714
3890
  const wrappedCommand = `source '${envFile.path}' && ${command}`;
3715
- landstripArgs = ["--trap-fd", "3", "-p", policy.path, shell, ...args, wrappedCommand];
3891
+ landstripArgs = [
3892
+ ...windowsBackendArgs(config),
3893
+ "-p",
3894
+ policy.path,
3895
+ shell,
3896
+ ...args,
3897
+ wrappedCommand
3898
+ ];
3899
+ if (process.platform !== "win32")
3900
+ landstripArgs.unshift("--trap-fd", "3");
3716
3901
  } catch (error) {
3717
3902
  await teardownResources();
3718
3903
  throw error;
@@ -3722,7 +3907,10 @@ function createLandstripIntegration(options = {}) {
3722
3907
  let timeoutHandle;
3723
3908
  let timedOut = false;
3724
3909
  let cleaned = false;
3725
- const [trapSocket, childEnd] = await createSocketPair();
3910
+ let trapSocket;
3911
+ let childEnd;
3912
+ if (process.platform !== "win32")
3913
+ [trapSocket, childEnd] = await createSocketPair();
3726
3914
  const cleanup = async () => {
3727
3915
  if (cleaned)
3728
3916
  return;
@@ -3730,24 +3918,20 @@ function createLandstripIntegration(options = {}) {
3730
3918
  if (timeoutHandle)
3731
3919
  clearTimeout(timeoutHandle);
3732
3920
  signal?.removeEventListener("abort", onAbort);
3733
- trapSocket.destroy();
3921
+ trapSocket?.destroy();
3734
3922
  await teardownResources();
3735
3923
  };
3924
+ const stdio = childEnd ? ["ignore", "pipe", "pipe", childEnd] : ["ignore", "pipe", "pipe"];
3736
3925
  const child = spawn2(binaryPath(), landstripArgs, {
3737
3926
  cwd,
3738
3927
  env: { PATH: process.env.PATH, HOME: process.env.HOME },
3739
3928
  detached: true,
3740
- stdio: ["ignore", "pipe", "pipe", childEnd]
3929
+ stdio
3741
3930
  });
3742
- childEnd.destroy();
3931
+ patchProcessGroupKill(child);
3932
+ childEnd?.destroy();
3743
3933
  function killChild() {
3744
- if (child.pid === undefined)
3745
- return;
3746
- try {
3747
- process.kill(-child.pid, "SIGKILL");
3748
- } catch {
3749
- child.kill("SIGKILL");
3750
- }
3934
+ child.kill("SIGKILL");
3751
3935
  }
3752
3936
  function onAbort() {
3753
3937
  killChild();
@@ -3824,7 +4008,7 @@ function createLandstripIntegration(options = {}) {
3824
4008
  let trapBuffer = "";
3825
4009
  let queryChain = Promise.resolve();
3826
4010
  const respondQuery = (queryId, action) => {
3827
- if (trapSocket.destroyed)
4011
+ if (!trapSocket || trapSocket.destroyed)
3828
4012
  return;
3829
4013
  trapSocket.write(controlResponseLine(queryId, action));
3830
4014
  };
@@ -3834,7 +4018,7 @@ function createLandstripIntegration(options = {}) {
3834
4018
  errorFdAcc += infoLine;
3835
4019
  callbacks.onErrorFd?.(Buffer.from(infoLine, "utf8"));
3836
4020
  };
3837
- trapSocket.on("data", (data) => {
4021
+ trapSocket?.on("data", (data) => {
3838
4022
  trapBuffer += trapDecoder.write(data);
3839
4023
  let nl = trapBuffer.indexOf(`
3840
4024
  `);
@@ -3848,7 +4032,7 @@ function createLandstripIntegration(options = {}) {
3848
4032
  const trap = parseTrapLine(line);
3849
4033
  if (trap && isQueryTrap(trap)) {
3850
4034
  queryChain = queryChain.then(async () => {
3851
- const result = await resolveTrapQuery(trap, ctx, cwd, allowances, callbacks.promptOnBlock === true, signal);
4035
+ const result = await resolveTrapQuery(trap, ctx, cwd, processAllowances, callbacks.promptOnBlock === true, signal);
3852
4036
  if (result.reason === "unprompted" || !isFilesystemTrap(trap) && result.reason === "rejected") {
3853
4037
  appendErrorLine(line);
3854
4038
  }
@@ -3924,7 +4108,7 @@ function createLandstripIntegration(options = {}) {
3924
4108
  ${globalPath}`, "warning");
3925
4109
  return null;
3926
4110
  }
3927
- const needsPrompt = operation === "read" ? !matchesPattern(blockedPath, getEffectiveAllowRead(config, allowances), ctx.cwd) : shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config, allowances), ctx.cwd);
4111
+ const needsPrompt = operation === "read" ? !matchesPattern(blockedPath, getEffectiveAllowRead(config, ctx.cwd, allowances), ctx.cwd) : shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config, allowances), ctx.cwd);
3928
4112
  if (needsPrompt) {
3929
4113
  const choice = operation === "read" ? await promptReadBlock(ctx, blockedPath, matchesPattern(blockedPath, config.filesystem.denyRead, ctx.cwd) ? "granting allowRead will override it" : undefined, signal) : await promptWriteBlock(ctx, blockedPath, signal);
3930
4114
  if (choice === "abort")
@@ -4010,6 +4194,16 @@ ${message}
4010
4194
  return;
4011
4195
  notify(ctx, 'Network sandbox allows all domains because network.allowedDomains contains "*".', "warning");
4012
4196
  }
4197
+ function warnIfWindowsSecurityDowngraded(ctx, config) {
4198
+ if (process.platform !== "win32" || config.windows.backend !== "appContainer")
4199
+ return;
4200
+ if (config.windows.appContainerMode === "standard") {
4201
+ notify(ctx, "Windows uses standard AppContainer mode, which is weaker than LPAC isolation.", "warning");
4202
+ }
4203
+ if (config.windows.allowLoopback) {
4204
+ notify(ctx, "Windows loopback access includes all local loopback services, not only the Landstrip proxy.", "warning");
4205
+ }
4206
+ }
4013
4207
  function enableStatus(ctx, config) {
4014
4208
  if (!hasTuiStatus(ctx))
4015
4209
  return;
@@ -4021,6 +4215,9 @@ ${message}
4021
4215
  if (config.network.allowNetwork) {
4022
4216
  networkLabel = "unrestricted";
4023
4217
  networkColor = "warning";
4218
+ } else if (process.platform === "win32" && config.windows.backend === "appContainer" && !config.windows.allowLoopback) {
4219
+ networkLabel = "blocked";
4220
+ networkColor = "accent";
4024
4221
  } else if (allowsAllDomains(config.network.allowedDomains)) {
4025
4222
  networkLabel = "any domain";
4026
4223
  networkColor = "warning";
@@ -4031,7 +4228,14 @@ ${message}
4031
4228
  const sep2 = theme.fg("dim", "·");
4032
4229
  const net = theme.fg(networkColor, networkLabel);
4033
4230
  const write = theme.fg("accent", `${config.filesystem.allowWrite.length} write paths`);
4034
- setTuiStatus(ctx, "sandbox", `${dot} ${label} ${sep2} ${net} ${sep2} ${write}`);
4231
+ const fields = [`${dot} ${label}`, net, write];
4232
+ if (process.platform === "win32") {
4233
+ const backend = config.windows.backend;
4234
+ const standard = backend === "appContainer" && config.windows.appContainerMode === "standard";
4235
+ const label2 = backend === "restrictedUser" ? "restricted user" : `${config.windows.appContainerMode} AppContainer`;
4236
+ fields.splice(1, 0, theme.fg(standard ? "warning" : "accent", label2));
4237
+ }
4238
+ setTuiStatus(ctx, "sandbox", fields.join(` ${sep2} `));
4035
4239
  }
4036
4240
  const headlessWarnings = new Set;
4037
4241
  function warnUnsandboxed(ctx, message, level) {
@@ -4061,6 +4265,7 @@ ${message}
4061
4265
  sandboxReady = true;
4062
4266
  setSandboxState("enabled", ctx);
4063
4267
  warnIfAllDomainsAllowed(ctx, config);
4268
+ warnIfWindowsSecurityDowngraded(ctx, config);
4064
4269
  enableStatus(ctx, config);
4065
4270
  return true;
4066
4271
  }
@@ -4191,7 +4396,7 @@ ${message}
4191
4396
  section("Config");
4192
4397
  item("project", text(projectPath));
4193
4398
  item("global", text(globalPath));
4194
- const networkMode = config.network.allowNetwork ? "unrestricted" : "proxied";
4399
+ const networkMode = config.network.allowNetwork ? "unrestricted" : process.platform === "win32" && config.windows.backend === "appContainer" && !config.windows.allowLoopback ? "blocked" : "proxied";
4195
4400
  section("Network", networkMode);
4196
4401
  item("allow network", boolValue(config.network.allowNetwork));
4197
4402
  item("allowed", listValue(config.network.allowedDomains));
@@ -4205,6 +4410,16 @@ ${message}
4205
4410
  item("allow read", listValue(config.filesystem.allowRead));
4206
4411
  item("allow write", listValue(config.filesystem.allowWrite));
4207
4412
  item("deny write", listValue(config.filesystem.denyWrite));
4413
+ if (process.platform === "win32") {
4414
+ section("Windows");
4415
+ const backend = config.windows.backend;
4416
+ item("backend", text(backend === "restrictedUser" ? "restricted user" : "AppContainer"));
4417
+ if (backend === "appContainer") {
4418
+ const mode = config.windows.appContainerMode;
4419
+ item("container", mode === "standard" ? theme.fg("warning", `${mode} (weaker)`) : text(mode));
4420
+ item("loopback", boolValue(config.windows.allowLoopback));
4421
+ }
4422
+ }
4208
4423
  if (sessionAllowedReadPaths.length > 0 || sessionAllowedWritePaths.length > 0) {
4209
4424
  section("Session grants");
4210
4425
  if (sessionAllowedReadPaths.length > 0) {
@@ -4479,6 +4694,8 @@ export {
4479
4694
  isQueryTrap,
4480
4695
  isPublicProxyAddress,
4481
4696
  formatLandstripTraps,
4697
+ extractNativeWriteDeniedPath,
4698
+ extractNativeDeniedPath,
4482
4699
  domainMatchesAny,
4483
4700
  pi_landstrip_default as default,
4484
4701
  createLandstripIntegration,
package/dist/sandbox.json CHANGED
@@ -20,5 +20,10 @@
20
20
  ".pi/sandbox.json",
21
21
  "~/.pi/agent/sandbox.json"
22
22
  ]
23
+ },
24
+ "windows": {
25
+ "backend": "appContainer",
26
+ "appContainerMode": "standard",
27
+ "allowLoopback": false
23
28
  }
24
29
  }
@@ -19,6 +19,23 @@
19
19
  "prompt": "Complete the delegated task autonomously. Return one concise result to the parent.",
20
20
  "permission": { "todowrite": "deny" }
21
21
  },
22
+ "scout": {
23
+ "description": "Fast codebase reconnaissance with concise handoff findings",
24
+ "mode": "subagent",
25
+ "prompt": "Scout the codebase without modifying it. Return concise findings with exact file references, key code, architecture, and a recommended starting point.",
26
+ "permission": {
27
+ "*": "deny",
28
+ "read": {
29
+ "*": "allow",
30
+ "**/*.env": "ask",
31
+ "**/*.env.*": "ask",
32
+ "**/*.env.example": "allow"
33
+ },
34
+ "glob": "allow",
35
+ "grep": "allow",
36
+ "list": "allow"
37
+ }
38
+ },
22
39
  "explore": {
23
40
  "description": "Fast read-focused agent for exploring codebases",
24
41
  "mode": "subagent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.17.34",
3
+ "version": "0.17.35",
4
4
  "description": "Sandbox-aware Pi subagents and interactive command permissions",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -30,7 +30,7 @@
30
30
  "build": "node -e \"const fs = require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.mkdirSync('dist'); for (const file of ['sandbox.json', 'subagents.json']) fs.copyFileSync(file, 'dist/' + file)\" && bun build index.ts --outfile dist/index.ts --target=node --packages=external",
31
31
  "prepack": "npm run build",
32
32
  "fmt": "oxfmt *.ts package.json tsconfig.json subagents.json sandbox.json .oxfmtrc.json README.md",
33
- "lint": "oxlint *.ts",
33
+ "lint": "oxlint .",
34
34
  "check": "tsc --noEmit",
35
35
  "test": "npm run build && vitest run",
36
36
  "all": "npm run fmt && npm run lint && npm run check && npm run test",
@@ -40,7 +40,7 @@
40
40
  "ci:test": "npm run test"
41
41
  },
42
42
  "dependencies": {
43
- "@landstrip/landstrip": "^0.17.34",
43
+ "@landstrip/landstrip": "^0.17.35",
44
44
  "minimatch": "^10.2.5"
45
45
  },
46
46
  "devDependencies": {
package/sandbox.json CHANGED
@@ -20,5 +20,10 @@
20
20
  ".pi/sandbox.json",
21
21
  "~/.pi/agent/sandbox.json"
22
22
  ]
23
+ },
24
+ "windows": {
25
+ "backend": "appContainer",
26
+ "appContainerMode": "standard",
27
+ "allowLoopback": false
23
28
  }
24
29
  }
package/subagents.json CHANGED
@@ -19,6 +19,23 @@
19
19
  "prompt": "Complete the delegated task autonomously. Return one concise result to the parent.",
20
20
  "permission": { "todowrite": "deny" }
21
21
  },
22
+ "scout": {
23
+ "description": "Fast codebase reconnaissance with concise handoff findings",
24
+ "mode": "subagent",
25
+ "prompt": "Scout the codebase without modifying it. Return concise findings with exact file references, key code, architecture, and a recommended starting point.",
26
+ "permission": {
27
+ "*": "deny",
28
+ "read": {
29
+ "*": "allow",
30
+ "**/*.env": "ask",
31
+ "**/*.env.*": "ask",
32
+ "**/*.env.example": "allow"
33
+ },
34
+ "glob": "allow",
35
+ "grep": "allow",
36
+ "list": "allow"
37
+ }
38
+ },
22
39
  "explore": {
23
40
  "description": "Fast read-focused agent for exploring codebases",
24
41
  "mode": "subagent",