sh3-core 0.14.0 → 0.15.0

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 (63) hide show
  1. package/dist/api.d.ts +3 -1
  2. package/dist/api.js +4 -0
  3. package/dist/contributions/index.d.ts +1 -1
  4. package/dist/contributions/index.js +1 -1
  5. package/dist/contributions/registry.d.ts +7 -0
  6. package/dist/contributions/registry.js +24 -4
  7. package/dist/contributions/registry.test.js +56 -1
  8. package/dist/contributions/types.d.ts +9 -0
  9. package/dist/layout/LayoutRenderer.svelte +1 -1
  10. package/dist/layout/tree-walk.js +6 -1
  11. package/dist/layout/types.d.ts +7 -0
  12. package/dist/overlays/FloatFrame.svelte +8 -2
  13. package/dist/overlays/float.js +6 -3
  14. package/dist/overlays/float.test.js +71 -0
  15. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
  16. package/dist/primitives/widgets/Segmented.svelte +4 -1
  17. package/dist/runtime/index.d.ts +2 -0
  18. package/dist/runtime/index.js +1 -0
  19. package/dist/runtime/runVerb.d.ts +10 -0
  20. package/dist/runtime/runVerb.js +97 -0
  21. package/dist/runtime/runVerb.test.d.ts +1 -0
  22. package/dist/runtime/runVerb.test.js +132 -0
  23. package/dist/sh3core-shard/AppInfoView.svelte +154 -0
  24. package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
  25. package/dist/sh3core-shard/appActions.js +23 -5
  26. package/dist/shards/activate-contributions.test.js +31 -0
  27. package/dist/shards/activate-runtime.test.d.ts +1 -0
  28. package/dist/shards/activate-runtime.test.js +179 -0
  29. package/dist/shards/activate.svelte.js +20 -3
  30. package/dist/shards/registry.d.ts +11 -1
  31. package/dist/shards/registry.js +16 -4
  32. package/dist/shards/registry.test.js +24 -16
  33. package/dist/shards/types.d.ts +38 -1
  34. package/dist/shell-shard/ScrollbackView.svelte +40 -19
  35. package/dist/shell-shard/Terminal.svelte +55 -4
  36. package/dist/shell-shard/contract.d.ts +34 -0
  37. package/dist/shell-shard/dispatch-custom.test.js +48 -0
  38. package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
  39. package/dist/shell-shard/dispatch-gating.test.js +63 -0
  40. package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
  41. package/dist/shell-shard/dispatch-invoke.test.js +214 -0
  42. package/dist/shell-shard/dispatch.d.ts +9 -1
  43. package/dist/shell-shard/dispatch.js +73 -2
  44. package/dist/shell-shard/output.d.ts +8 -1
  45. package/dist/shell-shard/output.js +17 -1
  46. package/dist/shell-shard/output.test.js +24 -5
  47. package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
  48. package/dist/shell-shard/registry-resolve.test.js +26 -0
  49. package/dist/shell-shard/registry.d.ts +12 -1
  50. package/dist/shell-shard/registry.js +12 -1
  51. package/dist/shell-shard/shellApi.d.ts +3 -0
  52. package/dist/shell-shard/shellApi.js +142 -0
  53. package/dist/shell-shard/shellShard.svelte.d.ts +1 -7
  54. package/dist/shell-shard/shellShard.svelte.js +8 -163
  55. package/dist/shell-shard/terminal-dispatch.test.js +10 -3
  56. package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
  57. package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
  58. package/dist/shell-shard/verbs/clear.js +1 -0
  59. package/dist/shell-shard/verbs/mode.js +1 -0
  60. package/dist/verbs/types.d.ts +68 -0
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.js +1 -1
  63. package/package.json +1 -1
@@ -3,7 +3,8 @@ import type { ZoneSchema, ZoneManager } from '../state/types';
3
3
  import type { DocumentHandle, DocumentHandleOptions } from '../documents/types';
4
4
  import type { BrowseCapability } from '../documents/browse';
