cli-jaw 2.17.72 → 2.17.73

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 (169) hide show
  1. package/README.md +16 -0
  2. package/dist/src/agent/aside-catalog.js +339 -0
  3. package/dist/src/agent/aside-catalog.js.map +1 -0
  4. package/dist/src/agent/aside-cli.js +208 -0
  5. package/dist/src/agent/aside-cli.js.map +1 -0
  6. package/dist/src/agent/aside-replay.js +120 -0
  7. package/dist/src/agent/aside-replay.js.map +1 -0
  8. package/dist/src/agent/aside-runtime.js +175 -0
  9. package/dist/src/agent/aside-runtime.js.map +1 -0
  10. package/dist/src/agent/lifecycle-handler.js +26 -18
  11. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  12. package/dist/src/agent/memory-flush-controller.js +3 -0
  13. package/dist/src/agent/memory-flush-controller.js.map +1 -1
  14. package/dist/src/agent/prompt-context.js +2 -2
  15. package/dist/src/agent/prompt-context.js.map +1 -1
  16. package/dist/src/agent/session-persistence.js +2 -0
  17. package/dist/src/agent/session-persistence.js.map +1 -1
  18. package/dist/src/agent/spawn/backend-aside.js +314 -0
  19. package/dist/src/agent/spawn/backend-aside.js.map +1 -0
  20. package/dist/src/agent/spawn/resume.js +3 -0
  21. package/dist/src/agent/spawn/resume.js.map +1 -1
  22. package/dist/src/agent/spawn-env.js +5 -0
  23. package/dist/src/agent/spawn-env.js.map +1 -1
  24. package/dist/src/agent/spawn.js +60 -6
  25. package/dist/src/agent/spawn.js.map +1 -1
  26. package/dist/src/cli/cli-status-worker.js +2 -1
  27. package/dist/src/cli/cli-status-worker.js.map +1 -1
  28. package/dist/src/cli/employee-handler.js +2 -0
  29. package/dist/src/cli/employee-handler.js.map +1 -1
  30. package/dist/src/cli/handlers-completions.js +20 -8
  31. package/dist/src/cli/handlers-completions.js.map +1 -1
  32. package/dist/src/cli/handlers-runtime.js +2 -0
  33. package/dist/src/cli/handlers-runtime.js.map +1 -1
  34. package/dist/src/cli/readiness.js +7 -3
  35. package/dist/src/cli/readiness.js.map +1 -1
  36. package/dist/src/cli/registry-live.js +47 -0
  37. package/dist/src/cli/registry-live.js.map +1 -1
  38. package/dist/src/cli/registry.js +5 -0
  39. package/dist/src/cli/registry.js.map +1 -1
  40. package/dist/src/core/config.js +10 -3
  41. package/dist/src/core/config.js.map +1 -1
  42. package/dist/src/core/employees.js +5 -3
  43. package/dist/src/core/employees.js.map +1 -1
  44. package/dist/src/core/settings-merge.js +35 -0
  45. package/dist/src/core/settings-merge.js.map +1 -1
  46. package/dist/src/core/settings-watch.js +10 -1
  47. package/dist/src/core/settings-watch.js.map +1 -1
  48. package/dist/src/orchestrator/gateway.js +2 -0
  49. package/dist/src/orchestrator/gateway.js.map +1 -1
  50. package/dist/src/routes/employees.js +8 -0
  51. package/dist/src/routes/employees.js.map +1 -1
  52. package/dist/src/routes/jaw-memory.js +4 -0
  53. package/dist/src/routes/jaw-memory.js.map +1 -1
  54. package/dist/src/routes/orchestrate.js +22 -3
  55. package/dist/src/routes/orchestrate.js.map +1 -1
  56. package/dist/src/routes/settings.js +23 -0
  57. package/dist/src/routes/settings.js.map +1 -1
  58. package/dist/src/shared/aside-contract.js +2 -0
  59. package/dist/src/shared/aside-contract.js.map +1 -0
  60. package/dist/src/types/cli-engine.js +1 -0
  61. package/dist/src/types/cli-engine.js.map +1 -1
  62. package/package.json +1 -1
  63. package/public/dist/.vite/manifest.json +186 -175
  64. package/public/dist/assets/{Activity-DPLlOJ46.js → Activity-DsBgOkCP.js} +1 -1
  65. package/public/dist/assets/{AdvancedExport-BgOgGvc5.js → AdvancedExport-COGQ7vbK.js} +1 -1
  66. package/public/dist/assets/Agent-6qGQN7lY.js +1 -0
  67. package/public/dist/assets/AsideSelectionFields-DvU5xWAP.js +1 -0
  68. package/public/dist/assets/{Browser-m6VynQXV.js → Browser-r09vOsR1.js} +1 -1
  69. package/public/dist/assets/ChannelEnablementControl-ooOkdd-Q.js +85 -0
  70. package/public/dist/assets/{ChannelSetupEntry-DHLoZEzJ.js → ChannelSetupEntry-B0b4TdhH.js} +1 -1
  71. package/public/dist/assets/{ChannelsDiscord-B-G8vsYy.js → ChannelsDiscord-9EMLt5b3.js} +1 -1
  72. package/public/dist/assets/{ChannelsSlack-oPuh922z.js → ChannelsSlack-B8ETzWFt.js} +1 -1
  73. package/public/dist/assets/{ChannelsTelegram-PmTv43KQ.js → ChannelsTelegram-CflLUsQw.js} +1 -1
  74. package/public/dist/assets/{DashboardMeta-BqEiJrvr.js → DashboardMeta-wjEuh9-E.js} +1 -1
  75. package/public/dist/assets/{Developer-CbySB7_Z.js → Developer-B3cDkldo.js} +1 -1
  76. package/public/dist/assets/{Display-DU4_76cS.js → Display-BXQxpv6-.js} +1 -1
  77. package/public/dist/assets/{Display-C0Zdamcb.js → Display-Cl4DD334.js} +1 -1
  78. package/public/dist/assets/{Embedding-CCFMOB_2.js → Embedding-D-T92-En.js} +1 -1
  79. package/public/dist/assets/{Employees-BanvF58d.js → Employees-Cf4IeF0N.js} +1 -1
  80. package/public/dist/assets/{Heartbeat-uyi_MpUd.js → Heartbeat-Bj8V_bUM.js} +1 -1
  81. package/public/dist/assets/{Mcp-rjPoL_Zo.js → Mcp-jAcut_fw.js} +1 -1
  82. package/public/dist/assets/Memory-BOXkBFKg.js +1 -0
  83. package/public/dist/assets/ModelProvider-DIDQ37z-.js +1 -0
  84. package/public/dist/assets/{Network-CuqFrOvR.js → Network-I5eOe2hf.js} +1 -1
  85. package/public/dist/assets/{Permissions-BltXiZnz.js → Permissions-B7PthH01.js} +1 -1
  86. package/public/dist/assets/{Permissions-DcCbFMIq.js → Permissions-BCYUfJg1.js} +1 -1
  87. package/public/dist/assets/Profile-CYPMVhQM.js +1 -0
  88. package/public/dist/assets/{Prompts-C9vY8ivQ.js → Prompts-BRAgZLNQ.js} +1 -1
  89. package/public/dist/assets/{SpeechKeys-BJ8V7U5E.js → SpeechKeys-UlXNSFoe.js} +1 -1
  90. package/public/dist/assets/{TelegramHub-DtyjLGFR.js → TelegramHub-BhUFhIcv.js} +1 -1
  91. package/public/dist/assets/agent-meta-v4jNRAbX.js +1 -0
  92. package/public/dist/assets/app-SVIyP1TT.js +59 -0
  93. package/public/dist/assets/bgtask-badge-HIrvpcr_.js +1 -0
  94. package/public/dist/assets/{bgtask-badge-oPvZyylH.js → bgtask-badge-tYRnplEV.js} +1 -1
  95. package/public/dist/assets/constants-_iw-lnKY.js +1 -0
  96. package/public/dist/assets/{employees-BchFBkDz.js → employees-DNTPcxtZ.js} +2 -2
  97. package/public/dist/assets/{error-normalize-clOi-AAA.js → error-normalize-jnjrKZDu.js} +1 -1
  98. package/public/dist/assets/{iframe-renderer-CcGzEi0v.js → iframe-renderer--lHbKTDh.js} +2 -2
  99. package/public/dist/assets/iframe-renderer-DigNC5FU.js +1 -0
  100. package/public/dist/assets/{manager-nquYZ7_x.js → manager-qGH-xUZ7.js} +1 -1
  101. package/public/dist/assets/{memory-DTpD4see.js → memory-BAdRkBdk.js} +1 -1
  102. package/public/dist/assets/memory-DA8x5314.js +1 -0
  103. package/public/dist/assets/message-history-CGOgZH4l.js +1 -0
  104. package/public/dist/assets/{message-history-DSvflaZ9.js → message-history-PAYeKmia.js} +1 -1
  105. package/public/dist/assets/page-shell-Cz2ylri8.js +1 -0
  106. package/public/dist/assets/{render-D8dVQ_bm.js → render-F6xF6w6c.js} +1 -1
  107. package/public/dist/assets/settings-DMbNzuTm.js +1 -0
  108. package/public/dist/assets/{settings-DMiQe469.js → settings-DZ2-ca8T.js} +1 -1
  109. package/public/dist/assets/{settings-Bp0sgQ2a.js → settings-W0WFhBf2.js} +1 -1
  110. package/public/dist/assets/settings-core-2vZXNf6n.js +2 -0
  111. package/public/dist/assets/settings-core-BofMyzL9.js +1 -0
  112. package/public/dist/assets/{settings-embedding-BRW0WZvh.js → settings-embedding-Bjwpe8xu.js} +2 -2
  113. package/public/dist/assets/{shared-D1begqvi.js → shared-CftvKNY9.js} +1 -1
  114. package/public/dist/assets/{sidebar-CrpBbYFg.js → sidebar-Clkqd78q.js} +3 -3
  115. package/public/dist/assets/{skills-Bx8U-gJa.js → skills-0aA-RuN9.js} +1 -1
  116. package/public/dist/assets/skills-SqmQXndw.js +1 -0
  117. package/public/dist/assets/{slash-commands-DkrGUOnC.js → slash-commands-4zHHXEeU.js} +1 -1
  118. package/public/dist/assets/slash-commands-D5LqONzL.js +1 -0
  119. package/public/dist/assets/{trace-drawer-Dbe5pTbf.js → trace-drawer-B4O0Tl1M.js} +1 -1
  120. package/public/dist/assets/{ui-rnVAM91F.js → ui-CVtGpivr.js} +1 -1
  121. package/public/dist/assets/ui-_cqgseFX.js +1 -0
  122. package/public/dist/index.html +1 -1
  123. package/public/dist/manager/index.html +1 -1
  124. package/public/dist/settings/index.html +1 -1
  125. package/public/js/constants.ts +5 -0
  126. package/public/js/features/employees.ts +3 -3
  127. package/public/js/features/heartbeat.ts +14 -1
  128. package/public/js/features/settings-core.ts +39 -13
  129. package/public/js/features/settings-types.ts +1 -1
  130. package/public/locales/en.json +2 -0
  131. package/public/locales/ja.json +2 -0
  132. package/public/locales/ko.json +2 -0
  133. package/public/locales/zh.json +2 -0
  134. package/public/manager/src/settings/SettingsShell.tsx +1 -1
  135. package/public/manager/src/settings/pages/Agent.tsx +137 -52
  136. package/public/manager/src/settings/pages/Heartbeat.tsx +6 -0
  137. package/public/manager/src/settings/pages/Memory.tsx +8 -6
  138. package/public/manager/src/settings/pages/ModelProvider.tsx +31 -6
  139. package/public/manager/src/settings/pages/Profile.tsx +2 -2
  140. package/public/manager/src/settings/pages/components/AsideSelectionFields.tsx +33 -0
  141. package/public/manager/src/settings/pages/components/AvatarCard.tsx +22 -34
  142. package/public/manager/src/settings/pages/components/PerCliRow.tsx +29 -1
  143. package/public/manager/src/settings/pages/components/agent/FlushAgentSection.tsx +7 -6
  144. package/public/manager/src/settings/pages/components/agent/RuntimeEmployeeRow.tsx +4 -4
  145. package/public/manager/src/settings/pages/components/agent/RuntimeHeader.tsx +5 -0
  146. package/public/manager/src/settings/pages/components/agent/agent-meta.ts +7 -0
  147. package/public/manager/src/settings/pages/components/agent/runtime-employees-helpers.ts +3 -2
  148. package/public/manager/src/settings/pages/components/aside-models.ts +104 -0
  149. package/public/manager/src/settings/settings-client.ts +3 -1
  150. package/public/manager/src/settings/types.ts +1 -0
  151. package/public/dist/assets/Agent-O_U8ny90.js +0 -1
  152. package/public/dist/assets/ChannelEnablementControl-BSuKQhTW.js +0 -85
  153. package/public/dist/assets/Memory-C9I33pIi.js +0 -1
  154. package/public/dist/assets/ModelProvider-fiUktMaO.js +0 -1
  155. package/public/dist/assets/Profile-DUL7-RE_.js +0 -1
  156. package/public/dist/assets/agent-meta-94LMwZZK.js +0 -1
  157. package/public/dist/assets/app-Iu76iEiU.js +0 -59
  158. package/public/dist/assets/bgtask-badge-DHo2mkMI.js +0 -1
  159. package/public/dist/assets/constants-DmZ9Rjym.js +0 -1
  160. package/public/dist/assets/iframe-renderer-DSLsfZq8.js +0 -1
  161. package/public/dist/assets/memory-BgUF3M9l.js +0 -1
  162. package/public/dist/assets/message-history-DkB768L3.js +0 -1
  163. package/public/dist/assets/page-shell-DfinU3mb.js +0 -1
  164. package/public/dist/assets/settings-DxjoyiIM.js +0 -1
  165. package/public/dist/assets/settings-core-CXWmPKUE.js +0 -1
  166. package/public/dist/assets/settings-core-D4zGy72z.js +0 -2
  167. package/public/dist/assets/skills-BY-hQnqM.js +0 -1
  168. package/public/dist/assets/slash-commands-C53T9J3g.js +0 -1
  169. package/public/dist/assets/ui-C0o427q6.js +0 -1
