opencode-landstrip 0.19.3 → 0.19.6

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.
Files changed (4) hide show
  1. package/README.md +1 -1
  2. package/index.ts +296 -387
  3. package/package.json +9 -8
  4. package/tui.ts +742 -730
package/tui.ts CHANGED
@@ -5,16 +5,12 @@ import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:n
5
5
  import { homedir } from 'node:os';
6
6
  import path from 'node:path';
7
7
 
8
- import type {
9
- TuiHostSlotMap,
10
- TuiPlugin,
11
- TuiSlotContext,
12
- TuiSlotPlugin,
13
- } from '@opencode-ai/plugin/tui';
8
+ import type { Plugin as TuiPlugin } from '@opencode/plugin/tui';
9
+ import type { ResolvedTheme } from '@opencode/theme/tui';
14
10
  import { RGBA } from '@opentui/core';
15
11
  import { Portal, useTerminalDimensions } from '@opentui/solid';
16
12
  import { Fragment, jsx, jsxs } from '@opentui/solid/jsx-runtime';
17
- import { createSignal, onCleanup, onMount } from 'solid-js';
13
+ import { createSignal, onMount } from 'solid-js';
18
14
 
19
15
  import {
20
16
  controlResponseLine,
@@ -118,742 +114,743 @@ function formatPath(input: string, base: string): string {
118
114
  return absolute;
119
115
  }
120
116
 
121
- const tui: TuiPlugin = async (api, options, meta) => {
122
- const optionOverrides = normalizeOptions(options);
123
- const enabledManagedByOptions = optionOverrides.enabled !== undefined;
124
-
125
- function LandstripPermissionPrompt(rawProps: Record<string, unknown>) {
126
- const props = rawProps as unknown as PermissionPromptProps<string>;
127
- const theme = api.theme.current;
128
- const dimensions = useTerminalDimensions();
129
- const [selected, setSelected] = createSignal(0);
130
- const [expanded, setExpanded] = createSignal(false);
131
- onMount(props.onShow);
117
+ function resolveThemeColors(theme: ResolvedTheme) {
118
+ return {
119
+ primary: theme.text.action.primary.base,
120
+ text: theme.text.base,
121
+ textMuted: theme.text.muted,
122
+ background: theme.background.base,
123
+ backgroundPanel: theme.background.raised.base,
124
+ backgroundMenu: theme.background.raised.high,
125
+ backgroundElement: theme.background.action.secondary.base,
126
+ border: theme.border.base,
127
+ warning: theme.text.feedback.warning.base,
128
+ warningRgba: theme.text.feedback.warning.base,
129
+ success: theme.text.feedback.success.base,
130
+ error: theme.text.feedback.error.base,
131
+ };
132
+ }
132
133
 
133
- function move(direction: number): void {
134
- setSelected((index) => (index + direction + props.options.length) % props.options.length);
135
- }
134
+ const tui: TuiPlugin.Definition = {
135
+ id: 'opencode-landstrip',
136
+ async setup(context: TuiPlugin.Context) {
137
+ const directory = context.location?.directory ?? process.cwd();
138
+ const optionOverrides = normalizeOptions(context.options);
139
+ const enabledManagedByOptions = optionOverrides.enabled !== undefined;
140
+
141
+ function LandstripPermissionPrompt(rawProps: Record<string, unknown>) {
142
+ const props = rawProps as unknown as PermissionPromptProps<string>;
143
+ const theme = resolveThemeColors(context.theme);
144
+ const dimensions = useTerminalDimensions();
145
+ const [selected, setSelected] = createSignal(0);
146
+ const [expanded, setExpanded] = createSignal(false);
147
+ onMount(props.onShow);
148
+
149
+ function move(direction: number): void {
150
+ setSelected((index) => (index + direction + props.options.length) % props.options.length);
151
+ }
136
152
 
137
- function submit(): void {
138
- const option = props.options[selected()];
139
- if (option) props.onSelect(option.value);
140
- }
153
+ function submit(): void {
154
+ const option = props.options[selected()];
155
+ if (option) props.onSelect(option.value);
156
+ }
141
157
 
142
- const unregister = api.keymap.registerLayer({
143
- priority: 1000,
144
- bindings: [
145
- { key: 'left', desc: 'Previous permission option', group: 'Sandbox', cmd: () => move(-1) },
146
- { key: 'right', desc: 'Next permission option', group: 'Sandbox', cmd: () => move(1) },
147
- { key: 'return', desc: 'Select permission option', group: 'Sandbox', cmd: submit },
148
- { key: 'escape', desc: 'Reject permission', group: 'Sandbox', cmd: props.onCancel },
149
- {
150
- key: 'ctrl+f',
151
- desc: 'Toggle permission prompt fullscreen',
152
- group: 'Sandbox',
153
- cmd: () => setExpanded((value) => !value),
154
- },
155
- ],
156
- });
157
- onCleanup(unregister);
158
-
159
- const optionButtons = props.options.map((option, index) =>
160
- jsx('box', {
161
- paddingLeft: 1,
162
- paddingRight: 1,
163
- flexShrink: 0,
164
- get backgroundColor() {
165
- return selected() === index ? theme.warning : theme.backgroundMenu;
166
- },
167
- onMouseOver: () => setSelected(index),
168
- onMouseUp: () => {
169
- setSelected(index);
170
- props.onSelect(option.value);
171
- },
172
- children: jsx('text', {
173
- get fg() {
174
- if (selected() !== index) return theme.textMuted;
175
- const background = theme.warning;
176
- const luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
177
- return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
158
+ context.keymap.layer(() => ({
159
+ priority: 1000,
160
+ commands: [
161
+ { id: 'landstrip-perm-prev', bind: 'left', run: () => move(-1) },
162
+ { id: 'landstrip-perm-next', bind: 'right', run: () => move(1) },
163
+ { id: 'landstrip-perm-submit', bind: 'return', run: submit },
164
+ { id: 'landstrip-perm-cancel', bind: 'escape', run: props.onCancel },
165
+ {
166
+ id: 'landstrip-perm-fullscreen',
167
+ bind: 'ctrl+f',
168
+ run: () => setExpanded((value) => !value),
178
169
  },
179
- children: option.label,
180
- }),
181
- }),
182
- );
183
-
184
- const children = [
185
- jsxs('box', {
186
- gap: 1,
187
- paddingLeft: 1,
188
- paddingRight: 3,
189
- paddingTop: 1,
190
- paddingBottom: 1,
191
- flexGrow: 1,
192
- children: [
193
- jsxs('box', {
194
- flexDirection: 'column',
195
- gap: 0,
196
- paddingLeft: 1,
197
- flexShrink: 0,
198
- children: [
199
- jsxs('box', {
200
- flexDirection: 'row',
201
- gap: 1,
202
- flexShrink: 0,
203
- children: [
204
- jsx('text', { fg: theme.warning, children: '△' }),
205
- jsx('text', { fg: theme.text, children: 'Permission required' }),
206
- ],
207
- }),
208
- jsxs('box', {
209
- flexDirection: 'row',
210
- gap: 1,
211
- paddingLeft: 2,
212
- flexShrink: 0,
213
- children: [
214
- jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
215
- jsx('text', { fg: theme.text, wrapMode: 'word', children: props.title }),
216
- ],
217
- }),
218
- ],
219
- }),
220
170
  ],
221
- }),
222
- jsxs('box', {
223
- get flexDirection() {
224
- return dimensions().width < 80 ? 'column' : 'row';
225
- },
226
- flexShrink: 0,
227
- gap: 1,
228
- paddingTop: 1,
229
- paddingLeft: 2,
230
- paddingRight: 3,
231
- paddingBottom: 1,
232
- backgroundColor: theme.backgroundElement,
233
- get justifyContent() {
234
- return dimensions().width < 80 ? 'flex-start' : 'space-between';
235
- },
236
- get alignItems() {
237
- return dimensions().width < 80 ? 'flex-start' : 'center';
238
- },
239
- children: [
240
- jsx('box', {
241
- flexDirection: 'row',
242
- gap: 1,
243
- flexShrink: 0,
244
- children: optionButtons,
245
- }),
246
- jsxs('box', {
247
- flexDirection: 'row',
248
- gap: 2,
249
- flexShrink: 0,
250
- children: [
251
- jsxs('text', {
252
- fg: theme.text,
253
- children: [
254
- 'ctrl+f ',
255
- jsx('span', {
256
- style: { fg: theme.textMuted },
257
- children: expanded() ? 'minimize' : 'fullscreen',
258
- }),
259
- ],
260
- }),
261
- jsxs('text', {
262
- fg: theme.text,
263
- children: [
264
- '⇆ ',
265
- jsx('span', { style: { fg: theme.textMuted }, children: 'select' }),
266
- ],
267
- }),
268
- jsxs('text', {
269
- fg: theme.text,
270
- children: [
271
- 'enter ',
272
- jsx('span', { style: { fg: theme.textMuted }, children: 'confirm' }),
273
- ],
274
- }),
275
- ],
171
+ }));
172
+
173
+ const optionButtons = props.options.map((option, index) =>
174
+ jsx('box', {
175
+ paddingLeft: 1,
176
+ paddingRight: 1,
177
+ flexShrink: 0,
178
+ get backgroundColor() {
179
+ return selected() === index ? theme.warning : theme.backgroundMenu;
180
+ },
181
+ onMouseOver: () => setSelected(index),
182
+ onMouseUp: () => {
183
+ setSelected(index);
184
+ props.onSelect(option.value);
185
+ },
186
+ children: jsx('text', {
187
+ get fg() {
188
+ if (selected() !== index) return theme.textMuted;
189
+ const background = theme.warningRgba;
190
+ const luminance =
191
+ 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
192
+ return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
193
+ },
194
+ children: option.label,
276
195
  }),
277
- ],
278
- }),
279
- ];
280
-
281
- if (expanded()) {
282
- return Portal({
283
- children: jsxs('box', {
284
- top: dimensions().height * -1 + 1,
285
- bottom: 1,
286
- left: 2,
287
- right: 2,
288
- position: 'absolute',
289
- border: ['left'],
290
- borderColor: theme.warning,
291
- customBorderChars: promptBorderChars,
292
- backgroundColor: theme.backgroundPanel,
293
- children,
294
196
  }),
197
+ );
198
+
199
+ const children = [
200
+ jsxs('box', {
201
+ gap: 1,
202
+ paddingLeft: 1,
203
+ paddingRight: 3,
204
+ paddingTop: 1,
205
+ paddingBottom: 1,
206
+ flexGrow: 1,
207
+ children: [
208
+ jsxs('box', {
209
+ flexDirection: 'column',
210
+ gap: 0,
211
+ paddingLeft: 1,
212
+ flexShrink: 0,
213
+ children: [
214
+ jsxs('box', {
215
+ flexDirection: 'row',
216
+ gap: 1,
217
+ flexShrink: 0,
218
+ children: [
219
+ jsx('text', { fg: theme.warning, children: '△' }),
220
+ jsx('text', { fg: theme.text, children: 'Permission required' }),
221
+ ],
222
+ }),
223
+ jsxs('box', {
224
+ flexDirection: 'row',
225
+ gap: 1,
226
+ paddingLeft: 2,
227
+ flexShrink: 0,
228
+ children: [
229
+ jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
230
+ jsx('text', { fg: theme.text, wrapMode: 'word', children: props.title }),
231
+ ],
232
+ }),
233
+ ],
234
+ }),
235
+ ],
236
+ }),
237
+ jsxs('box', {
238
+ get flexDirection() {
239
+ return dimensions().width < 80 ? 'column' : 'row';
240
+ },
241
+ flexShrink: 0,
242
+ gap: 1,
243
+ paddingTop: 1,
244
+ paddingLeft: 2,
245
+ paddingRight: 3,
246
+ paddingBottom: 1,
247
+ backgroundColor: theme.backgroundElement,
248
+ get justifyContent() {
249
+ return dimensions().width < 80 ? 'flex-start' : 'space-between';
250
+ },
251
+ get alignItems() {
252
+ return dimensions().width < 80 ? 'flex-start' : 'center';
253
+ },
254
+ children: [
255
+ jsx('box', {
256
+ flexDirection: 'row',
257
+ gap: 1,
258
+ flexShrink: 0,
259
+ children: optionButtons,
260
+ }),
261
+ jsxs('box', {
262
+ flexDirection: 'row',
263
+ gap: 2,
264
+ flexShrink: 0,
265
+ children: [
266
+ jsxs('text', {
267
+ fg: theme.text,
268
+ children: [
269
+ 'ctrl+f ',
270
+ jsx('span', {
271
+ style: { fg: theme.textMuted },
272
+ children: expanded() ? 'minimize' : 'fullscreen',
273
+ }),
274
+ ],
275
+ }),
276
+ jsxs('text', {
277
+ fg: theme.text,
278
+ children: [
279
+ '⇆ ',
280
+ jsx('span', { style: { fg: theme.textMuted }, children: 'select' }),
281
+ ],
282
+ }),
283
+ jsxs('text', {
284
+ fg: theme.text,
285
+ children: [
286
+ 'enter ',
287
+ jsx('span', { style: { fg: theme.textMuted }, children: 'confirm' }),
288
+ ],
289
+ }),
290
+ ],
291
+ }),
292
+ ],
293
+ }),
294
+ ];
295
+
296
+ if (expanded()) {
297
+ return Portal({
298
+ children: jsxs('box', {
299
+ top: dimensions().height * -1 + 1,
300
+ bottom: 1,
301
+ left: 2,
302
+ right: 2,
303
+ position: 'absolute',
304
+ border: ['left'],
305
+ borderColor: theme.warning,
306
+ customBorderChars: promptBorderChars,
307
+ backgroundColor: theme.backgroundPanel,
308
+ children,
309
+ }),
310
+ });
311
+ }
312
+ return jsxs('box', {
313
+ top: 0,
314
+ maxHeight: 15,
315
+ bottom: 0,
316
+ left: 0,
317
+ right: 0,
318
+ position: 'relative',
319
+ border: ['left'],
320
+ borderColor: theme.warning,
321
+ customBorderChars: promptBorderChars,
322
+ backgroundColor: theme.backgroundPanel,
323
+ children,
295
324
  });
296
325
  }
297
- return jsxs('box', {
298
- top: 0,
299
- maxHeight: 15,
300
- bottom: 0,
301
- left: 0,
302
- right: 0,
303
- position: 'relative',
304
- border: ['left'],
305
- borderColor: theme.warning,
306
- customBorderChars: promptBorderChars,
307
- backgroundColor: theme.backgroundPanel,
308
- children,
309
- });
310
- }
311
-
312
- const resolved = new Set<string>();
313
- const queue: QueueEntry[] = [];
314
- const [activeEntry, setActiveEntry] = createSignal<QueueEntry>();
315
- const [permissionRevision, setPermissionRevision] = createSignal(0);
316
- let activeId: string | undefined;
317
- let refreshSandboxStatus: (() => void) | undefined;
318
-
319
- const sessionAllowances = new Map<string, SessionAllowances>();
320
- const notified = new Set<string>();
321
-
322
- // Filesystem and network queries still awaiting a response, so cleanup can
323
- // release held syscalls instead of letting the child hang.
324
- const liveQueries = new Set<FsQueryEntry | NetworkQueryEntry>();
325
-
326
- function nativePermissionPending(entry: QueueEntry): boolean {
327
- if (api.state.session.permission(entry.sessionID).length > 0) return true;
328
- return (
329
- entry.sourceSessionID !== entry.sessionID &&
330
- api.state.session.permission(entry.sourceSessionID).length > 0
331
- );
332
- }
333
-
334
- function pump(): void {
335
- const route = api.route.current;
336
- if (route.name !== 'session' || !route.params) return;
337
- const routeSessionID =
338
- typeof route.params.sessionID === 'string' ? route.params.sessionID : undefined;
339
- if (!routeSessionID) return;
340
-
341
- const active = activeEntry();
342
- if (activeId !== undefined) {
343
- if (active?.sessionID === routeSessionID) return;
344
- if (active && !resolved.has(active.id)) queue.unshift(active);
345
- activeId = undefined;
346
- setActiveEntry(undefined);
326
+
327
+ const resolved = new Set<string>();
328
+ const queue: QueueEntry[] = [];
329
+ const [activeEntry, setActiveEntry] = createSignal<QueueEntry>();
330
+ const [permissionRevision, setPermissionRevision] = createSignal(0);
331
+ let activeId: string | undefined;
332
+ let refreshSandboxStatus: (() => void) | undefined;
333
+
334
+ const sessionAllowances = new Map<string, SessionAllowances>();
335
+ const notified = new Set<string>();
336
+
337
+ // Filesystem and network queries still awaiting a response, so cleanup can
338
+ // release held syscalls instead of letting the child hang.
339
+ const liveQueries = new Set<FsQueryEntry | NetworkQueryEntry>();
340
+
341
+ function nativePermissionPending(entry: QueueEntry): boolean {
342
+ if ((context.data.session.permission.list(entry.sessionID)?.length ?? 0) > 0) return true;
343
+ return (
344
+ entry.sourceSessionID !== entry.sessionID &&
345
+ (context.data.session.permission.list(entry.sourceSessionID)?.length ?? 0) > 0
346
+ );
347
347
  }
348
348
 
349
- for (let index = queue.length - 1; index >= 0; index -= 1) {
350
- const entry = queue[index];
351
- if (entry && resolved.has(entry.id)) queue.splice(index, 1);
349
+ function pump(): void {
350
+ const route = context.ui.router.current();
351
+ if (route.type !== 'session' || !route.sessionID) return;
352
+ const routeSessionID = route.sessionID;
353
+
354
+ const active = activeEntry();
355
+ if (activeId !== undefined) {
356
+ if (active?.sessionID === routeSessionID) return;
357
+ if (active && !resolved.has(active.id)) queue.unshift(active);
358
+ activeId = undefined;
359
+ setActiveEntry(undefined);
360
+ }
361
+
362
+ for (let index = queue.length - 1; index >= 0; index -= 1) {
363
+ const entry = queue[index];
364
+ if (entry && resolved.has(entry.id)) queue.splice(index, 1);
365
+ }
366
+
367
+ const index = nextSandboxPermissionIndex(queue, routeSessionID, nativePermissionPending);
368
+ if (index === -1) return;
369
+ const [next] = queue.splice(index, 1);
370
+ if (!next) return;
371
+ activeId = next.id;
372
+ setActiveEntry(next);
352
373
  }
353
374
 
354
- const index = nextSandboxPermissionIndex(queue, routeSessionID, nativePermissionPending);
355
- if (index === -1) return;
356
- const [next] = queue.splice(index, 1);
357
- if (!next) return;
358
- activeId = next.id;
359
- setActiveEntry(next);
360
- }
361
-
362
- function enqueueEntry(entry: QueueEntry): void {
363
- if (!entry.id || resolved.has(entry.id)) return;
364
- if (activeId === entry.id) return;
365
- if (queue.some((item) => item.id === entry.id)) return;
366
- queue.push(entry);
367
- pump();
368
- }
369
-
370
- function finishActive(id: string): void {
371
- resolved.add(id);
372
- notified.delete(id);
373
- if (activeId === id) {
374
- activeId = undefined;
375
- setActiveEntry(undefined);
375
+ function enqueueEntry(entry: QueueEntry): void {
376
+ if (!entry.id || resolved.has(entry.id)) return;
377
+ if (activeId === entry.id) return;
378
+ if (queue.some((item) => item.id === entry.id)) return;
379
+ queue.push(entry);
380
+ pump();
376
381
  }
377
- queueMicrotask(pump);
378
- }
379
-
380
- function notifyQuery(entry: QueueEntry): void {
381
- if (notified.has(entry.id)) return;
382
- notified.add(entry.id);
383
- void api.attention
384
- .notify({
385
- title: `Sandbox ${entry.operation} blocked`,
386
- message: entry.kind === 'fs-query' ? entry.path : entry.target,
387
- sound: { name: 'permission' },
388
- notification: true,
389
- })
390
- .catch(() => undefined);
391
- }
392
-
393
- function renderSessionPrompt(props: TuiHostSlotMap['session_prompt']) {
394
- return jsx(Fragment, {
395
- get children() {
396
- permissionRevision();
397
- queueMicrotask(pump);
398
- const entry = activeEntry();
399
- const hostPromptActive =
400
- props.visible === false ||
401
- Boolean(props.disabled) ||
402
- Boolean(entry && nativePermissionPending(entry));
403
- const showEntry = Boolean(
404
- entry &&
405
- shouldRenderSandboxPermission(entry.sessionID, props.session_id, hostPromptActive),
406
- );
407
-
408
- if (showEntry && entry?.kind === 'fs-query') {
409
- const verb = entry.operation === 'read' ? 'Read' : 'Write';
410
- return jsx(LandstripPermissionPrompt, {
411
- icon: '→',
412
- title: `${verb} ${formatPath(entry.path, entry.directory)}`,
413
- options: [
414
- { label: 'Allow once', value: 'once' },
415
- { label: 'Allow for session', value: 'session' },
416
- { label: 'Allow for project', value: 'project' },
417
- { label: 'Allow globally', value: 'global' },
418
- { label: 'Deny', value: 'deny' },
419
- ],
420
- onSelect: (choice: QueryChoice) => resolveFsQuery(entry, choice),
421
- onCancel: () => resolveFsQuery(entry, 'deny'),
422
- onShow: () => notifyQuery(entry),
423
- });
424
- }
425
- if (showEntry && entry?.kind === 'net-query') {
426
- const verb = entry.operation
427
- ? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
428
- : 'Network';
429
- return jsx(LandstripPermissionPrompt, {
430
- icon: '%',
431
- title: `${verb} ${entry.target}`,
432
- options: [
433
- { label: 'Allow once', value: 'once' },
434
- { label: 'Allow for session', value: 'session' },
435
- { label: 'Deny', value: 'deny' },
436
- ],
437
- onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
438
- onCancel: () => resolveNetworkQuery(entry, 'deny'),
439
- onShow: () => notifyQuery(entry),
440
- });
441
- }
442
- return api.ui.Prompt({
443
- sessionID: props.session_id,
444
- ...(props.visible === undefined ? {} : { visible: props.visible }),
445
- ...(props.disabled === undefined ? {} : { disabled: props.disabled }),
446
- ...(props.on_submit === undefined ? {} : { onSubmit: props.on_submit }),
447
- ...(props.ref === undefined ? {} : { ref: props.ref }),
448
- right: api.ui.Slot({
449
- name: 'session_prompt_right',
450
- session_id: props.session_id,
451
- }),
452
- });
453
- },
454
- });
455
- }
456
-
457
- function respondQuery(socket: NetSocket, queryId: string, action: 'allow' | 'deny'): void {
458
- if (!socket.destroyed) socket.write(controlResponseLine(queryId, action));
459
- }
460
-
461
- function resolveFsQuery(entry: FsQueryEntry, choice: QueryChoice): void {
462
- if (resolved.has(entry.id)) return;
463
- const action = choice === 'deny' ? 'deny' : 'allow';
464
- const verb = entry.operation === 'read' ? 'Read' : 'Write';
465
- const directory = entry.directory;
466
- const scope = sessionScopeFor(entry.path, directory);
467
- const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
468
- const sessionPaths = entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
469
-
470
- let responseAction: 'allow' | 'deny' = action;
471
- try {
472
- if (action === 'allow') {
473
- if (choice === 'project' || choice === 'global') {
474
- const { globalPath, projectPath } = getConfigPaths(directory);
475
- const update = updateForPermission({
476
- permission: entry.operation,
477
- metadata: { filepath: scope },
478
- });
479
- if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
480
- }
481
- if (choice !== 'once') sessionPaths.add(scope);
382
+
383
+ function finishActive(id: string): void {
384
+ resolved.add(id);
385
+ notified.delete(id);
386
+ if (activeId === id) {
387
+ activeId = undefined;
388
+ setActiveEntry(undefined);
482
389
  }
483
- } catch {
484
- responseAction = 'deny';
390
+ queueMicrotask(pump);
485
391
  }
486
392
 
487
- try {
488
- respondQuery(entry.socket, entry.queryId, responseAction);
489
- api.ui.toast({
490
- title: 'Sandbox',
491
- message:
492
- responseAction === 'deny'
493
- ? `${verb} denied: ${entry.path}`
494
- : `${verb} allowed (${choice}) under ${scope}`,
495
- variant: responseAction === 'deny' ? 'warning' : 'success',
393
+ function notifyQuery(entry: QueueEntry): void {
394
+ if (notified.has(entry.id)) return;
395
+ notified.add(entry.id);
396
+ void context.attention
397
+ .notify({
398
+ title: `Sandbox ${entry.operation} blocked`,
399
+ message: entry.kind === 'fs-query' ? entry.path : entry.target,
400
+ sound: { name: 'permission' },
401
+ notification: true,
402
+ })
403
+ .catch(() => undefined);
404
+ }
405
+
406
+ function renderSessionPrompt(props: { sessionID: string }) {
407
+ return jsx(Fragment, {
408
+ get children() {
409
+ permissionRevision();
410
+ queueMicrotask(pump);
411
+ const entry = activeEntry();
412
+ const hostPromptActive = Boolean(entry && nativePermissionPending(entry));
413
+ const showEntry = Boolean(
414
+ entry &&
415
+ shouldRenderSandboxPermission(entry.sessionID, props.sessionID, hostPromptActive),
416
+ );
417
+ if (showEntry && entry?.kind === 'fs-query') {
418
+ const verb = entry.operation === 'read' ? 'Read' : 'Write';
419
+ return jsx(LandstripPermissionPrompt, {
420
+ icon: '→',
421
+ title: `${verb} ${formatPath(entry.path, entry.directory)}`,
422
+ options: [
423
+ { label: 'Allow once', value: 'once' },
424
+ { label: 'Allow for session', value: 'session' },
425
+ { label: 'Allow for project', value: 'project' },
426
+ { label: 'Allow globally', value: 'global' },
427
+ { label: 'Deny', value: 'deny' },
428
+ ],
429
+ onSelect: (choice: QueryChoice) => resolveFsQuery(entry, choice),
430
+ onCancel: () => resolveFsQuery(entry, 'deny'),
431
+ onShow: () => notifyQuery(entry),
432
+ });
433
+ }
434
+ if (showEntry && entry?.kind === 'net-query') {
435
+ const verb = entry.operation
436
+ ? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
437
+ : 'Network';
438
+ return jsx(LandstripPermissionPrompt, {
439
+ icon: '%',
440
+ title: `${verb} ${entry.target}`,
441
+ options: [
442
+ { label: 'Allow once', value: 'once' },
443
+ { label: 'Allow for session', value: 'session' },
444
+ { label: 'Deny', value: 'deny' },
445
+ ],
446
+ onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
447
+ onCancel: () => resolveNetworkQuery(entry, 'deny'),
448
+ onShow: () => notifyQuery(entry),
449
+ });
450
+ }
451
+ return null;
452
+ },
496
453
  });
497
- } finally {
498
- liveQueries.delete(entry);
499
- finishActive(entry.id);
500
454
  }
501
- }
502
455
 
503
- function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
504
- if (resolved.has(entry.id)) return;
505
- const action = choice === 'deny' ? 'deny' : 'allow';
456
+ function respondQuery(socket: NetSocket, queryId: string, action: 'allow' | 'deny'): void {
457
+ if (!socket.destroyed) socket.write(controlResponseLine(queryId, action));
458
+ }
506
459
 
507
- try {
508
- if (action === 'allow' && choice === 'session') {
509
- sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
460
+ function resolveFsQuery(entry: FsQueryEntry, choice: QueryChoice): void {
461
+ if (resolved.has(entry.id)) return;
462
+ const action = choice === 'deny' ? 'deny' : 'allow';
463
+ const verb = entry.operation === 'read' ? 'Read' : 'Write';
464
+ const directory = entry.directory;
465
+ const scope = sessionScopeFor(entry.path, directory);
466
+ const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
467
+ const sessionPaths =
468
+ entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
469
+
470
+ let responseAction: 'allow' | 'deny' = action;
471
+ try {
472
+ if (action === 'allow') {
473
+ if (choice === 'project' || choice === 'global') {
474
+ const { globalPath, projectPath } = getConfigPaths(directory);
475
+ const update = updateForPermission({
476
+ permission: entry.operation,
477
+ metadata: { filepath: scope },
478
+ });
479
+ if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
480
+ }
481
+ if (choice !== 'once') sessionPaths.add(scope);
482
+ }
483
+ } catch {
484
+ responseAction = 'deny';
510
485
  }
511
486
 
512
- respondQuery(entry.socket, entry.queryId, action);
513
- api.ui.toast({
514
- title: 'Sandbox',
515
- message:
516
- action === 'deny'
517
- ? `${entry.operation} denied: ${entry.target}`
518
- : `${entry.operation} allowed (${choice}): ${entry.target}`,
519
- variant: action === 'deny' ? 'warning' : 'success',
520
- });
521
- } finally {
522
- liveQueries.delete(entry);
523
- finishActive(entry.id);
487
+ try {
488
+ respondQuery(entry.socket, entry.queryId, responseAction);
489
+ context.ui.toast.show({
490
+ title: 'Sandbox',
491
+ message:
492
+ responseAction === 'deny'
493
+ ? `${verb} denied: ${entry.path}`
494
+ : `${verb} allowed (${choice}) under ${scope}`,
495
+ variant: responseAction === 'deny' ? 'warning' : 'success',
496
+ });
497
+ } finally {
498
+ liveQueries.delete(entry);
499
+ finishActive(entry.id);
500
+ }
524
501
  }
525
- }
526
-
527
- // Query-response socket server (Linux-only — landstrip's socket protocol lives
528
- // in the seccomp broker). The server plugin connects each sandboxed run's
529
- // fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
530
- const sockets = new Set<NetSocket>();
531
- let socketServer: ReturnType<typeof createServer> | undefined;
532
-
533
- if (process.platform === 'linux') {
534
- const baseDirectory = api.state.path.directory || process.cwd();
535
- let socketSeq = 0;
536
-
537
- socketServer = createServer((socket) => {
538
- sockets.add(socket);
539
- socket.setEncoding('utf-8');
540
- const socketId = ++socketSeq;
541
- const seen = new Set<string>();
542
- let buffer = '';
543
- let sourceSessionID: string | undefined;
544
- let routeSessionID: string | undefined;
545
- let sessionDirectory: string | undefined;
546
-
547
- socket.on('data', (chunk: string | Buffer) => {
548
- buffer += chunk.toString();
549
- if (buffer.length > 1024 * 1024) {
550
- socket.destroy();
551
- return;
502
+
503
+ function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
504
+ if (resolved.has(entry.id)) return;
505
+ const action = choice === 'deny' ? 'deny' : 'allow';
506
+
507
+ try {
508
+ if (action === 'allow' && choice === 'session') {
509
+ sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
552
510
  }
553
511
 
554
- let newline: number;
555
- while ((newline = buffer.indexOf('\n')) !== -1) {
556
- const line = buffer.slice(0, newline);
557
- buffer = buffer.slice(newline + 1);
558
-
559
- if (!sourceSessionID || !routeSessionID || !sessionDirectory) {
560
- const hello = decodeTrapSessionHello(line);
561
- const sourceSession = hello ? api.state.session.get(hello.sessionID) : undefined;
562
- const rootSession = hello
563
- ? rootSessionIDFor(hello.sessionID, (sessionID) => api.state.session.get(sessionID))
564
- : undefined;
565
- if (!hello || !sourceSession || !rootSession) {
566
- socket.destroy();
567
- return;
568
- }
569
- sourceSessionID = hello.sessionID;
570
- routeSessionID = rootSession;
571
- sessionDirectory = sourceSession.directory || baseDirectory;
572
- continue;
573
- }
512
+ respondQuery(entry.socket, entry.queryId, action);
513
+ context.ui.toast.show({
514
+ title: 'Sandbox',
515
+ message:
516
+ action === 'deny'
517
+ ? `${entry.operation} denied: ${entry.target}`
518
+ : `${entry.operation} allowed (${choice}): ${entry.target}`,
519
+ variant: action === 'deny' ? 'warning' : 'success',
520
+ });
521
+ } finally {
522
+ liveQueries.delete(entry);
523
+ finishActive(entry.id);
524
+ }
525
+ }
574
526
 
575
- const allowances = sessionAllowancesFor(sessionAllowances, routeSessionID);
576
- for (const trap of parseLandstripTraps(line)) {
577
- if (trap.kind !== 'filesystem' && trap.kind !== 'network') continue;
578
- if (trap.state !== 'query') continue;
579
- if (seen.has(trap.query_id)) continue;
580
- seen.add(trap.query_id);
527
+ // Query-response socket server (Linux-only — landstrip's socket protocol lives
528
+ // in the seccomp broker). The server plugin connects each sandboxed run's
529
+ // fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
530
+ const sockets = new Set<NetSocket>();
531
+ let socketServer: ReturnType<typeof createServer> | undefined;
532
+
533
+ if (process.platform === 'linux') {
534
+ const baseDirectory = directory;
535
+ let socketSeq = 0;
536
+
537
+ socketServer = createServer((socket) => {
538
+ sockets.add(socket);
539
+ socket.setEncoding('utf-8');
540
+ const socketId = ++socketSeq;
541
+ const seen = new Set<string>();
542
+ let buffer = '';
543
+ let sourceSessionID: string | undefined;
544
+ let routeSessionID: string | undefined;
545
+ let sessionDirectory: string | undefined;
546
+
547
+ socket.on('data', (chunk: string | Buffer) => {
548
+ buffer += chunk.toString();
549
+ if (buffer.length > 1024 * 1024) {
550
+ socket.destroy();
551
+ return;
552
+ }
581
553
 
582
- if (!loadConfig(sessionDirectory, optionOverrides).enabled) {
583
- respondQuery(socket, trap.query_id, 'allow');
554
+ let newline: number;
555
+ while ((newline = buffer.indexOf('\n')) !== -1) {
556
+ const line = buffer.slice(0, newline);
557
+ buffer = buffer.slice(newline + 1);
558
+
559
+ if (!sourceSessionID || !routeSessionID || !sessionDirectory) {
560
+ const hello = decodeTrapSessionHello(line);
561
+ const sourceSession = hello ? context.data.session.get(hello.sessionID) : undefined;
562
+ const rootSession = hello
563
+ ? rootSessionIDFor(hello.sessionID, (sessionID) =>
564
+ context.data.session.get(sessionID),
565
+ )
566
+ : undefined;
567
+ if (!hello || !sourceSession || !rootSession) {
568
+ socket.destroy();
569
+ return;
570
+ }
571
+ sourceSessionID = hello.sessionID;
572
+ routeSessionID = rootSession;
573
+ sessionDirectory = sourceSession.location.directory || directory;
584
574
  continue;
585
575
  }
586
576
 
587
- if (trap.kind === 'filesystem') {
588
- if (trap.operation === 'write' && trap.reason === 'deny_match') {
589
- respondQuery(socket, trap.query_id, 'deny');
590
- continue;
591
- }
592
- const sessionPaths =
593
- trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
594
- if (sessionAllows(sessionPaths, trap.path)) {
595
- respondQuery(socket, trap.query_id, 'allow');
596
- continue;
597
- }
577
+ const allowances = sessionAllowancesFor(sessionAllowances, routeSessionID);
578
+ for (const trap of parseLandstripTraps(line)) {
579
+ if (trap.kind !== 'filesystem' && trap.kind !== 'network') continue;
580
+ if (trap.state !== 'query') continue;
581
+ if (seen.has(trap.query_id)) continue;
582
+ seen.add(trap.query_id);
598
583
 
599
- const entry: FsQueryEntry = {
600
- kind: 'fs-query',
601
- id: `landstrip-${trap.operation}:${socketId}:${trap.query_id}`,
602
- socket,
603
- queryId: trap.query_id,
604
- operation: trap.operation,
605
- path: trap.path,
606
- sessionID: routeSessionID,
607
- sourceSessionID,
608
- directory: sessionDirectory,
609
- };
610
- liveQueries.add(entry);
611
- enqueueEntry(entry);
612
- } else {
613
- if (allowances.targets.has(trap.target)) {
584
+ if (!loadConfig(sessionDirectory, optionOverrides).enabled) {
614
585
  respondQuery(socket, trap.query_id, 'allow');
615
586
  continue;
616
587
  }
617
588
 
618
- const entry: NetworkQueryEntry = {
619
- kind: 'net-query',
620
- id: `landstrip-net:${socketId}:${trap.query_id}`,
621
- socket,
622
- queryId: trap.query_id,
623
- operation: trap.operation,
624
- target: trap.target,
625
- sessionID: routeSessionID,
626
- sourceSessionID,
627
- directory: sessionDirectory,
628
- };
629
- liveQueries.add(entry);
630
- enqueueEntry(entry);
589
+ if (trap.kind === 'filesystem') {
590
+ if (trap.operation === 'write' && trap.reason === 'deny_match') {
591
+ respondQuery(socket, trap.query_id, 'deny');
592
+ continue;
593
+ }
594
+ const sessionPaths =
595
+ trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
596
+ if (sessionAllows(sessionPaths, trap.path)) {
597
+ respondQuery(socket, trap.query_id, 'allow');
598
+ continue;
599
+ }
600
+
601
+ const entry: FsQueryEntry = {
602
+ kind: 'fs-query',
603
+ id: `landstrip-${trap.operation}:${socketId}:${trap.query_id}`,
604
+ socket,
605
+ queryId: trap.query_id,
606
+ operation: trap.operation,
607
+ path: trap.path,
608
+ sessionID: routeSessionID,
609
+ sourceSessionID,
610
+ directory: sessionDirectory,
611
+ };
612
+ liveQueries.add(entry);
613
+ enqueueEntry(entry);
614
+ } else {
615
+ if (allowances.targets.has(trap.target)) {
616
+ respondQuery(socket, trap.query_id, 'allow');
617
+ continue;
618
+ }
619
+
620
+ const entry: NetworkQueryEntry = {
621
+ kind: 'net-query',
622
+ id: `landstrip-net:${socketId}:${trap.query_id}`,
623
+ socket,
624
+ queryId: trap.query_id,
625
+ operation: trap.operation,
626
+ target: trap.target,
627
+ sessionID: routeSessionID,
628
+ sourceSessionID,
629
+ directory: sessionDirectory,
630
+ };
631
+ liveQueries.add(entry);
632
+ enqueueEntry(entry);
633
+ }
631
634
  }
632
635
  }
636
+ });
637
+
638
+ const cleanup = () => {
639
+ sockets.delete(socket);
640
+ // The child is gone; drop our holds for this socket so the queue moves on.
641
+ // Deleting the current entry mid-iteration is well-defined for a Set.
642
+ for (const entry of liveQueries) {
643
+ if (entry.socket !== socket) continue;
644
+ liveQueries.delete(entry);
645
+ finishActive(entry.id);
646
+ }
647
+ };
648
+ socket.on('error', cleanup);
649
+ socket.on('close', cleanup);
650
+ });
651
+
652
+ socketServer.on('error', () => {
653
+ try {
654
+ removeDiscoveryFile(baseDirectory);
655
+ } catch {
656
+ // best effort
633
657
  }
634
658
  });
635
659
 
636
- const cleanup = () => {
637
- sockets.delete(socket);
638
- // The child is gone; drop our holds for this socket so the queue moves on.
639
- // Deleting the current entry mid-iteration is well-defined for a Set.
640
- for (const entry of liveQueries) {
641
- if (entry.socket !== socket) continue;
642
- liveQueries.delete(entry);
643
- finishActive(entry.id);
660
+ socketServer.listen(0, '127.0.0.1', () => {
661
+ const address = socketServer?.address() as AddressInfo | null;
662
+ if (address && typeof address === 'object') {
663
+ try {
664
+ writeDiscoveryPort(baseDirectory, address.port);
665
+ } catch {
666
+ // best effort — falls back to the server's reset model
667
+ }
644
668
  }
645
- };
646
- socket.on('error', cleanup);
647
- socket.on('close', cleanup);
648
- });
669
+ });
670
+ }
649
671
 
650
- socketServer.on('error', () => {
651
- try {
652
- removeDiscoveryFile(baseDirectory);
653
- } catch {
654
- // best effort
655
- }
656
- });
672
+ const refreshPermissionPresentation = (): void => {
673
+ queueMicrotask(() => {
674
+ setPermissionRevision((revision) => revision + 1);
675
+ pump();
676
+ });
677
+ };
678
+ const unregisterPermissionAsked = context.data.on(
679
+ 'permission.asked',
680
+ refreshPermissionPresentation,
681
+ );
682
+ const unregisterPermissionReplied = context.data.on(
683
+ 'permission.replied',
684
+ refreshPermissionPresentation,
685
+ );
657
686
 
658
- socketServer.listen(0, '127.0.0.1', () => {
659
- const address = socketServer?.address() as AddressInfo | null;
660
- if (address && typeof address === 'object') {
661
- try {
662
- writeDiscoveryPort(baseDirectory, address.port);
663
- } catch {
664
- // best effort — falls back to the server's reset model
665
- }
687
+ const [landstripOpen, setLandstripOpen] = createSignal(false);
688
+ const [confirmingDisable, setConfirmingDisable] = createSignal(false);
689
+ const [sandboxRevision, setSandboxRevision] = createSignal(0);
690
+ let popLandstripMode: (() => void) | undefined;
691
+
692
+ const closeLandstrip = () => {
693
+ popLandstripMode?.();
694
+ popLandstripMode = undefined;
695
+ setConfirmingDisable(false);
696
+ setLandstripOpen(false);
697
+ };
698
+ const toggleLandstrip = () => {
699
+ if (enabledManagedByOptions) {
700
+ context.ui.toast.show({
701
+ title: 'Landstrip',
702
+ message: 'Sandbox state is managed by plugin options',
703
+ variant: 'warning',
704
+ });
705
+ return;
666
706
  }
667
- });
668
- }
669
707
 
670
- const refreshPermissionPresentation = (): void => {
671
- queueMicrotask(() => {
672
- setPermissionRevision((revision) => revision + 1);
673
- pump();
674
- });
675
- };
676
- const unregisterPermissionAsked = api.event.on('permission.asked', refreshPermissionPresentation);
677
- const unregisterPermissionReplied = api.event.on(
678
- 'permission.replied',
679
- refreshPermissionPresentation,
680
- );
681
-
682
- const [landstripOpen, setLandstripOpen] = createSignal(false);
683
- const [confirmingDisable, setConfirmingDisable] = createSignal(false);
684
- const [sandboxRevision, setSandboxRevision] = createSignal(0);
685
- let unregisterLandstripKeys: (() => void) | undefined;
686
- let popLandstripMode: (() => void) | undefined;
687
-
688
- const closeLandstrip = () => {
689
- unregisterLandstripKeys?.();
690
- popLandstripMode?.();
691
- unregisterLandstripKeys = undefined;
692
- popLandstripMode = undefined;
693
- setConfirmingDisable(false);
694
- setLandstripOpen(false);
695
- };
696
- const toggleLandstrip = () => {
697
- if (enabledManagedByOptions) {
698
- api.ui.toast({
708
+ const config = loadConfig(directory, optionOverrides);
709
+ if (config.enabled && !confirmingDisable()) {
710
+ setConfirmingDisable(true);
711
+ return;
712
+ }
713
+ setConfirmingDisable(false);
714
+ const enabled = !config.enabled;
715
+ const scope = setSandboxConfigEnabled(directory, enabled, optionOverrides);
716
+ refreshSandboxStatus?.();
717
+ context.ui.toast.show({
699
718
  title: 'Landstrip',
700
- message: 'Sandbox state is managed by plugin options',
701
- variant: 'warning',
719
+ message: `Sandbox ${enabled ? 'enabled' : 'disabled'} (${scope} config)`,
720
+ variant: enabled ? 'success' : 'warning',
702
721
  });
703
- return;
704
- }
722
+ };
723
+ const cancelLandstrip = () => {
724
+ if (confirmingDisable()) setConfirmingDisable(false);
725
+ else closeLandstrip();
726
+ };
705
727
 
706
- const directory = api.state.path.directory || process.cwd();
707
- const config = loadConfig(directory, optionOverrides);
708
- if (config.enabled && !confirmingDisable()) {
709
- setConfirmingDisable(true);
710
- return;
711
- }
712
- setConfirmingDisable(false);
713
- const enabled = !config.enabled;
714
- const scope = setSandboxConfigEnabled(directory, enabled, optionOverrides);
715
- refreshSandboxStatus?.();
716
- api.ui.toast({
717
- title: 'Landstrip',
718
- message: `Sandbox ${enabled ? 'enabled' : 'disabled'} (${scope} config)`,
719
- variant: enabled ? 'success' : 'warning',
720
- });
721
- };
722
- const cancelLandstrip = () => {
723
- if (confirmingDisable()) setConfirmingDisable(false);
724
- else closeLandstrip();
725
- };
728
+ function LandstripPane() {
729
+ const theme = resolveThemeColors(context.theme);
730
+ const dimensions = useTerminalDimensions();
726
731
 
727
- function LandstripPane() {
728
- const theme = api.theme.current;
729
- const dimensions = useTerminalDimensions();
730
-
731
- const row = (label: string, value: string) =>
732
- jsxs('box', {
733
- flexDirection: 'row',
734
- paddingLeft: 2,
735
- paddingRight: 2,
736
- children: [
737
- jsx('text', { fg: theme.textMuted, width: 22, children: label }),
738
- jsx('text', { fg: theme.text, flexGrow: 1, wrapMode: 'word', children: value }),
732
+ context.keymap.layer(() => ({
733
+ enabled: () => landstripOpen(),
734
+ priority: 1000,
735
+ commands: [
736
+ { id: 'landstrip-toggle', bind: 'return', run: toggleLandstrip },
737
+ { id: 'landstrip-cancel', bind: 'escape', run: cancelLandstrip },
739
738
  ],
740
- });
739
+ }));
741
740
 
742
- return Portal({
743
- get children() {
744
- if (!landstripOpen()) return null;
745
- sandboxRevision();
746
- const directory = api.state.path.directory || process.cwd();
747
- const config = loadConfig(directory, optionOverrides);
748
- const target = sandboxConfigTarget(directory);
749
- const values = (items: readonly string[]) => items.join(', ') || 'none';
750
- const status = enabledManagedByOptions
751
- ? config.enabled
752
- ? 'Active (plugin options)'
753
- : 'Disabled by plugin options'
754
- : config.enabled
755
- ? 'Active'
756
- : 'Disabled by configuration';
757
- return jsxs('box', {
758
- position: 'absolute',
759
- zIndex: 4000,
760
- bottom: 0,
761
- left: 0,
762
- right: 0,
763
- get height() {
764
- return Math.min(22, Math.max(12, dimensions().height - 2));
765
- },
766
- flexDirection: 'column',
767
- border: ['top'],
768
- borderColor: theme.primary,
769
- backgroundColor: theme.backgroundPanel,
741
+ const row = (label: string, value: string) =>
742
+ jsxs('box', {
743
+ flexDirection: 'row',
744
+ paddingLeft: 2,
745
+ paddingRight: 2,
770
746
  children: [
771
- jsxs('box', {
772
- flexDirection: 'row',
773
- paddingLeft: 2,
774
- paddingRight: 2,
775
- paddingTop: 1,
776
- paddingBottom: 1,
777
- justifyContent: 'space-between',
778
- children: [
779
- jsx('text', { fg: theme.primary, children: 'Landstrip' }),
780
- jsx('text', {
781
- fg: config.enabled ? theme.success : theme.warning,
782
- children: status,
783
- }),
784
- ],
785
- }),
786
- row('Network', config.network.allowNetwork ? 'Unrestricted' : 'Proxied'),
787
- row('Allowed Domains', values(config.network.allowedDomains)),
788
- row('Denied Domains', values(config.network.deniedDomains)),
789
- row('Allowed Reads', values(config.filesystem.allowRead)),
790
- row('Denied Reads', values(config.filesystem.denyRead)),
791
- row('Allowed Writes', values(config.filesystem.allowWrite)),
792
- row('Denied Writes', values(config.filesystem.denyWrite)),
793
- row(
794
- 'Configuration Scope',
795
- enabledManagedByOptions
796
- ? 'Plugin options'
797
- : target.scope === 'project'
798
- ? 'Project'
799
- : 'Global',
800
- ),
801
- row('Configuration File', formatPath(target.path, directory)),
802
- jsx('box', { flexGrow: 1 }),
803
- jsx('text', {
804
- fg: confirmingDisable() ? theme.error : theme.textMuted,
805
- marginLeft: 2,
806
- marginRight: 2,
807
- marginBottom: 1,
808
- children: confirmingDisable()
809
- ? 'Disable the sandbox? Commands will run without OS isolation. Enter confirm · Esc cancel'
810
- : enabledManagedByOptions
811
- ? 'Sandbox state is managed by plugin options · Esc close'
812
- : `Enter ${config.enabled ? 'disable' : 'enable'} · Esc close`,
813
- }),
747
+ jsx('text', { fg: theme.textMuted, width: 22, children: label }),
748
+ jsx('text', { fg: theme.text, flexGrow: 1, wrapMode: 'word', children: value }),
814
749
  ],
815
750
  });
816
- },
817
- });
818
- }
819
-
820
- const showLandstrip = () => {
821
- if (landstripOpen()) return;
822
- setLandstripOpen(true);
823
- popLandstripMode = api.mode.push('modal');
824
- unregisterLandstripKeys = api.keymap.registerLayer({
825
- priority: 1000,
826
- bindings: [
827
- { key: 'return', desc: 'Toggle sandbox', group: 'Landstrip', cmd: toggleLandstrip },
828
- { key: 'escape', desc: 'Close Landstrip', group: 'Landstrip', cmd: cancelLandstrip },
751
+
752
+ return Portal({
753
+ get children() {
754
+ if (!landstripOpen()) return null;
755
+ sandboxRevision();
756
+ const config = loadConfig(directory, optionOverrides);
757
+ const target = sandboxConfigTarget(directory);
758
+ const values = (items: readonly string[]) => items.join(', ') || 'none';
759
+ const status = enabledManagedByOptions
760
+ ? config.enabled
761
+ ? 'Active (plugin options)'
762
+ : 'Disabled by plugin options'
763
+ : config.enabled
764
+ ? 'Active'
765
+ : 'Disabled by configuration';
766
+ return jsxs('box', {
767
+ position: 'absolute',
768
+ zIndex: 4000,
769
+ bottom: 0,
770
+ left: 0,
771
+ right: 0,
772
+ get height() {
773
+ return Math.min(22, Math.max(12, dimensions().height - 2));
774
+ },
775
+ flexDirection: 'column',
776
+ border: ['top'],
777
+ borderColor: theme.primary,
778
+ backgroundColor: theme.backgroundPanel,
779
+ children: [
780
+ jsxs('box', {
781
+ flexDirection: 'row',
782
+ paddingLeft: 2,
783
+ paddingRight: 2,
784
+ paddingTop: 1,
785
+ paddingBottom: 1,
786
+ justifyContent: 'space-between',
787
+ children: [
788
+ jsx('text', { fg: theme.primary, children: 'Landstrip' }),
789
+ jsx('text', {
790
+ fg: config.enabled ? theme.success : theme.warning,
791
+ children: status,
792
+ }),
793
+ ],
794
+ }),
795
+ row('Network', config.network.allowNetwork ? 'Unrestricted' : 'Proxied'),
796
+ row('Allowed Domains', values(config.network.allowedDomains)),
797
+ row('Denied Domains', values(config.network.deniedDomains)),
798
+ row('Allowed Reads', values(config.filesystem.allowRead)),
799
+ row('Denied Reads', values(config.filesystem.denyRead)),
800
+ row('Allowed Writes', values(config.filesystem.allowWrite)),
801
+ row('Denied Writes', values(config.filesystem.denyWrite)),
802
+ row(
803
+ 'Configuration Scope',
804
+ enabledManagedByOptions
805
+ ? 'Plugin options'
806
+ : target.scope === 'project'
807
+ ? 'Project'
808
+ : 'Global',
809
+ ),
810
+ row('Configuration File', formatPath(target.path, directory)),
811
+ jsx('box', { flexGrow: 1 }),
812
+ jsx('text', {
813
+ fg: confirmingDisable() ? theme.error : theme.textMuted,
814
+ marginLeft: 2,
815
+ marginRight: 2,
816
+ marginBottom: 1,
817
+ children: confirmingDisable()
818
+ ? 'Disable the sandbox? Commands will run without OS isolation. Enter confirm · Esc cancel'
819
+ : enabledManagedByOptions
820
+ ? 'Sandbox state is managed by plugin options · Esc close'
821
+ : `Enter ${config.enabled ? 'disable' : 'enable'} · Esc close`,
822
+ }),
823
+ ],
824
+ });
825
+ },
826
+ });
827
+ }
828
+
829
+ const showLandstrip = () => {
830
+ if (landstripOpen()) return;
831
+ setLandstripOpen(true);
832
+ popLandstripMode = context.keymap.mode.push('modal');
833
+ };
834
+
835
+ context.keymap.layer(() => ({
836
+ commands: [
837
+ {
838
+ id: 'landstrip',
839
+ title: 'Landstrip',
840
+ description: 'Manage the Landstrip sandbox',
841
+ palette: true,
842
+ suggested: true,
843
+ slash: { name: 'landstrip' },
844
+ run: showLandstrip,
845
+ },
829
846
  ],
830
- });
831
- };
832
- api.lifecycle.onDispose(closeLandstrip);
847
+ }));
833
848
 
834
- api.keymap.registerLayer({
835
- commands: [
836
- {
837
- namespace: 'palette',
838
- name: 'landstrip',
839
- title: 'Landstrip',
840
- desc: 'Manage the Landstrip sandbox',
841
- category: 'Landstrip',
842
- suggested: true,
843
- slash: { name: 'landstrip' },
844
- slashName: 'landstrip',
845
- run: showLandstrip,
846
- },
847
- ],
848
- });
849
-
850
- // Persistent status badge in the prompt area.
851
- try {
852
- const statusBadge = (ctx: TuiSlotContext) => {
849
+ // Persistent status badge in the prompt area.
850
+ const statusBadge = () => {
853
851
  sandboxRevision();
854
- const directory = api.state.path.directory || process.cwd();
855
852
  const config = loadConfig(directory, optionOverrides);
856
- const theme = ctx.theme.current;
853
+ const theme = resolveThemeColors(context.theme);
857
854
 
858
855
  if (!config.enabled) return jsx('text', { fg: theme.textMuted, children: 'sandbox off' });
859
856
 
@@ -864,55 +861,70 @@ const tui: TuiPlugin = async (api, options, meta) => {
864
861
  });
865
862
  };
866
863
 
867
- const statusSlot: TuiSlotPlugin = {
868
- slots: {
869
- app: () => jsx(LandstripPane, {}),
870
- home_prompt_right: (ctx) => statusBadge(ctx),
871
- session_prompt: (_ctx, props) => renderSessionPrompt(props),
872
- session_prompt_right: (ctx) => statusBadge(ctx),
873
- },
874
- };
875
- api.slots.register(statusSlot);
876
- refreshSandboxStatus = () => setSandboxRevision((revision) => revision + 1);
877
- } catch {
878
- // Solid runtime unavailable on this host — skip the pane and status badge.
879
- }
880
-
881
- // First-run onboarding: a single quiet pointer to the default-strict policy
882
- // and the inspector command. `meta.state` flags a freshly installed plugin;
883
- // the kv flag keeps it from repeating across reloads.
884
- if (meta.state === 'first' && !api.kv.get<boolean>('onboarded', false)) {
885
- api.kv.set('onboarded', true);
886
- api.ui.toast({
887
- title: 'Sandbox active',
888
- message: 'Landstrip sandbox is on. Run /landstrip to inspect it.',
889
- variant: 'info',
890
- duration: 8000,
864
+ context.ui.slot({
865
+ append: 'app',
866
+ render: () => jsx(LandstripPane, {}),
891
867
  });
892
- }
893
-
894
- api.lifecycle.onDispose(() => {
895
- unregisterPermissionAsked();
896
- unregisterPermissionReplied();
897
- sessionAllowances.clear();
898
- // Deny any still-held queries so the sandboxed children don't hang, then
899
- // tear down the socket server and drop the discovery file.
900
- for (const entry of liveQueries) {
901
- respondQuery(entry.socket, entry.queryId, 'deny');
902
- liveQueries.delete(entry);
903
- finishActive(entry.id);
904
- }
905
- for (const socket of sockets) socket.destroy();
906
- if (socketServer) {
907
- socketServer.close();
908
- try {
909
- removeDiscoveryFile(api.state.path.directory || process.cwd());
910
- } catch {
911
- // best effort
868
+ context.ui.slot({
869
+ append: 'home.footer.status',
870
+ render: () => statusBadge(),
871
+ });
872
+ context.ui.slot({
873
+ append: 'prompt.footer.status',
874
+ render: () => statusBadge(),
875
+ });
876
+ context.ui.slot({
877
+ append: 'session.composer.top',
878
+ render: (props) => renderSessionPrompt(props),
879
+ });
880
+ refreshSandboxStatus = () => setSandboxRevision((revision) => revision + 1);
881
+
882
+ // First-run onboarding: a single quiet pointer to the default-strict policy
883
+ // and the inspector command.
884
+ try {
885
+ const [persisted, setPersisted] = context.storage.store<{ onboarded?: boolean }>(
886
+ 'opencode-landstrip',
887
+ { initial: { onboarded: false } },
888
+ );
889
+ if (!persisted.onboarded) {
890
+ void setPersisted((draft) => {
891
+ draft.onboarded = true;
892
+ }).catch(() => undefined);
893
+ context.ui.toast.show({
894
+ title: 'Sandbox active',
895
+ message: 'Landstrip sandbox is on. Run /landstrip to inspect it.',
896
+ variant: 'info',
897
+ duration: 8000,
898
+ });
912
899
  }
900
+ } catch {
901
+ // Ignore storage errors
913
902
  }
914
- });
903
+
904
+ return () => {
905
+ closeLandstrip();
906
+ unregisterPermissionAsked();
907
+ unregisterPermissionReplied();
908
+ sessionAllowances.clear();
909
+ // Deny any still-held queries so the sandboxed children don't hang, then
910
+ // tear down the socket server and drop the discovery file.
911
+ for (const entry of liveQueries) {
912
+ respondQuery(entry.socket, entry.queryId, 'deny');
913
+ liveQueries.delete(entry);
914
+ finishActive(entry.id);
915
+ }
916
+ for (const socket of sockets) socket.destroy();
917
+ if (socketServer) {
918
+ socketServer.close();
919
+ try {
920
+ removeDiscoveryFile(directory);
921
+ } catch {
922
+ // best effort
923
+ }
924
+ }
925
+ };
926
+ },
915
927
  };
916
928
 
917
929
  export { tui };
918
- export default { id: 'opencode-landstrip', tui };
930
+ export default tui;