5
5
  import type { EnvState } from '../env/types';
6
- import type { Verb } from '../verbs/types';
6
+ import type { Verb, VerbSchema } from '../verbs/types';
7
+ import type { ScrollbackEntry } from '../shell-shard/scrollback.svelte';
7
8
  import type { ShardContextKeys } from '../keys/types';
8
9
  import type { ContributionsApi } from '../contributions/types';
9
10
  import type { ActionsApi } from '../actions/types';
@@ -251,6 +252,42 @@ export interface ShardContext {
251
252
  * entries). Actions are auto-unregistered when the shard deactivates.
252
253
  */
253
254
  actions: ActionsApi;
255
+ /**
256
+ * Read-only snapshot of every verb registered across every active shard.
257
+ * Returned entries include the contributing `shardId`, the prefixed
258
+ * `name`, the verb's `summary`, and (when present) its `schema`.
259
+ * Order is undefined.
260
+ *
261
+ * No permission gate — verb names + summaries are already visible via
262
+ * the `help` verb. Diagnostic and AI-class shards (sh3-ai, sh3-diagnostic)
263
+ * use this to enumerate the host's action surface.
264
+ */
265
+ listVerbs(): Array<{
266
+ shardId: string;
267
+ name: string;
268
+ summary: string;
269
+ schema?: VerbSchema;
270
+ }>;
271
+ /**
272
+ * Programmatically dispatch a verb by `(shardId, name)`. Resolves with
273
+ * `{ result, scrollback }` where `scrollback` is the array of entries
274
+ * the verb pushed during invocation. Rejects on:
275
+ * - unknown shardId,
276
+ * - unknown verb,
277
+ * - target verb not opted in via `programmatic: true`,
278
+ * - any error thrown by the verb's `run`.
279
+ *
280
+ * Pass `opts.structured` to populate `ctx.structuredArgs` for verbs
281
+ * that declare `schema.input`. Pass `opts.signal` for cooperative
282
+ * cancellation (verbs must opt in to honor it).
283
+ */
284
+ runVerb(shardId: string, name: string, args: string[], opts?: {
285
+ signal?: AbortSignal;
286
+ structured?: unknown;
287
+ }): Promise<{
288
+ result: unknown;
289
+ scrollback: ScrollbackEntry[];
290
+ }>;
254
291
  }