@@ -37,7 +37,7 @@ import {
37
37
  type MemoryBlock,
38
38
  type MemoryEntry,
39
39
  } from './components/memory-helpers';
40
- import { metaFor, selectableRuntimeOptions, isRetiredCliSelection, retiredRuntimeLabel } from './components/agent/agent-meta';
40
+ import { metaFor, auxiliaryRuntimeOptions, isRetiredCliSelection, retiredRuntimeLabel } from './components/agent/agent-meta';
41
41
 
42
42
  // Re-export pure helpers for unit tests (Heartbeat pattern).
43
43
  export {
@@ -125,6 +125,7 @@ export default function Memory({ port, client, dirty, registerSave }: SettingsPa
125
125
  if (isMemorySettingsKey(key)) filtered[key] = value;
126
126
  }
127
127
  if (Object.keys(filtered).length === 0) return;
128
+ if (filtered['memory.cli'] === 'aside' || (filtered['memory.cli'] === '' && settingsSnap.state.kind === 'ready' && settingsSnap.state.data['cli'] === 'aside')) throw new Error('Aside cannot run memory flush.');
128
129
  const patch = expandPatch(filtered);
129
130
  const updated = await client.put<SettingsSnapshot>('/api/settings', patch);
130
131
  const fresh = (updated && typeof updated === 'object' && 'data' in updated
@@ -211,12 +212,13 @@ export default function Memory({ port, client, dirty, registerSave }: SettingsPa
211
212
  const retentionError = validatePositiveInt(retentionDays, 'Retention days');
212
213
 
213
214
  const perCli = settingsSnap.state.data.perCli || {};
214
- const cliKeys = selectableRuntimeOptions(Object.keys(perCli));
215
+ const cliKeys = auxiliaryRuntimeOptions(Object.keys(perCli));
216
+ const asideInherited = !cli && settingsSnap.state.data['cli'] === 'aside';
215
217
  const cliOptions = [
216
- { value: '', label: '(profile default)' },
218
+ ...(settingsSnap.state.data['cli'] === 'aside' ? [] : [{ value: '', label: '(profile default)' }]),
217
219
  ...cliKeys.map((c) => ({ value: c, label: metaFor(c).label || c })),
218
220
  ];
219
- if (cli && !isRetiredCliSelection(cli) && !cliOptions.some((opt) => opt.value === cli)) {
221
+ if (cli && cli !== 'aside' && !isRetiredCliSelection(cli) && !cliOptions.some((opt) => opt.value === cli)) {
220
222
  cliOptions.push({ value: cli, label: `${metaFor(cli).label || cli} (legacy)` });
221
223
  }
222
224
 
@@ -276,8 +278,8 @@ export default function Memory({ port, client, dirty, registerSave }: SettingsPa
276
278
  id="memory-cli"
277
279
  label="Flush CLI"
278
280
  value={cli}
279
- missingValueLabel={isRetiredCliSelection(cli) ? retiredRuntimeLabel(cli) : undefined}
280
- error={isRetiredCliSelection(cli) ? 'The saved runtime is retired. Choose an available runtime.' : null}
281
+ missingValueLabel={asideInherited ? '(profile default unavailable)' : cli === 'aside' ? 'Aside (main-only)' : isRetiredCliSelection(cli) ? retiredRuntimeLabel(cli) : undefined}
282
+ error={asideInherited || cli === 'aside' ? 'Aside cannot run memory flush. Choose a separate runtime.' : isRetiredCliSelection(cli) ? 'The saved runtime is retired. Choose an available runtime.' : null}
281
283
  options={cliOptions}
282
284
  onChange={(next) => {
283
285
  setCli(next);
@@ -16,6 +16,7 @@ import {
16
16
  usePageSnapshot,
17
17
  type SnapshotState,
18
18
  } from './page-shell';
19
+ import { useAsideModels, asideSelectionError } from './components/aside-models';
19
20
  import { PerCliRow, type PiRegistration } from './components/PerCliRow';
20
21
  import { metaFor, normalizeCliMetaRegistry, selectableRuntimeOptions, isRetiredCliSelection, retiredRuntimeLabel } from './components/agent/agent-meta';
21
22
  import type { CliMeta, PerCliEntry } from './components/agent/agent-meta';
@@ -29,9 +30,9 @@ type ModelSnapshot = {
29
30
  pi?: PiSettingsView;
30
31
  [key: string]: unknown;
31
32
  };
32
- type ModelInstance = Pick<SettingsPageProps, 'client' | 'port' | 'dirty'>;
33
+ type ModelInstance = Pick<SettingsPageProps, 'client' | 'port' | 'instanceUrl' | 'dirty'>;
33
34
  type BoundSnapshot = { instance: ModelInstance; value: ModelSnapshot };
34
- const ownsModelKey = (key: string) => key === 'fallbackOrder' || key.startsWith('perCli.');
35
+ const ownsModelKey = (key: string) => key === 'fallbackOrder' || key.startsWith('perCli.') || key === 'activeOverrides.aside.model' || key === 'activeOverrides.aside.effort';
35
36
 
36
37
  function savedModelSnapshot(updated: unknown): ModelSnapshot {
37
38
  const value = updated && typeof updated === 'object' && 'data' in updated ? updated.data : updated;
@@ -62,8 +63,8 @@ export function buildResetOverridesPatch(snapshot: ModelSnapshot): {
62
63
  return { activeOverrides };
63
64
  }
64
65
 
65
- export default function ModelProvider({ port, client, dirty, registerSave }: SettingsPageProps) {
66
- const instance = useMemo<ModelInstance>(() => ({ client, port, dirty }), [client, port, dirty]);
66
+ export default function ModelProvider({ port, instanceUrl, client, dirty, registerSave }: SettingsPageProps) {
67
+ const instance = useMemo<ModelInstance>(() => ({ client, port, instanceUrl, dirty }), [client, port, instanceUrl, dirty]);
67
68
  const activeInstance = useRef<ModelInstance | null>(null);
68
69
  const activeOperation = useRef<{ instance: ModelInstance; kind: 'save' | 'reset'; promise: Promise<void> } | null>(null);
69
70
  const metadataGeneration = useRef(0);
@@ -88,6 +89,20 @@ export default function ModelProvider({ port, client, dirty, registerSave }: Set
88
89
  const [saving, setSaving] = useState(false);
89
90
  const [cliMeta, setCliMeta] = useState<Record<string, CliMeta> | null>(null);
90
91
 
92
+ const asideDraft = perCliDraft['aside'];
93
+ const aside = useAsideModels(client, instanceUrl, port, asideDraft?.account || '', !!asideDraft);
94
+ const asideSaveError = asideDraft ? asideDraft.host !== 'local' ? 'Aside requires the local host.'
95
+ : asideSelectionError(aside.inventory, asideDraft.model || '', asideDraft.effort || '') : null;
96
+
97
+ useEffect(() => {
98
+ for (const [key, entry] of dirty.pending) {
99
+ if (key.startsWith('perCli.aside.') || key.startsWith('activeOverrides.aside.')) {
100
+ const valid = !asideSaveError;
101
+ if (entry.valid !== valid) dirty.set(key, { ...entry, valid });
102
+ }
103
+ }
104
+ }, [asideSaveError, dirty]);
105
+
91
106
  useLayoutEffect(() => {
92
107
  activeInstance.current = instance; activeOperation.current = null; ++metadataGeneration.current;
93
108
  setSaving(false); setResetting(false); setSaveError(null); setResetError(null); setCliMeta(null);
@@ -159,8 +174,15 @@ export default function ModelProvider({ port, client, dirty, registerSave }: Set
159
174
  const pending = activeOperation.current;
160
175
  if (pending) return pending.kind === 'save' ? pending.promise
161
176
  : Promise.reject(new Error('Wait for the active override reset before saving.'));
177
+ if ([...dirty.pending.keys()].some(key => key.startsWith('perCli.aside.') || key.startsWith('activeOverrides.aside.')) && asideSaveError) return Promise.reject(new Error(asideSaveError));
162
178
  const bundle = Object.fromEntries(Object.entries(dirty.saveBundle()).filter(([key]) => ownsModelKey(key)));
163
179
  if (Object.keys(bundle).length === 0) return Promise.resolve();
180
+ if (Object.keys(bundle).some(key => key.startsWith('perCli.aside.') || key.startsWith('activeOverrides.aside.')) && asideSaveError) return Promise.reject(new Error(asideSaveError));
181
+ if (bundle['fallbackOrder'] && (bundle['fallbackOrder'] as string[]).includes('aside')) return Promise.reject(new Error('Aside cannot be used as a fallback.'));
182
+ if (Object.hasOwn(bundle, 'perCli.aside.account')) {
183
+ bundle['activeOverrides.aside.model'] = '';
184
+ bundle['activeOverrides.aside.effort'] = '';
185
+ }
164
186
  const patch = expandPatch(bundle);
165
187
  const submitted = new Map([...dirty.pending].filter(([key]) => Object.hasOwn(bundle, key)));
166
188
  setSaving(true); setSaveError(null);
@@ -182,7 +204,7 @@ export default function ModelProvider({ port, client, dirty, registerSave }: Set
182
204
  });
183
205
  activeOperation.current = operation;
184
206
  return operation.promise;
185
- }, [client, dirty, instance, loadCliMeta, refresh, setData]);
207
+ }, [client, dirty, instance, loadCliMeta, refresh, setData, asideSaveError]);
186
208
 
187
209
  useEffect(() => {
188
210
  if (!registerSave) return;
@@ -254,6 +276,8 @@ export default function ModelProvider({ port, client, dirty, registerSave }: Set
254
276
  setValue={(next) => { if (canEdit()) setPerCliDraft({ ...perCliDraft, [cli]: next }); }}
255
277
  setEntry={setEntry}
256
278
  client={client}
279
+ aside={aside}
280
+ asideOverride={overrides[cli] || {}}
257
281
  pi={piDraft}
258
282
  setPi={(next) => { if (canEdit()) setPiDraft(next); }}
259
283
  onPiRegistered={onPiRegistered}
@@ -270,6 +294,7 @@ export default function ModelProvider({ port, client, dirty, registerSave }: Set
270
294
  id="model-fallbackOrder"
271
295
  label="Fallback order"
272
296
  error={(() => {
297
+ if (fallback.includes('aside')) return 'Aside is main-only. Remove it from the fallback order.';
273
298
  const retired = fallback.find(isRetiredCliSelection);
274
299
  if (!retired) return null;
275
300
  const name = retiredRuntimeLabel(retired).replace(' (retired)', '');
@@ -283,7 +308,7 @@ export default function ModelProvider({ port, client, dirty, registerSave }: Set
283
308
  setEntry('fallbackOrder', {
284
309
  value: next,
285
310
  original: data.fallbackOrder || [],
286
- valid: !next.some(isRetiredCliSelection),
311
+ valid: !next.includes('aside') && !next.some(isRetiredCliSelection),
287
312
  });
288
313
  }}
289
314
  placeholder="cli name"
@@ -118,8 +118,8 @@ export default function Profile({ port, client, dirty, registerSave }: SettingsP
118
118
  </SettingsSection>
119
119
 
120
120
  <SettingsSection title="Avatars" hint="Uploads write to the instance's home folder.">
121
- <AvatarCard kind="agent" port={port} />
122
- <AvatarCard kind="user" port={port} />
121
+ <AvatarCard kind="agent" client={client} />
122
+ <AvatarCard kind="user" client={client} />
123
123
  </SettingsSection>
124
124
  </form>
125
125
  );
@@ -0,0 +1,33 @@
1
+ import { SelectField, TextField } from '../../fields';
2
+ import { asideEffortChoices, asideModelChoices, asideSelectionError, isAsideAccount, type AsideInventory } from './aside-models';
3
+
4
+ type Props = {
5
+ id: string; account: string; host?: string | undefined; model: string; effort: string;
6
+ inventory: AsideInventory; disabled?: boolean;
7
+ onAccountChange(value: string): void; onModelChange(value: string): void; onEffortChange(value: string): void;
8
+ refresh(): void;
9
+ };
10
+
11
+ export function AsideSelectionFields({ id, account, host, model, effort, inventory, disabled = false,
12
+ onAccountChange, onModelChange, onEffortChange, refresh }: Props) {
13
+ const models = asideModelChoices(inventory);
14
+ const efforts = asideEffortChoices(inventory, model);
15
+ const error = asideSelectionError(inventory, model, effort);
16
+ return <>
17
+ <p className="settings-percli-note">Experimental · local main agent only. Scheduled heartbeat runs are unsupported with Aside as the main runtime and will be rejected. Account selection does not confirm sign-in or model access.</p>
18
+ <TextField id={`${id}-account`} label="Aside account" value={account} placeholder="uN" disabled={disabled}
19
+ error={isAsideAccount(account) ? null : 'Enter an explicit account (uN).'} onChange={onAccountChange} />
20
+ <TextField id={`${id}-host`} label="Host" value={host || 'local'} disabled
21
+ error={host && host !== 'local' ? 'Only the local host is supported. Edit the account to use local.' : null} onChange={() => {}} />
22
+ <SelectField id={`${id}-model`} label="Model" value={model || 'default'} options={models}
23
+ missingValueLabel={`${model || 'default'} (unavailable)`} disabled={disabled || inventory.kind !== 'ready' || models.length === 0}
24
+ error={error} onChange={onModelChange} />
25
+ <SelectField id={`${id}-effort`} label="Effort" value={effort === 'default' ? '' : effort}
26
+ options={[{ value: '', label: '(default)' }, ...efforts.map(value => ({ value, label: value }))]}
27
+ missingValueLabel={`${effort} (unavailable)`} disabled={disabled || inventory.kind !== 'ready' || (efforts.length === 0 && !effort)}
28
+ error={error?.includes('effort') ? error : null} onChange={onEffortChange} />
29
+ {inventory.kind === 'ready' && inventory.catalog.status !== 'available'
30
+ ? <p className="settings-percli-note" role="status">Catalog {inventory.catalog.status}{inventory.catalog.diagnostics.map(d => ` · ${d.code}`).join('')}</p> : null}
31
+ <button type="button" className="settings-action" disabled={disabled || !isAsideAccount(account)} onClick={refresh}>Refresh Aside models</button>
32
+ </>;
33
+ }
@@ -1,12 +1,11 @@
1
1
  // Phase 2 — avatar upload card.
2
2
  //
3
- // Avatar upload bypasses the JSON settings client because the route expects a
4
- // raw image body (`Content-Type: image/*` or `application/octet-stream`) plus
5
- // `X-Filename`. We mount our own fetch + state instead of registering with the
6
- // page-level dirty store: uploads are atomic side-effects, not pending edits.
3
+ // Avatar uploads use the configured settings transport with a raw image body.
4
+ // They remain atomic side-effects outside the page-level dirty store.
7
5
 
8
6
  import { useEffect, useRef, useState } from 'react';
9
7
  import { icon } from '../../../../../js/icons';
8
+ import type { SettingsClient } from '../../types';
10
9
 
11
10
  type AvatarKind = 'agent' | 'user';
12
11
 
@@ -20,12 +19,12 @@ type EnvelopeMeta =
20
19
 
21
20
  type Props = {
22
21
  kind: AvatarKind;
23
- port: number;
22
+ client: SettingsClient;
24
23
  };
25
24
 
26
25
  const ACCEPTED_TYPES = 'image/png,image/jpeg,image/webp,image/gif';
27
26
 
28
- export function AvatarCard({ kind, port }: Props) {
27
+ export function AvatarCard({ kind, client }: Props) {
29
28
  const [meta, setMeta] = useState<AvatarMeta | null>(null);
30
29
  const [busy, setBusy] = useState(false);
31
30
  const [error, setError] = useState<string | null>(null);
@@ -35,9 +34,8 @@ export function AvatarCard({ kind, port }: Props) {
35
34
  let cancelled = false;
36
35
  setError(null);
37
36
  setMeta(null);
38
- fetch(`/i/${port}/api/avatar`)
39
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`avatar HTTP ${r.status}`))))
40
- .then((envelope: EnvelopeMeta) => {
37
+ client.get<EnvelopeMeta>('/api/avatar')
38
+ .then((envelope) => {
41
39
  if (cancelled) return;
42
40
  const inner = 'data' in envelope && envelope.data ? envelope.data : envelope;
43
41
  const got = (inner as { agent?: AvatarMeta; user?: AvatarMeta })[kind] || null;
@@ -50,7 +48,7 @@ export function AvatarCard({ kind, port }: Props) {
50
48
  return () => {
51
49
  cancelled = true;
52
50
  };
53
- }, [kind, port]);
51
+ }, [kind, client]);
54
52
 
55
53
  const onPick = () => inputRef.current?.click();
56
54
 
@@ -60,21 +58,17 @@ export function AvatarCard({ kind, port }: Props) {
60
58
  try {
61
59
  const buf = await file.arrayBuffer();
62
60
  const ct = file.type || 'application/octet-stream';
63
- const response = await fetch(`/i/${port}/api/avatar/${kind}/upload`, {
64
- method: 'POST',
65
- headers: {
66
- 'content-type': ct,
67
- 'x-filename': encodeURIComponent(file.name),
61
+ const envelope = await client.post<{ ok?: boolean; data?: AvatarMeta } | AvatarMeta>(
62
+ `/api/avatar/${kind}/upload`, undefined, {
63
+ headers: {
64
+ 'content-type': ct,
65
+ 'x-filename': encodeURIComponent(file.name),
66
+ },
67
+ body: buf,
68
+ // Image transfers may exceed the client's JSON request timeout.
69
+ signal: new AbortController().signal,
68
70
  },
69
- body: buf,
70
- });
71
- if (!response.ok) {
72
- const text = await response.text().catch(() => '');
73
- throw new Error(`upload failed: ${response.status} ${text}`);
74
- }
75
- const envelope = (await response.json()) as
76
- | { ok?: boolean; data?: AvatarMeta }
77
- | AvatarMeta;
71
+ );
78
72
  const next: AvatarMeta = 'data' in envelope && envelope.data
79
73
  ? (envelope.data as AvatarMeta)
80
74
  : (envelope as AvatarMeta);
@@ -91,15 +85,9 @@ export function AvatarCard({ kind, port }: Props) {
91
85
  setBusy(true);
92
86
  setError(null);
93
87
  try {
94
- const response = await fetch(`/i/${port}/api/avatar/${kind}/image`, {
95
- method: 'DELETE',
96
- });
97
- if (!response.ok) {
98
- throw new Error(`clear failed: ${response.status}`);
99
- }
100
- const envelope = (await response.json()) as
101
- | { ok?: boolean; data?: AvatarMeta }
102
- | AvatarMeta;
88
+ const envelope = await client.delete<{ ok?: boolean; data?: AvatarMeta } | AvatarMeta>(
89
+ `/api/avatar/${kind}/image`,
90
+ );
103
91
  const next: AvatarMeta = 'data' in envelope && envelope.data
104
92
  ? (envelope.data as AvatarMeta)
105
93
  : (envelope as AvatarMeta);
@@ -112,7 +100,7 @@ export function AvatarCard({ kind, port }: Props) {
112
100
  };
113
101
 
114
102
  const imageUrl = meta && meta.kind === 'image'
115
- ? `/i/${port}${meta.imageUrl}`
103
+ ? client.url(meta.imageUrl)
116
104
  : null;
117
105
  const label = kind === 'agent' ? 'Agent avatar' : 'User avatar';
118
106
 
@@ -10,6 +10,8 @@ import { coerceEffortForModel, effortChoicesForModel } from './agent/agent-meta'
10
10
  import type { CliMeta, PerCliEntry } from './agent/agent-meta';
11
11
  import { PiProfileDialog } from './PiProfileDialog';
12
12
  import { piModelOptions, piProfileOptions, type PiSettingsView } from './pi-profile';
13
+ import { AsideSelectionFields } from './AsideSelectionFields';
14
+ import { type AsideInventory, isAsideAccount } from './aside-models';
13
15
  import { hasRuntimeTransportChoice, RuntimeTransportField } from './runtime-transport-field';
14
16
  export type PiRegistration = Parameters<ComponentProps<typeof PiProfileDialog>['onRegistered']>[0];
15
17
 
@@ -23,6 +25,8 @@ type Props = {
23
25
  dirty: DirtyStore;
24
26
  disabled?: boolean;
25
27
  client?: SettingsClient;
28
+ aside?: { inventory: AsideInventory; refresh(): void };
29
+ asideOverride?: { model?: string; effort?: string };
26
30
  pi?: PiSettingsView | undefined;
27
31
  setPi?: (next: PiSettingsView) => void;
28
32
  onPiRegistered?: (next: PiRegistration) => void;
@@ -32,7 +36,7 @@ function entryFor(value: unknown, original: unknown, valid = true): DirtyEntry {
32
36
  return { value, original, valid };
33
37
  }
34
38
 
35
- export function PerCliRow({ cli, meta, original, value, setValue, setEntry, dirty, disabled = false, client, pi, setPi, onPiRegistered }: Props) {
39
+ export function PerCliRow({ cli, meta, original, value, setValue, setEntry, dirty, disabled = false, client, aside, asideOverride, pi, setPi, onPiRegistered }: Props) {
36
40
  const modelDatalistId = `percli-${cli}-models`;
37
41
  const isPi = cli === 'pi';
38
42
  const [piDialogOpen, setPiDialogOpen] = useState(false);
@@ -43,6 +47,30 @@ export function PerCliRow({ cli, meta, original, value, setValue, setEntry, dirt
43
47
  blocked.current = disabled;
44
48
  return () => { blocked.current = true; };
45
49
  }, [disabled]);
50
+ if (cli === 'aside' && aside) return <div className="settings-percli-row" data-cli={cli}>
51
+ <SettingsToolbar><strong className="settings-percli-title">Aside</strong></SettingsToolbar>
52
+ <AsideSelectionFields id="percli-aside" account={value.account || ''} host={value.host}
53
+ model={value.model || ''} effort={value.effort || ''} inventory={aside.inventory} disabled={disabled}
54
+ refresh={aside.refresh}
55
+ onAccountChange={next => {
56
+ if (blocked.current) return;
57
+ setValue({ ...value, account: next, host: 'local' });
58
+ setEntry('perCli.aside.account', entryFor(next, original.account || '', isAsideAccount(next)));
59
+ setEntry('perCli.aside.host', entryFor('local', original.host || ''));
60
+ setEntry('activeOverrides.aside.model', entryFor('', asideOverride?.model || ''));
61
+ setEntry('activeOverrides.aside.effort', entryFor('', asideOverride?.effort || ''));
62
+ }}
63
+ onModelChange={next => {
64
+ if (blocked.current) return;
65
+ setValue({ ...value, model: next });
66
+ setEntry('perCli.aside.model', entryFor(next, original.model || ''));
67
+ }}
68
+ onEffortChange={next => {
69
+ if (blocked.current) return;
70
+ setValue({ ...value, effort: next });
71
+ setEntry('perCli.aside.effort', entryFor(next, original.effort || ''));
72
+ }} />
73
+ </div>;
46
74
  const provider = value.provider || meta.defaultProvider || meta.providers?.[0] || 'claude';
47
75
  const piModels = isPi ? piModelOptions(pi, provider, value.model || '') : [];
48
76
  const hasProviders = !isPi && (meta.providers?.length ?? 0) > 0;
@@ -1,6 +1,6 @@
1
1
  import { SelectField } from '../../../fields';
2
2
  import { SettingsSection } from '../../page-shell';
3
- import { metaFor, selectableRuntimeOptions, isRetiredCliSelection, retiredRuntimeLabel, type CliMeta } from './agent-meta';
3
+ import { metaFor, auxiliaryRuntimeOptions, isRetiredCliSelection, retiredRuntimeLabel, type CliMeta } from './agent-meta';
4
4
 
5
5
  type FlushAgentSectionProps = {
6
6
  activeCli: string;
@@ -28,6 +28,7 @@ export function FlushAgentSection({
28
28
  onFlushModelChange,
29
29
  }: FlushAgentSectionProps) {
30
30
  const effectiveCli = flushCli || activeCli;
31
+ const unsupported = effectiveCli === 'aside';
31
32
  const effectiveCliLabel = effectiveCli ? metaFor(effectiveCli, cliMeta).label || effectiveCli : 'active';
32
33
  return (
33
34
  <SettingsSection
@@ -46,11 +47,11 @@ export function FlushAgentSection({
46
47
  id="agent-flush-cli"
47
48
  label="Flush CLI"
48
49
  value={flushCli}
49
- missingValueLabel={isRetiredCliSelection(flushCli) ? retiredRuntimeLabel(flushCli) : undefined}
50
- error={isRetiredCliSelection(effectiveCli) ? 'The saved flush runtime is retired. Choose an available runtime.' : null}
50
+ missingValueLabel={!flushCli && activeCli === 'aside' ? '(active CLI unavailable)' : flushCli === 'aside' ? 'Aside (main-only)' : isRetiredCliSelection(flushCli) ? retiredRuntimeLabel(flushCli) : undefined}
51
+ error={unsupported ? 'Aside cannot run memory flush. Choose a separate runtime.' : isRetiredCliSelection(effectiveCli) ? 'The saved flush runtime is retired. Choose an available runtime.' : null}
51
52
  options={[
52
- { value: '', label: '(active CLI)' },
53
- ...selectableRuntimeOptions(cliOptions).map((value) => ({ value, label: metaFor(value, cliMeta).label || value })),
53
+ ...(activeCli === 'aside' ? [] : [{ value: '', label: '(active CLI)' }]),
54
+ ...auxiliaryRuntimeOptions(cliOptions).map((value) => ({ value, label: metaFor(value, cliMeta).label || value })),
54
55
  ]}
55
56
  onChange={onFlushCliChange}
56
57
  />
@@ -58,7 +59,7 @@ export function FlushAgentSection({
58
59
  id="agent-flush-model"
59
60
  label="Flush model"
60
61
  value={flushModel}
61
- disabled={isRetiredCliSelection(effectiveCli)}
62
+ disabled={unsupported || isRetiredCliSelection(effectiveCli)}
62
63
  options={[{ value: '', label: '(default)' }, ...modelOptions]}
63
64
  onChange={onFlushModelChange}
64
65
  />
@@ -1,6 +1,6 @@
1
1
  import { SelectField, TextField } from '../../../fields';
2
2
  import { SettingsActions, StatusBadge } from '../../page-shell';
3
- import { metaFor, optionList, selectableRuntimeOptions, isRetiredCliSelection, retiredRuntimeLabel, type CliMeta } from './agent-meta';
3
+ import { metaFor, optionList, auxiliaryRuntimeOptions, isRetiredCliSelection, retiredRuntimeLabel, type CliMeta } from './agent-meta';
4
4
  import {
5
5
  isStaticEmployee,
6
6
  runtimeEmployeeError,
@@ -28,8 +28,8 @@ export function RuntimeEmployeeRow({
28
28
  const meta = metaFor(employee.cli, cliMeta);
29
29
  const modelOptions = optionList(meta.models, employee.model);
30
30
  const error = runtimeEmployeeError(employee);
31
- const retired = isRetiredCliSelection(employee.cli);
32
- const cliChoices = selectableRuntimeOptions(Array.from(new Set([...cliOptions, employee.cli, 'claude'])).filter(Boolean));
31
+ const retired = isRetiredCliSelection(employee.cli) || employee.cli === 'aside';
32
+ const cliChoices = auxiliaryRuntimeOptions(Array.from(new Set([...cliOptions, employee.cli, 'claude'])).filter(Boolean));
33
33
 
34
34
  const employeeName = employee.name || `Employee ${index + 1}`;
35
35
  return (
@@ -51,7 +51,7 @@ export function RuntimeEmployeeRow({
51
51
  id={`runtime-employee-${employee.id}-cli`}
52
52
  label="CLI"
53
53
  value={employee.cli}
54
- missingValueLabel={isRetiredCliSelection(employee.cli) ? retiredRuntimeLabel(employee.cli) : undefined}
54
+ missingValueLabel={employee.cli === 'aside' ? 'Aside (main-only)' : isRetiredCliSelection(employee.cli) ? retiredRuntimeLabel(employee.cli) : undefined}
55
55
  error={retired ? 'This saved runtime cannot execute. Choose an available runtime.' : null}
56
56
  disabled={locked}
57
57
  options={cliChoices.map((value) => ({ value, label: metaFor(value, cliMeta).label || value }))}
@@ -1,8 +1,10 @@
1
+ import type { ReactNode } from 'react';
1
2
  import { SelectField, TextField } from '../../../fields';
2
3
  import { SettingsSection } from '../../page-shell';
3
4
  import { metaFor, orderRuntimeCliOptions, PRIMARY_CLIS, isRetiredCliSelection, retiredRuntimeLabel, type CliMeta } from './agent-meta';
4
5
 
5
6
  type RuntimeHeaderProps = {
7
+ asideFields?: ReactNode;
6
8
  cli: string;
7
9
  cliOptions: ReadonlyArray<string>;
8
10
  provider?: string;
@@ -22,6 +24,7 @@ type RuntimeHeaderProps = {
22
24
  };
23
25
 
24
26
  export function RuntimeHeader({
27
+ asideFields,
25
28
  cli,
26
29
  cliOptions,
27
30
  provider = '',
@@ -58,6 +61,7 @@ export function RuntimeHeader({
58
61
  collapsedAfter={orderedPrimaryCliCount}
59
62
  onChange={onCliChange}
60
63
  />
64
+ {asideFields || <>
61
65
  {providerOptions.length > 0 ? (
62
66
  <SelectField
63
67
  id={`agent-${cli}-provider`}
@@ -86,6 +90,7 @@ export function RuntimeHeader({
86
90
  disabled={retired || effortOptions.length === 0}
87
91
  onChange={onEffortChange}
88
92
  />
93
+ </>}
89
94
  <TextField
90
95
  id="agent-workingDir"
91
96
  label="Working directory"
@@ -31,6 +31,8 @@ export type CliMeta = {
31
31
  };
32
32
 
33
33
  export type PerCliEntry = {
34
+ account?: string;
35
+ host?: string;
34
36
  transport?: RuntimeTransport;
35
37
  provider?: string;
36
38
  model?: string;
@@ -50,6 +52,7 @@ const CODEX_MODELS: ReadonlyArray<string> = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini
50
52
  export const PRIMARY_CLIS: ReadonlyArray<string> = ['pi', 'claude', 'agy', 'codex', 'cursor', 'kiro-code', 'gemini'];
51
53
 
52
54
  export const CLI_META: Record<string, CliMeta> = {
55
+ aside: { label: 'Aside (experimental)', models: [], efforts: [] },
53
56
  agy: {
54
57
  label: 'Antigravity',
55
58
  // Label form is what `agy --model` accepts when no --effort is sent
@@ -316,6 +319,10 @@ export function selectableRuntimeOptions(cliOptions: ReadonlyArray<string>): str
316
319
  return cliOptions.filter(value => !isRetiredCliSelection(value));
317
320
  }
318
321
 
322
+ export function auxiliaryRuntimeOptions(cliOptions: ReadonlyArray<string>): string[] {
323
+ return selectableRuntimeOptions(cliOptions).filter(cli => cli !== 'aside');
324
+ }
325
+
319
326
  export function orderRuntimeCliOptions(input: ReadonlyArray<string>): string[] {
320
327
  const cliOptions = selectableRuntimeOptions(input);
321
328
  const primary = PRIMARY_CLIS.filter((value) => cliOptions.includes(value));
@@ -1,5 +1,5 @@
1
1
  import type { SettingsClient } from '../../../types';
2
- import { selectableRuntimeOptions, type CliMeta } from './agent-meta';
2
+ import { auxiliaryRuntimeOptions, type CliMeta } from './agent-meta';
3
3
 
4
4
  export type RuntimeEmployeeSource = 'static' | 'db';
5
5
 
@@ -42,7 +42,7 @@ export function makeDefaultRuntimeEmployee(
42
42
  cliOptions: ReadonlyArray<string>,
43
43
  cliMeta?: Record<string, CliMeta> | null,
44
44
  ): RuntimeEmployeeRecord {
45
- const cli = selectableRuntimeOptions(cliOptions)[0] || 'claude';
45
+ const cli = auxiliaryRuntimeOptions(cliOptions)[0] || 'claude';
46
46
  const model = cliMeta?.[cli]?.models?.[0] || 'default';
47
47
  return {
48
48
  id: newRuntimeEmployeeId(),
@@ -81,6 +81,7 @@ export function normalizeRuntimeEmployee(value: unknown): RuntimeEmployeeRecord
81
81
 
82
82
  export function runtimeEmployeeError(row: RuntimeEmployeeRecord): string | null {
83
83
  if (!row.name.trim()) return 'Name is required';
84
+ if (row.cli === 'aside') return 'Aside supports the main agent only';
84
85
  if (!row.cli.trim()) return 'CLI is required';
85
86
  if (!row.model.trim()) return 'Model is required';
86
87
  return null;
@@ -0,0 +1,104 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import type { SettingsClient } from '../../types';
3
+ import type { AsideCatalog, AsideModelEntry, AsideThinkingLevel } from '../../../../../../src/shared/aside-contract';
4
+
5
+ export const isAsideAccount = (account: string) => /^u(?:0|[1-9][0-9]{0,8})$/.test(account);
6
+ const levels: readonly string[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
7
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
8
+ const qualified = (value: unknown): value is string => typeof value === 'string' && /^[^/\s]+\/.+/.test(value);
9
+
10
+ /** SettingsClient returns raw JSON. Reject mismatched context and malformed wire data. */
11
+ export function unwrapAsideCatalog(raw: unknown, account: string): AsideCatalog {
12
+ if (!record(raw) || raw['ok'] !== true || !record(raw['data'])) throw new Error('Aside catalog response is unavailable.');
13
+ const data = raw['data'];
14
+ const context = data['context'];
15
+ if (!record(context) || context['account'] !== account || context['host'] !== 'local'
16
+ || data['source'] !== 'local-files' || !['available', 'partial', 'unavailable'].includes(String(data['status']))
17
+ || !Array.isArray(data['entries']) || !Array.isArray(data['cachedIds']) || !Array.isArray(data['diagnostics'])
18
+ || (data['defaultModel'] !== null && !qualified(data['defaultModel']))) throw new Error('Invalid Aside catalog response.');
19
+ const seen = new Set<string>();
20
+ for (const entry of data['entries']) {
21
+ if (!record(entry) || !qualified(entry['id']) || typeof entry['provider'] !== 'string'
22
+ || typeof entry['modelId'] !== 'string' || entry['id'] !== `${entry['provider']}/${entry['modelId']}`
23
+ || typeof entry['name'] !== 'string' || !['registered', 'unknown'].includes(String(entry['capability']))
24
+ || !Array.isArray(entry['efforts']) || !entry['efforts'].every(e => typeof e === 'string' && levels.includes(e))
25
+ || !record(entry['thinkingLevelMap']) || seen.has(entry['id'])) throw new Error('Invalid Aside model catalog.');
26
+ seen.add(entry['id']);
27
+ }
28
+ const preference = data['configuredDefault'];
29
+ if (preference !== null && (!record(preference) || typeof preference['provider'] !== 'string'
30
+ || typeof preference['modelId'] !== 'string'
31
+ || (preference['thinkingLevel'] !== undefined && !levels.includes(String(preference['thinkingLevel'])))))
32
+ throw new Error('Invalid Aside default model.');
33
+ if (data['defaultModel'] !== null && (!record(preference)
34
+ || data['defaultModel'] !== `${preference['provider']}/${preference['modelId']}`)) throw new Error('Invalid Aside default model.');
35
+ for (const diagnostic of data['diagnostics']) {
36
+ if (!record(diagnostic) || !['context', 'models', 'settings'].includes(String(diagnostic['source']))
37
+ || typeof diagnostic['code'] !== 'string' || typeof diagnostic['message'] !== 'string') throw new Error('Invalid Aside diagnostics.');
38
+ }
39
+ return data as unknown as AsideCatalog;
40
+ }
41
+
42
+ export type AsideInventory = { kind: 'loading' } | { kind: 'error'; message: string } | { kind: 'ready'; catalog: AsideCatalog };
43
+ type Context = { client: SettingsClient; instanceUrl: string; port: number; account: string; enabled: boolean; revision: number };
44
+
45
+ export function useAsideModels(client: SettingsClient, instanceUrl: string, port: number, account: string, enabled = true) {
46
+ const [revision, setRevision] = useState(0);
47
+ const context = useMemo<Context>(() => ({ client, instanceUrl, port, account, enabled, revision }), [client, instanceUrl, port, account, enabled, revision]);
48
+ const generation = useRef(0);
49
+ const [result, setResult] = useState<{ context: Context; value: AsideInventory } | null>(null);
50
+ useLayoutEffect(() => {
51
+ const epoch = ++generation.current;
52
+ let live = true;
53
+ if (enabled && isAsideAccount(account)) {
54
+ void client.get<unknown>(`/api/aside/models?account=${encodeURIComponent(account)}&host=local`).then(raw => {
55
+ if (!live || epoch !== generation.current) return;
56
+ setResult({ context, value: { kind: 'ready', catalog: unwrapAsideCatalog(raw, account) } });
57
+ }).catch(() => {
58
+ if (live && epoch === generation.current) setResult({ context, value: { kind: 'error', message: 'Could not read the local Aside catalog. Refresh to try again.' } });
59
+ });
60
+ }
61
+ return () => { live = false; ++generation.current; };
62
+ }, [context, client, account, enabled]);
63
+ // Hide old inventory in the first render of a changed context, before effects.
64
+ const inventory: AsideInventory = !isAsideAccount(account)
65
+ ? { kind: 'error', message: 'Enter an explicit Aside account (uN).' }
66
+ : result?.context === context ? result.value : { kind: 'loading' };
67
+ const refresh = useCallback(() => setRevision(value => value + 1), []);
68
+ return { inventory, refresh };
69
+ }
70
+
71
+ export function asideModelChoices(inventory: AsideInventory): Array<{ value: string; label: string }> {
72
+ if (inventory.kind !== 'ready') return [];
73
+ const catalog = inventory.catalog;
74
+ if (catalog.status === 'unavailable' || catalog.diagnostics.some(d => d.source === 'models' || d.source === 'context')) return [];
75
+ const ids = new Set(catalog.entries.map(e => e.id));
76
+ if (catalog.defaultModel) ids.add(catalog.defaultModel);
77
+ const defaultEntry = catalog.entries.find(e => e.id === catalog.defaultModel);
78
+ const level = catalog.configuredDefault?.thinkingLevel;
79
+ const defaultReady = !!catalog.defaultModel && (!level || defaultEntry?.capability !== 'registered' || defaultEntry.efforts.includes(level));
80
+ return [
81
+ ...(defaultReady ? [{ value: 'default', label: `Default (${catalog.defaultModel})` }] : []),
82
+ ...Array.from(ids, value => ({ value, label: value })),
83
+ ];
84
+ }
85
+
86
+ export function asideEffortChoices(inventory: AsideInventory, model: string): readonly string[] {
87
+ if (inventory.kind !== 'ready' || !asideModelChoices(inventory).some(e => e.value === (model || 'default'))) return [];
88
+ const catalog = inventory.catalog;
89
+ const concrete = !model || model === 'default' ? catalog.defaultModel : model;
90
+ const entry: AsideModelEntry | undefined = catalog.entries.find(e => e.id === concrete && e.capability === 'registered');
91
+ if (entry) return entry.efforts;
92
+ return concrete === catalog.defaultModel && catalog.configuredDefault?.thinkingLevel ? [catalog.configuredDefault.thinkingLevel] : [];
93
+ }
94
+
95
+ export function asideSelectionError(inventory: AsideInventory, model: string, effort: string): string | null {
96
+ if (inventory.kind === 'loading') return 'Loading Aside models…';
97
+ if (inventory.kind === 'error') return inventory.message;
98
+ if (!asideModelChoices(inventory).some(e => e.value === (model || 'default'))) return 'This model is unavailable for the selected Aside account.';
99
+ const concrete = !model || model === 'default' ? inventory.catalog.defaultModel : model;
100
+ const effectiveEffort = effort && effort !== 'default' ? effort
101
+ : concrete === inventory.catalog.defaultModel ? inventory.catalog.configuredDefault?.thinkingLevel : undefined;
102
+ if (effectiveEffort && !asideEffortChoices(inventory, model).includes(effectiveEffort as AsideThinkingLevel)) return 'This effort is unavailable for the selected Aside model.';
103
+ return null;
104
+ }