sh3-core 0.7.5 → 0.8.1

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 (156) hide show
  1. package/dist/api.d.ts +12 -2
  2. package/dist/api.js +13 -1
  3. package/dist/app/admin/SystemView.svelte +149 -11
  4. package/dist/app/store/StoreView.svelte +36 -7
  5. package/dist/app/store/storeShard.svelte.js +9 -3
  6. package/dist/app/store/verbs.js +8 -2
  7. package/dist/apps/lifecycle.d.ts +11 -0
  8. package/dist/apps/lifecycle.js +21 -1
  9. package/dist/apps/lifecycle.test.js +50 -1
  10. package/dist/apps/types.d.ts +7 -2
  11. package/dist/createShell.d.ts +2 -0
  12. package/dist/createShell.js +9 -7
  13. package/dist/documents/backends.d.ts +8 -0
  14. package/dist/documents/backends.js +87 -0
  15. package/dist/documents/backends.test.d.ts +1 -0
  16. package/dist/documents/backends.test.js +33 -0
  17. package/dist/documents/browse.d.ts +12 -0
  18. package/dist/documents/browse.js +19 -0
  19. package/dist/documents/browse.test.d.ts +1 -0
  20. package/dist/documents/browse.test.js +41 -0
  21. package/dist/documents/handle.js +5 -0
  22. package/dist/documents/http-backend.d.ts +4 -0
  23. package/dist/documents/http-backend.js +14 -0
  24. package/dist/documents/index.d.ts +1 -0
  25. package/dist/documents/index.js +1 -0
  26. package/dist/documents/journal-hook.d.ts +6 -0
  27. package/dist/documents/journal-hook.js +16 -0
  28. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  29. package/dist/documents/sync/activate-integration.test.js +37 -0
  30. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  31. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  32. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  33. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  34. package/dist/documents/sync/conflicts.d.ts +30 -0
  35. package/dist/documents/sync/conflicts.js +77 -0
  36. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  37. package/dist/documents/sync/conflicts.test.js +71 -0
  38. package/dist/documents/sync/engine.d.ts +19 -0
  39. package/dist/documents/sync/engine.js +188 -0
  40. package/dist/documents/sync/engine.test.d.ts +1 -0
  41. package/dist/documents/sync/engine.test.js +169 -0
  42. package/dist/documents/sync/handle.d.ts +11 -0
  43. package/dist/documents/sync/handle.js +79 -0
  44. package/dist/documents/sync/handle.test.d.ts +1 -0
  45. package/dist/documents/sync/handle.test.js +56 -0
  46. package/dist/documents/sync/hash.d.ts +1 -0
  47. package/dist/documents/sync/hash.js +13 -0
  48. package/dist/documents/sync/hash.test.d.ts +1 -0
  49. package/dist/documents/sync/hash.test.js +20 -0
  50. package/dist/documents/sync/index.d.ts +5 -0
  51. package/dist/documents/sync/index.js +10 -0
  52. package/dist/documents/sync/journal.d.ts +30 -0
  53. package/dist/documents/sync/journal.js +179 -0
  54. package/dist/documents/sync/journal.test.d.ts +1 -0
  55. package/dist/documents/sync/journal.test.js +87 -0
  56. package/dist/documents/sync/observer.d.ts +3 -0
  57. package/dist/documents/sync/observer.js +45 -0
  58. package/dist/documents/sync/registry.d.ts +13 -0
  59. package/dist/documents/sync/registry.js +73 -0
  60. package/dist/documents/sync/registry.test.d.ts +1 -0
  61. package/dist/documents/sync/registry.test.js +53 -0
  62. package/dist/documents/sync/serialization.d.ts +5 -0
  63. package/dist/documents/sync/serialization.js +24 -0
  64. package/dist/documents/sync/serialization.test.d.ts +1 -0
  65. package/dist/documents/sync/serialization.test.js +26 -0
  66. package/dist/documents/sync/singleton.d.ts +11 -0
  67. package/dist/documents/sync/singleton.js +26 -0
  68. package/dist/documents/sync/tombstones.d.ts +19 -0
  69. package/dist/documents/sync/tombstones.js +58 -0
  70. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  71. package/dist/documents/sync/tombstones.test.js +37 -0
  72. package/dist/documents/sync/types.d.ts +116 -0
  73. package/dist/documents/sync/types.js +27 -0
  74. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  75. package/dist/documents/sync/write-hook.test.js +36 -0
  76. package/dist/documents/types.d.ts +18 -0
  77. package/dist/documents/types.js +6 -1
  78. package/dist/env/client.d.ts +10 -5
  79. package/dist/env/client.js +12 -4
  80. package/dist/layout/inspection.d.ts +17 -0
  81. package/dist/layout/inspection.js +53 -0
  82. package/dist/registry/installer.d.ts +10 -7
  83. package/dist/registry/installer.js +39 -35
  84. package/dist/registry/register.d.ts +17 -0
  85. package/dist/registry/register.js +22 -0
  86. package/dist/registry/register.test.d.ts +1 -0
  87. package/dist/registry/register.test.js +28 -0
  88. package/dist/shards/activate-browse.test.d.ts +1 -0
  89. package/dist/shards/activate-browse.test.js +36 -0
  90. package/dist/shards/activate-sync-registry.test.d.ts +1 -0
  91. package/dist/shards/activate-sync-registry.test.js +42 -0
  92. package/dist/shards/activate-tenantid.test.d.ts +1 -0
  93. package/dist/shards/activate-tenantid.test.js +21 -0
  94. package/dist/shards/activate.svelte.d.ts +12 -0
  95. package/dist/shards/activate.svelte.js +53 -2
  96. package/dist/shards/types.d.ts +43 -1
  97. package/dist/shell-shard/Terminal.svelte +140 -33
  98. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  99. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  100. package/dist/shell-shard/auto-relocate.js +20 -0
  101. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  102. package/dist/shell-shard/auto-relocate.test.js +35 -0
  103. package/dist/shell-shard/dispatch.d.ts +15 -0
  104. package/dist/shell-shard/dispatch.js +56 -0
  105. package/dist/shell-shard/manifest.js +1 -1
  106. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  107. package/dist/shell-shard/modes/builtin.js +18 -0
  108. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  109. package/dist/shell-shard/modes/prefs.js +31 -0
  110. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  111. package/dist/shell-shard/modes/prefs.test.js +46 -0
  112. package/dist/shell-shard/modes/registry.d.ts +7 -0
  113. package/dist/shell-shard/modes/registry.js +27 -0
  114. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  115. package/dist/shell-shard/modes/registry.test.js +35 -0
  116. package/dist/shell-shard/modes/types.d.ts +8 -0
  117. package/dist/shell-shard/modes/types.js +1 -0
  118. package/dist/shell-shard/protocol.d.ts +6 -0
  119. package/dist/shell-shard/shellShard.svelte.js +57 -5
  120. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  121. package/dist/shell-shard/tenant-fs-client.js +44 -0
  122. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  123. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  124. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  125. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  126. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  127. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  128. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  129. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  130. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  131. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  132. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  133. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  134. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  135. package/dist/shell-shard/toolbar/slots.js +26 -0
  136. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  137. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  138. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  139. package/dist/shell-shard/verbs/cat.js +34 -0
  140. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  141. package/dist/shell-shard/verbs/cd.test.js +56 -0
  142. package/dist/shell-shard/verbs/env.d.ts +2 -0
  143. package/dist/shell-shard/verbs/env.js +14 -0
  144. package/dist/shell-shard/verbs/index.js +9 -2
  145. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  146. package/dist/shell-shard/verbs/ls.js +29 -0
  147. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  148. package/dist/shell-shard/verbs/ls.test.js +49 -0
  149. package/dist/shell-shard/verbs/session.d.ts +0 -1
  150. package/dist/shell-shard/verbs/session.js +58 -26
  151. package/dist/shell-shard/verbs/views.d.ts +2 -0
  152. package/dist/shell-shard/verbs/views.js +103 -2
  153. package/dist/verbs/types.d.ts +21 -0
  154. package/dist/version.d.ts +1 -1
  155. package/dist/version.js +1 -1
  156. package/package.json +1 -1
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Unified bundle registration — stamps loader-assigned metadata onto every
3
+ * shard/app manifest from a loaded bundle, then registers each. Shared by
4
+ * `installer.installPackage`, `installer.loadInstalledPackages`, and
5
+ * `createShell` discoveredPackages so the three boot paths stay in sync.
6
+ *
7
+ * Per ADR-013, external package authors omit `version` from their source
8
+ * manifests; the authoritative value comes from the persisted/server
9
+ * metadata and must be stamped here before any consumer reads the manifest.
10
+ */
11
+ import { registerShard } from '../shards/activate.svelte';
12
+ import { registerApp } from '../apps/registry.svelte';
13
+ export function registerLoadedBundle(loaded, meta) {
14
+ for (const shard of loaded.shards) {
15
+ shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: meta.version });
16
+ registerShard(shard);
17
+ }
18
+ for (const app of loaded.apps) {
19
+ app.manifest = Object.assign(Object.assign({}, app.manifest), { version: meta.version });
20
+ registerApp(app);
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { resetFramework } from '../__test__/reset';
3
+ import { makeApp, makeAppManifest, makeShard, makeShardManifest } from '../__test__/fixtures';
4
+ import { registerLoadedBundle } from './register';
5
+ import { registeredApps } from '../apps/registry.svelte';
6
+ import { registeredShards } from '../shards/activate.svelte';
7
+ describe('registerLoadedBundle', () => {
8
+ beforeEach(resetFramework);
9
+ it('stamps meta.version onto every shard manifest before registering', () => {
10
+ var _a;
11
+ const shard = makeShard({ manifest: makeShardManifest({ id: 'shard-1', version: '' }) });
12
+ registerLoadedBundle({ shards: [shard], apps: [] }, { version: '2.3.4', sourceRegistry: 'https://r', contractVersion: '1' });
13
+ expect((_a = registeredShards.get('shard-1')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('2.3.4');
14
+ });
15
+ it('stamps meta.version onto every app manifest before registering', () => {
16
+ var _a;
17
+ const app = makeApp({ manifest: makeAppManifest({ id: 'app-1', version: '' }) });
18
+ registerLoadedBundle({ shards: [], apps: [app] }, { version: '2.3.4', sourceRegistry: 'https://r', contractVersion: '1' });
19
+ expect((_a = registeredApps.get('app-1')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('2.3.4');
20
+ });
21
+ it('registers combo bundles (both shards and apps)', () => {
22
+ const shard = makeShard({ manifest: makeShardManifest({ id: 'combo-s' }) });
23
+ const app = makeApp({ manifest: makeAppManifest({ id: 'combo-a', requiredShards: ['combo-s'] }) });
24
+ registerLoadedBundle({ shards: [shard], apps: [app] }, { version: '1.0.0', sourceRegistry: '', contractVersion: '1' });
25
+ expect(registeredShards.has('combo-s')).toBe(true);
26
+ expect(registeredApps.has('combo-a')).toBe(true);
27
+ });
28
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { registerShard, activateShard, __resetShardRegistryForTest } from './activate.svelte';
5
+ import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
6
+ describe('ctx.browse permission gating', () => {
7
+ beforeEach(() => {
8
+ __resetShardRegistryForTest();
9
+ __setDocumentBackend(new MemoryDocumentBackend());
10
+ __setTenantId('tenant-a');
11
+ });
12
+ it('is undefined when permission is absent', async () => {
13
+ let captured = null;
14
+ registerShard({
15
+ manifest: { id: 'no-browse', label: 'n', version: '0.0.0', views: [] },
16
+ activate(ctx) { captured = ctx; },
17
+ });
18
+ await activateShard('no-browse');
19
+ expect(captured.browse).toBeUndefined();
20
+ });
21
+ it('is defined when documents:browse is declared', async () => {
22
+ var _a, _b, _c;
23
+ let captured = null;
24
+ registerShard({
25
+ manifest: {
26
+ id: 'has-browse', label: 'b', version: '0.0.0', views: [],
27
+ permissions: [PERMISSION_DOCUMENTS_BROWSE],
28
+ },
29
+ activate(ctx) { captured = ctx; },
30
+ });
31
+ await activateShard('has-browse');
32
+ expect(typeof ((_a = captured.browse) === null || _a === void 0 ? void 0 : _a.listDocuments)).toBe('function');
33
+ expect(typeof ((_b = captured.browse) === null || _b === void 0 ? void 0 : _b.watchDocuments)).toBe('function');
34
+ expect(typeof ((_c = captured.browse) === null || _c === void 0 ? void 0 : _c.listShards)).toBe('function');
35
+ });
36
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { __resetSyncBundlesForTest } from '../documents/sync/singleton';
5
+ import { registerShard, activateShard, __resetShardRegistryForTest } from './activate.svelte';
6
+ import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
7
+ describe('ctx.syncRegistry', () => {
8
+ beforeEach(() => {
9
+ __resetShardRegistryForTest();
10
+ __resetSyncBundlesForTest();
11
+ __setDocumentBackend(new MemoryDocumentBackend());
12
+ __setTenantId('tenant-a');
13
+ });
14
+ it('is undefined without documents:browse', async () => {
15
+ let captured = null;
16
+ registerShard({
17
+ manifest: { id: 'no-obs', label: 'n', version: '0.0.0', views: [] },
18
+ activate(ctx) { captured = ctx; },
19
+ });
20
+ await activateShard('no-obs');
21
+ expect(captured.syncRegistry).toBeUndefined();
22
+ });
23
+ it('is available under documents:browse', async () => {
24
+ let captured = null;
25
+ registerShard({
26
+ manifest: {
27
+ id: 'obs', label: 'o', version: '0.0.0', views: [],
28
+ permissions: [PERMISSION_DOCUMENTS_BROWSE],
29
+ },
30
+ activate(ctx) { captured = ctx; },
31
+ });
32
+ await activateShard('obs');
33
+ const reg = captured.syncRegistry();
34
+ expect(typeof reg.list).toBe('function');
35
+ expect(typeof reg.listConflicts).toBe('function');
36
+ expect(typeof reg.listAllConnectorIds).toBe('function');
37
+ expect(typeof reg.revoke).toBe('function');
38
+ // Functional smoke: empty registry should return []
39
+ expect(await reg.list()).toEqual([]);
40
+ expect(await reg.listConflicts()).toEqual([]);
41
+ });
42
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { registerShard, activateShard, __resetShardRegistryForTest } from './activate.svelte';
5
+ describe('ctx.tenantId', () => {
6
+ beforeEach(() => {
7
+ __resetShardRegistryForTest();
8
+ __setDocumentBackend(new MemoryDocumentBackend());
9
+ __setTenantId('tenant-a');
10
+ });
11
+ it('is present unconditionally on ctx', async () => {
12
+ let captured = null;
13
+ const shard = {
14
+ manifest: { id: 'test-tenantid', label: 't', version: '0.0.0', views: [] },
15
+ activate(ctx) { captured = ctx; },
16
+ };
17
+ registerShard(shard);
18
+ await activateShard('test-tenantid');
19
+ expect(captured.tenantId).toBe('tenant-a');
20
+ });
21
+ });
@@ -50,6 +50,18 @@ export declare function isActive(id: string): boolean;
50
50
  * Used by lifecycle.ts to pass context to `shard.resume()`.
51
51
  */
52
52
  export declare function getShardContext(id: string): ShardContext | undefined;
53
+ /**
54
+ * Enumerate every view declared as `standalone` across the currently
55
+ * active shards. Intended for the `views --standalone` verb and any
56
+ * launcher UI that wants to surface "summonable" primitives. Only
57
+ * pulls from `activeShards` — registered-but-inactive shards aren't
58
+ * ready to mount.
59
+ */
60
+ export declare function listStandaloneViews(): Array<{
61
+ shardId: string;
62
+ viewId: string;
63
+ label: string;
64
+ }>;
53
65
  /**
54
66
  * Test-only reset. Tears down any active shard entries (without running
55
67
  * deactivate hooks — tests should run deactivate explicitly if they care)
@@ -23,6 +23,12 @@ import { fetchEnvState, putEnvState } from '../env/client';
23
23
  import { isAdmin as checkIsAdmin } from '../auth/index';
24
24
  import { createZoneManager } from '../state/manage';
25
25
  import { PERMISSION_STATE_MANAGE } from '../state/types';
26
+ import { PERMISSION_DOCUMENTS_SYNC } from '../documents/sync/types';
27
+ import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
28
+ import { createBrowseCapability } from '../documents/browse';
29
+ import { getSyncBundle } from '../documents/sync/singleton';
30
+ import { createSyncHandle } from '../documents/sync/handle';
31
+ import { createSyncRegistryAccessor } from '../documents/sync/observer';
26
32
  /**
27
33
  * Reactive registry of every shard known to the host. Keys are shard ids.
28
34
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -65,7 +71,7 @@ export function registerShard(shard) {
65
71
  * @throws If the shard is not registered, or if a manifest view has no factory after activation.
66
72
  */
67
73
  export async function activateShard(id) {
68
- var _a, _b;
74
+ var _a, _b, _c, _d, _e;
69
75
  const shard = registeredShards.get(id);
70
76
  if (!shard) {
71
77
  throw new Error(`Cannot activate shard "${id}": not registered`);
@@ -125,9 +131,36 @@ export async function activateShard(id) {
125
131
  get isAdmin() {
126
132
  return checkIsAdmin();
127
133
  },
134
+ get tenantId() {
135
+ return getTenantId();
136
+ },
128
137
  zones: ((_a = shard.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
129
138
  ? createZoneManager()
130
139
  : undefined,
140
+ browse: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_BROWSE))
141
+ ? createBrowseCapability(getTenantId(), getDocumentBackend())
142
+ : undefined,
143
+ syncRegistry: ((_c = shard.manifest.permissions) === null || _c === void 0 ? void 0 : _c.includes(PERMISSION_DOCUMENTS_BROWSE))
144
+ ? createSyncRegistryAccessor(getDocumentBackend(), getTenantId())
145
+ : undefined,
146
+ sync: ((_d = shard.manifest.permissions) === null || _d === void 0 ? void 0 : _d.includes(PERMISSION_DOCUMENTS_SYNC))
147
+ ? () => {
148
+ const backend = getDocumentBackend();
149
+ const tenantId = getTenantId();
150
+ const bundlePromise = getSyncBundle(backend, tenantId);
151
+ const handlePromise = bundlePromise.then(({ engine, registry }) => createSyncHandle({ tenantId, connectorId: id, engine, registry }));
152
+ return {
153
+ connectorId: id,
154
+ grantedScopes: async () => (await handlePromise).grantedScopes(),
155
+ getManifest: async (scope) => (await handlePromise).getManifest(scope),
156
+ changesSince: async (scope, cursor) => (await handlePromise).changesSince(scope, cursor),
157
+ ack: async (scope, cursor) => (await handlePromise).ack(scope, cursor),
158
+ apply: async (scope, entry, opts) => (await handlePromise).apply(scope, entry, opts),
159
+ applyBatch: async (scope, manifest, opts) => (await handlePromise).applyBatch(scope, manifest, opts),
160
+ forget: async (scope, path) => (await handlePromise).forget(scope, path),
161
+ };
162
+ }
163
+ : undefined,
131
164
  };
132
165
  entry.ctx = ctx;
133
166
  active.set(id, entry);
@@ -149,7 +182,7 @@ export async function activateShard(id) {
149
182
  console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
150
183
  }
151
184
  }
152
- void ((_b = shard.autostart) === null || _b === void 0 ? void 0 : _b.call(shard, ctx));
185
+ void ((_e = shard.autostart) === null || _e === void 0 ? void 0 : _e.call(shard, ctx));
153
186
  }
154
187
  /**
155
188
  * Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
@@ -191,6 +224,24 @@ export function getShardContext(id) {
191
224
  var _a;
192
225
  return (_a = active.get(id)) === null || _a === void 0 ? void 0 : _a.ctx;
193
226
  }
227
+ /**
228
+ * Enumerate every view declared as `standalone` across the currently
229
+ * active shards. Intended for the `views --standalone` verb and any
230
+ * launcher UI that wants to surface "summonable" primitives. Only
231
+ * pulls from `activeShards` — registered-but-inactive shards aren't
232
+ * ready to mount.
233
+ */
234
+ export function listStandaloneViews() {
235
+ const out = [];
236
+ for (const shard of activeShards.values()) {
237
+ for (const view of shard.manifest.views) {
238
+ if (view.standalone) {
239
+ out.push({ shardId: shard.manifest.id, viewId: view.id, label: view.label });
240
+ }
241
+ }
242
+ }
243
+ return out;
244
+ }
194
245
  /**
195
246
  * Test-only reset. Tears down any active shard entries (without running
196
247
  * deactivate hooks — tests should run deactivate explicitly if they care)
@@ -1,6 +1,9 @@
1
1
  import type { StateZones } from '../state/zones.svelte';
2
2
  import type { ZoneSchema, ZoneManager } from '../state/types';
3
3
  import type { DocumentHandle, DocumentHandleOptions } from '../documents/types';
4
+ import type { BrowseCapability } from '../documents/browse';
5
+ import type { SyncHandle } from '../documents/sync/types';
6
+ import type { SyncRegistry } from '../documents/sync/registry';
4
7
  import type { EnvState } from '../env/types';
5
8
  import type { Verb } from '../verbs/types';
6
9
  /**
@@ -82,6 +85,14 @@ export interface ViewDeclaration {
82
85
  label: string;
83
86
  /** Optional icon hint (reserved; not yet rendered in phase 8). */
84
87
  icon?: string;
88
+ /**
89
+ * When true, this view is a standalone primitive — summonable from
90
+ * anywhere without requiring an owning app. Standalone views are
91
+ * enumerable via the framework's view directory and can be popped
92
+ * out into a float or docked into the active layout by verbs like
93
+ * `popout` and `dock`. Defaults to false.
94
+ */
95
+ standalone?: boolean;
85
96
  }
86
97
  /**
87
98
  * Static description of a shard as observed by the framework and consumers
@@ -116,7 +127,11 @@ export interface ShardManifest {
116
127
  /**
117
128
  * Optional permissions this shard requests beyond the default sandbox.
118
129
  * Declared in the manifest and surfaced to the user at install time.
119
- * Currently recognized: `'state:manage'` — cross-shard zone access.
130
+ * Currently recognized:
131
+ * - 'state:manage' — cross-shard zone access.
132
+ * - 'documents:sync' — cross-shard document sync API.
133
+ * - 'documents:browse' — tenant-wide document observation and sync
134
+ * registry visibility (observer-class shards, e.g. file-explorer).
120
135
  */
121
136
  permissions?: string[];
122
137
  }
@@ -185,12 +200,39 @@ export interface ShardContext {
185
200
  envUpdate<T extends Record<string, unknown>>(patch: Partial<T>): Promise<void>;
186
201
  /** Whether the current session has admin privileges. */
187
202
  isAdmin: boolean;
203
+ /**
204
+ * The active tenant id. Always present. Exposed for logging / diagnostics.
205
+ * Scopes never carry tenantId; the engine rebinds per-session. Do not
206
+ * serialize into persistent storage.
207
+ */
208
+ tenantId: string;
188
209
  /**
189
210
  * Cross-shard zone management API. Only present when the shard's
190
211
  * manifest declares the `'state:manage'` permission. Check with
191
212
  * `if (ctx.zones)` before use.
192
213
  */
193
214
  zones?: ZoneManager;
215
+ /**
216
+ * Cross-shard document sync API. Only present when the shard's
217
+ * manifest declares the `'documents:sync'` permission. Check with
218
+ * `if (ctx.sync)` before use.
219
+ */
220
+ sync?: () => SyncHandle;
221
+ /**
222
+ * Tenant-wide document browse API. Read-only enumeration and change
223
+ * subscription across every shard's documents for the active tenant.
224
+ * Only present when the shard's manifest declares the
225
+ * `'documents:browse'` permission. Writes still flow through the
226
+ * owning shard's own `ctx.documents()` handle.
227
+ */
228
+ browse?: BrowseCapability;
229
+ /**
230
+ * Sync registry observer. Read-only list/revoke/conflict enumeration
231
+ * for explorer-class shards. Only present when the shard declares
232
+ * `'documents:browse'`. Granting still happens exclusively via
233
+ * `<SyncGrantPicker />`.
234
+ */
235
+ syncRegistry?: () => SyncRegistry;
194
236
  }
195
237
  /**
196
238
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -6,55 +6,149 @@
6
6
  import { SessionClient } from './session-client.svelte';
7
7
  import { VerbRegistry, type ShellApi } from './registry';
8
8
  import type { ServerMessage } from './protocol';
9
+ import { TenantFsClient } from './tenant-fs-client';
10
+ import { ShellModeRegistry } from './modes/registry';
11
+ import { registerBuiltinModes } from './modes/builtin';
12
+ import { resolveInitialMode, writeLastMode } from './modes/prefs';
13
+ import type { ShellMode, ShellRole } from './modes/types';
14
+ import { makeDispatch } from './dispatch';
15
+ import { computeRelocate } from './auto-relocate';
16
+ import { activeLayout } from '../layout/store.svelte';
17
+ import type { LayoutNode } from '../layout/types';
18
+ import Toolbar from './toolbar/Toolbar.svelte';
19
+ import { ToolbarSlotRegistry } from './toolbar/slots';
20
+ import ModeSlot from './toolbar/slots/ModeSlot.svelte';
21
+ import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
22
+ import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
9
23
 
10
24
  interface Props {
11
25
  shell: ShellApi;
12
26
  wsUrl: string;
27
+ userId: string;
28
+ role: ShellRole;
13
29
  }
14
- let { shell, wsUrl }: Props = $props();
30
+ let { shell, wsUrl, userId, role }: Props = $props();
15
31
 
16
32
  const scrollback = new Scrollback();
33
+ const resolver = new VerbRegistry();
34
+ const fs = new TenantFsClient();
35
+
36
+ // Mode registry
37
+ const modeRegistry = new ShellModeRegistry();
38
+ registerBuiltinModes(modeRegistry);
39
+
40
+ // Reactive current mode
41
+ let mode = $state<ShellMode>(
42
+ untrack(() => resolveInitialMode(modeRegistry, userId, role)),
43
+ );
44
+
45
+ function setMode(id: string): void {
46
+ const next = modeRegistry.get(id);
47
+ if (!next) return;
48
+ if (next.requiresRole && next.requiresRole !== role) return;
49
+ mode = next;
50
+ writeLastMode(userId, id);
51
+ if (next.transport !== 'ws') {
52
+ scrollback.push({ kind: 'status', text: 'mode switch: reload to take effect for server-shell changes', level: 'info', ts: Date.now() });
53
+ }
54
+ }
55
+
17
56
  // wsUrl is a prop read at construction only. untrack prevents Svelte 5's
18
57
  // "referenced outside a closure" warning; the URL never changes at runtime.
19
58
  const session = untrack(() => new SessionClient(wsUrl));
20
- const resolver = new VerbRegistry();
59
+
60
+ const dispatch = untrack(() => makeDispatch({
61
+ mode: () => mode,
62
+ resolver,
63
+ scrollback,
64
+ session,
65
+ shell,
66
+ fs,
67
+ cwd: () => session.cwd,
68
+ }));
21
69
 
22
70
  let locked = $state(false);
23
71
 
24
- async function dispatch(line: string): Promise<void> {
25
- session.history.push(line);
26
- const resolution = resolver.resolve(line);
27
- if (resolution.kind === 'local') {
28
- // Log locally-dispatched verbs for shared history
29
- session.send({ t: 'history-log', line });
30
- scrollback.push({
31
- kind: 'prompt',
32
- cwd: session.cwd,
33
- line,
34
- ts: Date.now(),
35
- });
36
- try {
37
- await resolution.verb.run({
38
- shell,
39
- scrollback,
40
- session,
41
- cwd: session.cwd,
42
- dispatch,
43
- }, resolution.args);
44
- } catch (err) {
45
- scrollback.push({
46
- kind: 'status',
47
- text: `shell: verb ${resolution.verb.name} threw — ${(err as Error).message}`,
48
- level: 'error',
49
- ts: Date.now(),
50
- });
72
+ // ---------------------------------------------------------------------------
73
+ // Auto-relocate: track the focused shard and update session.cwd when focus
74
+ // changes to a shard whose documents directory exists. focusLocked and
75
+ // targetShard are read by Task 13's toolbar component via props.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ let focusLocked = $state(false);
79
+ let targetShard = $state<string | null>(null);
80
+
81
+ function toggleFocusLock(): void {
82
+ focusLocked = !focusLocked;
83
+ }
84
+
85
+ // Toolbar slot registry
86
+ const toolbarRegistry = new ToolbarSlotRegistry();
87
+ toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
88
+ toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'user', component: FocusLockSlot });
89
+ toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'user', component: TargetShardSlot });
90
+
91
+ let toolbarExpanded = $state((() => {
92
+ try { return localStorage.getItem('sh3.shell.toolbarExpanded') !== '0'; } catch { return true; }
93
+ })());
94
+
95
+ function toggleToolbar() {
96
+ toolbarExpanded = !toolbarExpanded;
97
+ try { localStorage.setItem('sh3.shell.toolbarExpanded', toolbarExpanded ? '1' : '0'); } catch {}
98
+ }
99
+
100
+ /** Walk the layout tree and return the viewId of the active tab in the first
101
+ * TabsNode found (breadth-first). Returns null if the layout contains no
102
+ * tabs node with a populated active tab. */
103
+ function getActiveViewId(node: LayoutNode): string | null {
104
+ if (node.type === 'tabs') {
105
+ const entry = node.tabs[node.activeTab];
106
+ return entry?.viewId ?? null;
107
+ }
108
+ if (node.type === 'split') {
109
+ for (const child of node.children) {
110
+ const found = getActiveViewId(child);
111
+ if (found !== null) return found;
51
112
  }
52
- } else {
53
- // Forward to server
54
- session.send({ t: 'submit', line: resolution.line });
55
113
  }
114
+ // slot node
115
+ return node.viewId ?? null;
56
116
  }
57
117
 
118
+ /** Derive the focused shard id from the currently-active layout. The shard
119
+ * id is the prefix before the first ':' in a viewId (e.g. 'shell:terminal'
120
+ * → 'shell'). Returns null when no view is active. */
121
+ function getFocusedShardId(): string | null {
122
+ try {
123
+ const tree = activeLayout();
124
+ const viewId = getActiveViewId(tree.docked);
125
+ if (!viewId) return null;
126
+ const colon = viewId.indexOf(':');
127
+ return colon >= 0 ? viewId.slice(0, colon) : viewId;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ $effect(() => {
134
+ const focused = getFocusedShardId();
135
+ const autoRelocate = mode.autoRelocate;
136
+ const isFocusLocked = focusLocked;
137
+ (async () => {
138
+ const r = await computeRelocate({
139
+ modeAutoRelocate: autoRelocate,
140
+ focusLocked: isFocusLocked,
141
+ focusedShardId: focused,
142
+ currentShardId: 'shell',
143
+ }, fs);
144
+ if (r.kind === 'relocate' && r.path !== undefined) {
145
+ session.cwd = r.path;
146
+ }
147
+ if (focused && focused !== 'shell') targetShard = focused;
148
+ else if (!focused) targetShard = null;
149
+ })();
150
+ });
151
+
58
152
  function handleServerMessage(msg: ServerMessage) {
59
153
  if (msg.t !== 'event') return;
60
154
  const e = msg.event;
@@ -96,7 +190,9 @@
96
190
 
97
191
  onMount(() => {
98
192
  unsub = session.onMessage(handleServerMessage);
99
- session.connect();
193
+ if (mode.transport === 'ws') {
194
+ session.connect();
195
+ }
100
196
  });
101
197
 
102
198
  onDestroy(() => {
@@ -106,6 +202,17 @@
106
202
  </script>
107
203
 
108
204
  <div class="shell-terminal">
205
+ <Toolbar
206
+ registry={toolbarRegistry}
207
+ ctx={{ mode, role }}
208
+ expanded={toolbarExpanded}
209
+ onToggle={toggleToolbar}
210
+ slotProps={{
211
+ mode: { mode, role, registry: modeRegistry, onSelect: setMode },
212
+ 'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
213
+ 'target-shard': { target: targetShard },
214
+ }}
215
+ />
109
216
  <ScrollbackView {scrollback} />
110
217
  <InputLine
111
218
  cwd={session.cwd}
@@ -1,7 +1,10 @@
1
1
  import { type ShellApi } from './registry';
2
+ import type { ShellRole } from './modes/types';
2
3
  interface Props {
3
4
  shell: ShellApi;
4
5
  wsUrl: string;
6
+ userId: string;
7
+ role: ShellRole;
5
8
  }
6
9
  declare const Terminal: import("svelte").Component<Props, {}, "">;
7
10
  type Terminal = ReturnType<typeof Terminal>;
@@ -0,0 +1,12 @@
1
+ import type { TenantFsClient } from './tenant-fs-client';
2
+ export interface RelocateInput {
3
+ modeAutoRelocate: boolean;
4
+ focusLocked: boolean;
5
+ focusedShardId: string | null;
6
+ currentShardId: string;
7
+ }
8
+ export interface RelocateEffect {
9
+ kind: 'noop' | 'relocate';
10
+ path?: string;
11
+ }
12
+ export declare function computeRelocate(input: RelocateInput, fs: TenantFsClient): Promise<RelocateEffect>;
@@ -0,0 +1,20 @@
1
+ export async function computeRelocate(input, fs) {
2
+ if (!input.modeAutoRelocate)
3
+ return { kind: 'noop' };
4
+ if (input.focusLocked)
5
+ return { kind: 'noop' };
6
+ if (input.focusedShardId === null)
7
+ return { kind: 'noop' };
8
+ if (input.focusedShardId === input.currentShardId)
9
+ return { kind: 'noop' };
10
+ const path = input.focusedShardId;
11
+ try {
12
+ const s = await fs.stat(path);
13
+ if (s.kind !== 'dir')
14
+ return { kind: 'noop' };
15
+ }
16
+ catch (_a) {
17
+ return { kind: 'noop' };
18
+ }
19
+ return { kind: 'relocate', path };
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { computeRelocate } from './auto-relocate';
3
+ const statOk = { list: vi.fn(), read: vi.fn(), stat: async () => ({ name: 'notes', kind: 'dir', size: 0, mtime: 0 }) };
4
+ const statMissing = { list: vi.fn(), read: vi.fn(), stat: async () => { throw new Error('not found'); } };
5
+ const statFile = { list: vi.fn(), read: vi.fn(), stat: async () => ({ name: 'notes', kind: 'file', size: 0, mtime: 0 }) };
6
+ describe('computeRelocate', () => {
7
+ it('noop when mode disables auto-relocate', async () => {
8
+ const r = await computeRelocate({ modeAutoRelocate: false, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
9
+ expect(r.kind).toBe('noop');
10
+ });
11
+ it('noop when focus-lock is on', async () => {
12
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: true, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
13
+ expect(r.kind).toBe('noop');
14
+ });
15
+ it('noop when nothing relevant is focused', async () => {
16
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: null, currentShardId: 'shell' }, statOk);
17
+ expect(r.kind).toBe('noop');
18
+ });
19
+ it('noop when focusing the shell itself', async () => {
20
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'shell', currentShardId: 'shell' }, statOk);
21
+ expect(r.kind).toBe('noop');
22
+ });
23
+ it('noop when target documents folder does not exist', async () => {
24
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statMissing);
25
+ expect(r.kind).toBe('noop');
26
+ });
27
+ it('noop when target is a file, not a directory', async () => {
28
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statFile);
29
+ expect(r.kind).toBe('noop');
30
+ });
31
+ it('relocates to the shard id path on happy path', async () => {
32
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
33
+ expect(r).toEqual({ kind: 'relocate', path: 'notes' });
34
+ });
35
+ });