255
292
  /**
256
293
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -11,30 +11,51 @@
11
11
  let { scrollback }: Props = $props();
12
12
 
13
13
  let container: HTMLDivElement | null = $state(null);
14
+ let content: HTMLDivElement | null = $state(null);
15
+ let stuck = true;
14
16
 
15
- // Auto-scroll to bottom on new entries
17
+ // scrollHeight - scrollTop - clientHeight can settle on small non-zero
18
+ // values from sub-pixel rounding even when visually at the bottom.
19
+ const STICK_THRESHOLD_PX = 4;
20
+
21
+ function isAtBottom(el: HTMLElement): boolean {
22
+ return el.scrollHeight - el.scrollTop - el.clientHeight <= STICK_THRESHOLD_PX;
23
+ }
24
+
25
+ function handleScroll(): void {
26
+ if (container) stuck = isAtBottom(container);
27
+ }
28
+
29
+ // ResizeObserver on the inner content wrapper fires on any layout-affecting
30
+ // change regardless of source: text-chunk pushes, mutated rich-entry props
31
+ // (output.stream() / output.rich() handles), image or font load. A reactivity-
32
+ // driven approach (depending on entries.length or chunk counts) misses rich
33
+ // streaming because props are mutated via Object.assign and the outer view
34
+ // never reads them.
16
35
  $effect(() => {
17
- // Depend on entries length so the effect re-runs
18
- const _len = scrollback.entries.length;
19
- void _len;
20
- if (container) {
21
- container.scrollTop = container.scrollHeight;
22
- }
36
+ if (!content || !container) return;
37
+ const ro = new ResizeObserver(() => {
38
+ if (container && stuck) container.scrollTop = container.scrollHeight;
39
+ });
40
+ ro.observe(content);
41
+ return () => ro.disconnect();
23
42
  });
24
43
  </script>
25
44
 
26
- <div class="shell-scrollback" bind:this={container}>
27
- {#each scrollback.entries as entry (entry.id)}
28
- {#if entry.kind === 'text'}
29
- <TextEntry stream={entry.stream} chunks={entry.chunks} />
30
- {:else if entry.kind === 'prompt'}
31
- <PromptEntry cwd={entry.cwd} line={entry.line} />
32
- {:else if entry.kind === 'status'}
33
- <StatusEntry text={entry.text} level={entry.level} />
34
- {:else if entry.kind === 'rich'}
35
- <RichEntry component={entry.component} componentProps={entry.props} />
36
- {/if}
37
- {/each}
45
+ <div class="shell-scrollback" bind:this={container} onscroll={handleScroll}>
46
+ <div class="content" bind:this={content}>
47
+ {#each scrollback.entries as entry (entry.id)}
48
+ {#if entry.kind === 'text'}
49
+ <TextEntry stream={entry.stream} chunks={entry.chunks} />
50
+ {:else if entry.kind === 'prompt'}
51
+ <PromptEntry cwd={entry.cwd} line={entry.line} />
52
+ {:else if entry.kind === 'status'}
53
+ <StatusEntry text={entry.text} level={entry.level} />
54
+ {:else if entry.kind === 'rich'}
55
+ <RichEntry component={entry.component} componentProps={entry.props} />
56
+ {/if}
57
+ {/each}
58
+ </div>
38
59
  </div>
39
60
 
40
61
  <style>
@@ -9,7 +9,7 @@
9
9
  import { TenantFsClient } from './tenant-fs-client';
10
10
  import { ShellModeRegistry } from './modes/registry';
11
11
  import { registerBuiltinModes } from './modes/builtin';
12
- import { resolveInitialMode, writeLastMode } from './modes/prefs';
12
+ import { readLastMode, resolveInitialMode, writeLastMode } from './modes/prefs';
13
13
  import type { ShellMode, ShellRole } from './modes/types';
14
14
  import type { ContributionsApi } from '../contributions/types';
15
15
  import { SHELL_MODE_CONTRIBUTION_POINT, type ShellModeDescriptor } from './contract';
@@ -22,6 +22,7 @@
22
22
  import ModeSlot from './toolbar/slots/ModeSlot.svelte';
23
23
  import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
24
24
  import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
25
+ import BusySlot from './toolbar/slots/BusySlot.svelte';
25
26
 
26
27
  interface Props {
27
28
  shell: ShellApi;
@@ -74,9 +75,24 @@
74
75
  .map(descriptorToMode),
75
76
  ]);
76
77
 
77
- // Reactive current mode
78
+ // Resolve against the merged visible set so a persisted contributed-mode
79
+ // id is honored at boot when its shard activated before this view mounted.
80
+ // Async activations still land on the role default.
78
81
  let mode = $state<ShellMode>(
79
- untrack(() => resolveInitialMode(modeRegistry, userId, role)),
82
+ untrack(() => {
83
+ const persisted = readLastMode(userId);
84
+ if (persisted) {
85
+ const initialVisible: ShellMode[] = [
86
+ ...modeRegistry.list(role),
87
+ ...contributedModes
88
+ .filter((d) => !d.requiresRole || d.requiresRole === role)
89
+ .map(descriptorToMode),
90
+ ];
91
+ const found = initialVisible.find((m) => m.id === persisted);
92
+ if (found) return found;
93
+ }
94
+ return resolveInitialMode(modeRegistry, userId, role);
95
+ }),
80
96
  );
81
97
 
82
98
  function setMode(id: string): void {
@@ -133,14 +149,44 @@
133
149
  // "referenced outside a closure" warning; the URL never changes at runtime.
134
150
  const session = untrack(() => new SessionClient(wsUrl));
135
151
 
152
+ // Busy controller — feeds the toolbar spinner. Each call to acquireBusy(label)
153
+ // adds an entry to the map; the returned disposer removes it. The slot
154
+ // renders when the map is non-empty.
155
+ let busyCounter = 0;
156
+ let busyEntries = $state(new Map<number, string | undefined>());
157
+
158
+ function acquireBusy(label?: string): () => void {
159
+ const id = ++busyCounter;
160
+ busyEntries.set(id, label);
161
+ busyEntries = new Map(busyEntries);
162
+ let cleared = false;
163
+ return () => {
164
+ if (cleared) return;
165
+ cleared = true;
166
+ busyEntries.delete(id);
167
+ busyEntries = new Map(busyEntries);
168
+ };
169
+ }
170
+
171
+ let busyActive = $derived(busyEntries.size > 0);
172
+ let busyLabel = $derived.by<string | null>(() => {
173
+ if (busyEntries.size === 0) return null;
174
+ // Most-recent label wins (insertion-order Map).
175
+ let last: string | undefined;
176
+ for (const v of busyEntries.values()) last = v;
177
+ return last ?? null;
178
+ });
179
+
136
180
  const { dispatch, cancel: cancelDispatch } = untrack(() => makeDispatch({
137
181
  mode: () => mode,
182
+ role: () => role,
138
183
  resolver,
139
184
  scrollback,
140
185
  session,
141
186
  shell: shellWithModes,
142
187
  fs,
143
188
  cwd: () => session.cwd,
189
+ busy: acquireBusy,
144
190
  customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
145
191
  }));
146
192
 
@@ -155,8 +201,12 @@
155
201
  let focusLocked = $state(false);
156
202
  let targetShard = $state<string | null>(null);
157
203
 
158
- // Toolbar slot registry
204
+ // Toolbar slot registry. The 'busy' slot is always-visible at the registry
205
+ // level; the BusySlot component itself gates rendering on `active` so we
206
+ // don't have to invalidate the toolbar's `slots` derivation when the
207
+ // spinner toggles (the toolbar re-derives from ctx, not from busyActive).
159
208
  const toolbarRegistry = new ToolbarSlotRegistry();
209
+ toolbarRegistry.register({ id: 'busy', order: 5, visible: () => true, component: BusySlot });
160
210
  toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
161
211
  toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'sh3', component: FocusLockSlot });
162
212
  toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'sh3', component: TargetShardSlot });
@@ -271,6 +321,7 @@
271
321
  registry={toolbarRegistry}
272
322
  ctx={{ mode, role }}
273
323
  slotProps={{
324
+ busy: { active: busyActive, label: busyLabel },
274
325
  mode: { mode, modes: visibleModes, onSelect: setMode },
275
326
  'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
276
327
  'target-shard': { target: targetShard },
@@ -50,6 +50,36 @@ export interface ShellModeOutput {
50
50
  * or `error()` is called so the renderer can show a loading affordance.
51
51
  */
52
52
  stream(component: Component<any>, initialProps: Record<string, unknown>): StreamHandle;
53
+ /**
54
+ * Show a spinner in the shell header for the duration of a long-running
55
+ * operation. The optional label is rendered next to the spinner; pass
56
+ * undefined for an unlabelled indicator. Returns a clear handle — call
57
+ * clear() exactly once when the operation completes (idempotent).
58
+ *
59
+ * The framework already auto-spawns a spinner while a dispatch is in
60
+ * flight. Use this method for work that runs *outside* a dispatch (e.g.
61
+ * during the descriptor's `activate()` lifecycle hook, or background
62
+ * pre-fetching).
63
+ */
64
+ busy(label?: string): BusyHandle;
65
+ /**
66
+ * Programmatically dispatch a line through another mode's resolution path.
67
+ *
68
+ * - `'sh3'` — full sh3 verb resolution (bypasses the mode-gating that
69
+ * normally restricts sh3-domain verbs to sh3 mode).
70
+ * - `'bash'` — forward to the WS session. Lazy-connects on first use.
71
+ * Resolves immediately on send (output streams asynchronously to the
72
+ * scrollback via the WS protocol).
73
+ * - any other registered mode id — routes through that mode's `dispatch()`
74
+ * handler. Output flows to the same scrollback.
75
+ *
76
+ * Throws synchronously on:
77
+ * - unknown mode id
78
+ * - role mismatch (e.g. a non-admin context targeting `'bash'`)
79
+ * - self-invocation (a mode invoking its own id — direct calls into the
80
+ * mode's helpers should be used instead)
81
+ */
82
+ invoke(modeId: string, line: string): Promise<void>;
53
83
  }
54
84
  export interface RichEntryHandle {
55
85
  /** Patch the entry's props. Triggers Svelte reactivity. */
@@ -63,3 +93,7 @@ export interface StreamHandle {
63
93
  /** Mark the stream finished with an error; renders an error status. */
64
94
  error(err: unknown): void;
65
95
  }
96
+ export interface BusyHandle {
97
+ /** Remove this busy indicator. Idempotent. */
98
+ clear(): void;
99
+ }
@@ -16,12 +16,14 @@ function makeStubDeps(mode, customMode) {
16
16
  return {
17
17
  deps: {
18
18
  mode: () => mode,
19
+ role: () => 'user',
19
20
  resolver,
20
21
  scrollback,
21
22
  session,
22
23
  shell,
23
24
  fs,
24
25
  cwd: () => '/',
26
+ busy: () => () => { },
25
27
  customMode,
26
28
  },
27
29
  pushed,
@@ -102,3 +104,49 @@ describe('dispatch — custom transport', () => {
102
104
  expect(err.text).toMatch(/no longer available/);
103
105
  });
104
106
  });
107
+ describe('dispatch — auto-spinner', () => {
108
+ it('acquires a busy slot for the duration of a custom-mode dispatch', async () => {
109
+ let active = 0;
110
+ let peak = 0;
111
+ const busy = () => {
112
+ active++;
113
+ peak = Math.max(peak, active);
114
+ return () => { active--; };
115
+ };
116
+ let resolveDispatch;
117
+ const desc = {
118
+ id: 'gemini',
119
+ label: 'Gemini',
120
+ runsOn: 'client',
121
+ dispatch: () => new Promise((resolve) => {
122
+ resolveDispatch = resolve;
123
+ }),
124
+ };
125
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
126
+ const { deps } = makeStubDeps(mode, () => desc);
127
+ const { dispatch } = makeDispatch(Object.assign(Object.assign({}, deps), { busy }));
128
+ const promise = dispatch('hi');
129
+ // Yield once so the dispatch closure runs through to the await on desc.dispatch
130
+ await Promise.resolve();
131
+ expect(peak).toBe(1);
132
+ expect(active).toBe(1);
133
+ resolveDispatch();
134
+ await promise;
135
+ expect(active).toBe(0);
136
+ });
137
+ it('clears busy when the dispatch handler throws', async () => {
138
+ let active = 0;
139
+ const busy = () => { active++; return () => { active--; }; };
140
+ const desc = {
141
+ id: 'gemini',
142
+ label: 'Gemini',
143
+ runsOn: 'client',
144
+ dispatch: async () => { throw new Error('kaboom'); },
145
+ };
146
+ const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
147
+ const { deps } = makeStubDeps(mode, () => desc);
148
+ const { dispatch } = makeDispatch(Object.assign(Object.assign({}, deps), { busy }));
149
+ await dispatch('hi');
150
+ expect(active).toBe(0);
151
+ });
152
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { makeDispatch } from './dispatch';
3
+ const appsVerb = { name: 'apps', summary: '', async run() { } };
4
+ const clearVerb = {
5
+ name: 'clear',
6
+ summary: '',
7
+ globalVerb: true,
8
+ async run(ctx) {
9
+ ctx.scrollback.push({ kind: 'status', text: 'cleared', level: 'info', ts: 0 });
10
+ },
11
+ };
12
+ function scaffold(mode) {
13
+ const sent = [];
14
+ const pushed = [];
15
+ const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
16
+ const session = { history: { push: vi.fn() }, send: (m) => sent.push(m), cwd: '/', connected: true, connect: vi.fn() };
17
+ const fs = {};
18
+ const shell = {};
19
+ // Real-shape resolver to exercise the new globalOnly path.
20
+ const resolver = {
21
+ resolve: (line, opts = {}) => {
22
+ const head = line.trim().split(/\s+/)[0];
23
+ const verb = head === 'apps' ? appsVerb : head === 'clear' ? clearVerb : null;
24
+ if (!verb)
25
+ return { kind: 'forward', line };
26
+ if (opts.globalOnly && !verb.globalVerb)
27
+ return { kind: 'forward', line };
28
+ return { kind: 'local', verb, args: [], line };
29
+ },
30
+ };
31
+ const { dispatch } = makeDispatch({
32
+ mode: () => mode,
33
+ role: () => (mode.requiresRole === 'admin' ? 'admin' : 'user'),
34
+ resolver,
35
+ scrollback,
36
+ session,
37
+ shell,
38
+ fs,
39
+ cwd: () => '/',
40
+ busy: () => () => { },
41
+ });
42
+ return { dispatch, sent, pushed };
43
+ }
44
+ describe('dispatch — mode-gated verb resolution', () => {
45
+ const sh3Mode = { id: 'sh3', label: 'SH3', transport: 'none', autoRelocate: true };
46
+ const bashMode = { id: 'bash', label: 'Bash', transport: 'ws', autoRelocate: false, requiresRole: 'admin' };
47
+ it('sh3 mode resolves sh3-domain verbs locally', async () => {
48
+ const { dispatch, sent } = scaffold(sh3Mode);
49
+ await dispatch('apps');
50
+ expect(sent).toEqual([]);
51
+ });
52
+ it('bash mode forwards sh3-domain verbs to ws', async () => {
53
+ const { dispatch, sent } = scaffold(bashMode);
54
+ await dispatch('apps');
55
+ expect(sent.some((m) => m.t === 'submit' && m.line === 'apps')).toBe(true);
56
+ });
57
+ it('bash mode runs globalVerb (clear) locally', async () => {
58
+ const { dispatch, sent, pushed } = scaffold(bashMode);
59
+ await dispatch('clear');
60
+ expect(sent.every((m) => m.t !== 'submit')).toBe(true);
61
+ expect(pushed.some((e) => e.kind === 'status' && e.text === 'cleared')).toBe(true);
62
+ });
63
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { makeDispatch } from './dispatch';
3
+ const openVerb = {
4
+ name: 'open',
5
+ summary: '',
6
+ async run(ctx, args) {
7
+ var _a;
8
+ ctx.scrollback.push({ kind: 'status', text: `opened:${(_a = args[0]) !== null && _a !== void 0 ? _a : ''}`, level: 'info', ts: 0 });
9
+ },
10
+ };
11
+ function scaffold(opts) {
12
+ const sent = [];
13
+ const pushed = [];
14
+ const connectSpy = vi.fn();
15
+ const scrollback = { push: (e) => pushed.push(e), clear: () => { } };
16
+ const session = {
17
+ history: { push: vi.fn() },
18
+ send: (m) => sent.push(m),
19
+ cwd: '/',
20
+ connect: connectSpy,
21
+ };
22
+ const fs = {};
23
+ const shell = {};
24
+ const resolver = {
25
+ resolve: (line, _opts = {}) => {
26
+ const head = line.trim().split(/\s+/)[0];
27
+ const rest = line.trim().split(/\s+/).slice(1);
28
+ if (head === 'open')
29
+ return { kind: 'local', verb: openVerb, args: rest, line };
30
+ return { kind: 'forward', line };
31
+ },
32
+ };
33
+ const { dispatch } = makeDispatch({
34
+ mode: () => opts.current,
35
+ role: () => opts.role,
36
+ resolver,
37
+ scrollback,
38
+ session,
39
+ shell,
40
+ fs,
41
+ cwd: () => '/',
42
+ busy: () => () => { },
43
+ customMode: (id) => { var _a, _b; return (_b = (_a = opts.customs) === null || _a === void 0 ? void 0 : _a.find((d) => d.id === id)) !== null && _b !== void 0 ? _b : null; },
44
+ });
45
+ return { dispatch, sent, pushed, connectSpy };
46
+ }
47
+ const customMode = (id) => ({ id, label: id, transport: 'custom', autoRelocate: false });
48
+ describe('output.invoke — sh3 target', () => {
49
+ it('runs an sh3 verb when invoked from a custom mode', async () => {
50
+ const captured = [];
51
+ const customs = [{
52
+ id: 'gemini',
53
+ label: 'Gemini',
54
+ runsOn: 'client',
55
+ dispatch: async (_input, output) => {
56
+ await output.invoke('sh3', 'open foo.md');
57
+ captured.push('after-invoke');
58
+ },
59
+ }];
60
+ const { dispatch, pushed } = scaffold({
61
+ current: customMode('gemini'),
62
+ role: 'user',
63
+ customs,
64
+ });
65
+ await dispatch('hello');
66
+ expect(pushed.some((e) => e.kind === 'status' && e.text === 'opened:foo.md')).toBe(true);
67
+ expect(captured).toEqual(['after-invoke']);
68
+ });
69
+ it('throws for unknown sh3 verb', async () => {
70
+ let caught;
71
+ const customs = [{
72
+ id: 'gemini',
73
+ label: 'Gemini',
74
+ runsOn: 'client',
75
+ dispatch: async (_input, output) => {
76
+ try {
77
+ await output.invoke('sh3', 'nonexistent');
78
+ }
79
+ catch (e) {
80
+ caught = e;
81
+ }
82
+ },
83
+ }];
84
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
85
+ await dispatch('hello');
86
+ expect(caught).toBeInstanceOf(Error);
87
+ expect(caught.message).toMatch(/unknown sh3 verb/);
88
+ });
89
+ });
90
+ describe('output.invoke — bash target', () => {
91
+ it('admin invocation lazy-connects and forwards', async () => {
92
+ const customs = [{
93
+ id: 'gemini',
94
+ label: 'Gemini',
95
+ runsOn: 'client',
96
+ dispatch: async (_input, output) => {
97
+ await output.invoke('bash', 'ls');
98
+ },
99
+ }];
100
+ const { dispatch, sent, connectSpy } = scaffold({
101
+ current: customMode('gemini'),
102
+ role: 'admin',
103
+ customs,
104
+ });
105
+ await dispatch('hello');
106
+ expect(connectSpy).toHaveBeenCalledTimes(1);
107
+ expect(sent.some((m) => m.t === 'submit' && m.line === 'ls')).toBe(true);
108
+ });
109
+ it('lazy-connect only fires once across multiple invokes', async () => {
110
+ const customs = [{
111
+ id: 'gemini',
112
+ label: 'Gemini',
113
+ runsOn: 'client',
114
+ dispatch: async (_input, output) => {
115
+ await output.invoke('bash', 'ls');
116
+ await output.invoke('bash', 'pwd');
117
+ },
118
+ }];
119
+ const { dispatch, connectSpy } = scaffold({
120
+ current: customMode('gemini'),
121
+ role: 'admin',
122
+ customs,
123
+ });
124
+ await dispatch('hello');
125
+ expect(connectSpy).toHaveBeenCalledTimes(1);
126
+ });
127
+ it('non-admin invoking bash throws', async () => {
128
+ let caught;
129
+ const customs = [{
130
+ id: 'gemini',
131
+ label: 'Gemini',
132
+ runsOn: 'client',
133
+ dispatch: async (_input, output) => {
134
+ try {
135
+ await output.invoke('bash', 'ls');
136
+ }
137
+ catch (e) {
138
+ caught = e;
139
+ }
140
+ },
141
+ }];
142
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
143
+ await dispatch('hello');
144
+ expect(caught).toBeInstanceOf(Error);
145
+ expect(caught.message).toMatch(/admin role/);
146
+ });
147
+ });
148
+ describe('output.invoke — guards', () => {
149
+ it('self-invoke throws', async () => {
150
+ let caught;
151
+ const customs = [{
152
+ id: 'gemini',
153
+ label: 'Gemini',
154
+ runsOn: 'client',
155
+ dispatch: async (_input, output) => {
156
+ try {
157
+ await output.invoke('gemini', 'foo');
158
+ }
159
+ catch (e) {
160
+ caught = e;
161
+ }
162
+ },
163
+ }];
164
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
165
+ await dispatch('hello');
166
+ expect(caught).toBeInstanceOf(Error);
167
+ expect(caught.message).toMatch(/cannot invoke own mode/);
168
+ });
169
+ it('unknown custom mode throws', async () => {
170
+ let caught;
171
+ const customs = [{
172
+ id: 'gemini',
173
+ label: 'Gemini',
174
+ runsOn: 'client',
175
+ dispatch: async (_input, output) => {
176
+ try {
177
+ await output.invoke('claude', 'foo');
178
+ }
179
+ catch (e) {
180
+ caught = e;
181
+ }
182
+ },
183
+ }];
184
+ const { dispatch } = scaffold({ current: customMode('gemini'), role: 'user', customs });
185
+ await dispatch('hello');
186
+ expect(caught).toBeInstanceOf(Error);
187
+ expect(caught.message).toMatch(/unknown mode/);
188
+ });
189
+ });
190
+ describe('output.invoke — custom target', () => {
191
+ it('routes through the target descriptor dispatch with the same scrollback', async () => {
192
+ const customs = [
193
+ {
194
+ id: 'gemini',
195
+ label: 'Gemini',
196
+ runsOn: 'client',
197
+ dispatch: async (_input, output) => {
198
+ await output.invoke('claude', 'hi');
199
+ },
200
+ },
201
+ {
202
+ id: 'claude',
203
+ label: 'Claude',
204
+ runsOn: 'client',
205
+ dispatch: async (input, output) => {
206
+ output.text('stdout', `claude:${input.line}\n`);
207
+ },
208
+ },
209
+ ];
210
+ const { dispatch, pushed } = scaffold({ current: customMode('gemini'), role: 'user', customs });
211
+ await dispatch('hello');
212
+ expect(pushed.some((e) => { var _a, _b; return e.kind === 'text' && ((_b = (_a = e.chunks) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.includes('claude:hi')); })).toBe(true);
213
+ });
214
+ });
@@ -2,16 +2,24 @@ import type { VerbRegistry, ShellApi } from './registry';
2
2
  import type { Scrollback } from './scrollback.svelte';
3
3
  import type { SessionClient } from './session-client.svelte';
4
4
  import type { TenantFsClient } from './tenant-fs-client';
5
- import type { ShellMode } from './modes/types';
5
+ import type { ShellMode, ShellRole } from './modes/types';
6
6
  import type { ShellModeDescriptor } from './contract';
7
7
  export interface DispatchDeps {
8
8
  mode: () => ShellMode;
9
+ /** Current shell role — used by invoke() role-gating. */
10
+ role: () => ShellRole;
9
11
  resolver: VerbRegistry;
10
12
  scrollback: Scrollback;
11
13
  session: SessionClient;
12
14
  shell: ShellApi;
13
15
  fs: TenantFsClient;
14
16
  cwd: () => string;
17
+ /**
18
+ * Acquire a busy indicator. Returns a clear handle. Calling clear()
19
+ * multiple times is safe (idempotent). Used internally to auto-spawn
20
+ * a spinner around custom-mode dispatch and exposed via output.busy().
21
+ */
22
+ busy: (label?: string) => () => void;
15
23
  /**
16
24
  * Look up a contributed mode descriptor by id. Called only when the active
17
25
  * mode has `transport: 'custom'`. Returns null if the descriptor has been