pi-jarvis 1.3.2 → 1.4.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.
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
- import { ModelSelectorComponent, SettingsManager, } from "@mariozechner/pi-coding-agent";
2
+ import { JarvisModelPicker } from "./model-picker.js";
3
3
  import { buildMainSessionContext } from "./main-context.js";
4
4
  import { MainSessionTracker } from "./main-session-state.js";
5
5
  import { attachOverlayBridge, JarvisOverlayBridge, JarvisOverlayComponent } from "./overlay.js";
6
6
  import { createJarvisSessionRef, readJarvisSessionRef, JARVIS_SESSION_REF_CUSTOM_TYPE } from "./session-ref.js";
7
- import { clearJarvisModelSelectionSetting, clearJarvisThinkingSelectionSetting, loadJarvisModelSelectionSetting, loadJarvisThinkingSelectionSetting, saveJarvisModelSelectionSetting, saveJarvisThinkingSelectionSetting, } from "./jarvis-config.js";
7
+ import { MalformedJarvisConfigError, clearJarvisModelSelectionSetting, clearJarvisThinkingSelectionSetting, loadJarvisModelSelectionSetting, loadJarvisThinkingSelectionSetting, saveJarvisModelSelectionSetting, saveJarvisThinkingSelectionSetting, } from "./jarvis-config.js";
8
8
  import { JarvisSideSessionRuntime, createSideSessionFile } from "./side-session.js";
9
9
  class StaleJarvisBootError extends Error {
10
10
  constructor() {
@@ -39,7 +39,16 @@ export default function jarvisExtension(pi) {
39
39
  pi.registerCommand("jarvis", {
40
40
  description: "Open the /jarvis side conversation overlay",
41
41
  handler: async (args, ctx) => {
42
+ if (ctx.mode !== "tui") {
43
+ ctx.ui.notify("/jarvis requires Pi's interactive terminal UI.", "warning");
44
+ return;
45
+ }
46
+ if (state.overlayOpen) {
47
+ ctx.ui.notify("/jarvis is already open.", "info");
48
+ return;
49
+ }
42
50
  updateContextState(pi, state, ctx);
51
+ state.overlayOpen = true;
43
52
  void ensureRuntime(pi, state, ctx).catch((error) => {
44
53
  if (isStaleJarvisBootError(error)) {
45
54
  return;
@@ -52,23 +61,37 @@ export default function jarvisExtension(pi) {
52
61
  void flushQueuedMessages(pi, state, ctx);
53
62
  }
54
63
  const overlayView = createOverlayView(pi, state, ctx);
55
- await ctx.ui.custom((tui, theme, _keybindings, done) => {
56
- state.themeProvider = () => theme;
57
- const closeOverlay = () => {
58
- resetTransientAccessControls(state);
59
- done(undefined);
60
- queueMicrotask(() => tui.requestRender());
61
- };
62
- return attachOverlayBridge(new JarvisOverlayComponent(tui, theme, state.bridge, overlayView, closeOverlay), state.bridge, tui);
63
- }, {
64
- overlay: true,
65
- overlayOptions: {
66
- width: "68%",
67
- minWidth: 68,
68
- maxHeight: "82%",
69
- anchor: "center",
70
- },
71
- });
64
+ try {
65
+ await ctx.ui.custom((tui, theme, keybindings, done) => {
66
+ state.themeProvider = () => theme;
67
+ let closed = false;
68
+ const closeOverlay = () => {
69
+ if (closed)
70
+ return;
71
+ closed = true;
72
+ resetTransientAccessControls(state);
73
+ state.bridge.resolveConfirmation(false);
74
+ done(undefined);
75
+ queueMicrotask(() => tui.requestRender());
76
+ };
77
+ state.closeOverlay = closeOverlay;
78
+ return attachOverlayBridge(new JarvisOverlayComponent(tui, theme, state.bridge, overlayView, closeOverlay, keybindings), state.bridge, tui);
79
+ }, {
80
+ overlay: true,
81
+ overlayOptions: {
82
+ width: "68%",
83
+ minWidth: 68,
84
+ maxHeight: "82%",
85
+ anchor: "center",
86
+ },
87
+ });
88
+ }
89
+ finally {
90
+ state.overlayOpen = false;
91
+ state.closeOverlay = undefined;
92
+ resetTransientAccessControls(state);
93
+ state.bridge.resolveConfirmation(false);
94
+ }
72
95
  },
73
96
  });
74
97
  pi.registerCommand("jarvis-model", {
@@ -85,9 +108,22 @@ export default function jarvisExtension(pi) {
85
108
  }
86
109
  const { request, scope, clearScope } = parsedCommand;
87
110
  const scopeLabel = formatJarvisModelSelectionScope(scope);
88
- const selectModelFromMenu = async (initialSearchInput) => {
89
- const selectorSettingsManager = SettingsManager.inMemory();
90
- return ctx.ui.custom((tui, _theme, _keybindings, done) => new ModelSelectorComponent(tui, getDesiredJarvisModel(state), selectorSettingsManager, ctx.modelRegistry, [], (model) => done(model), () => done(undefined), initialSearchInput));
111
+ const loadModels = async () => {
112
+ try {
113
+ return await getAvailableJarvisModels(ctx.modelRegistry);
114
+ }
115
+ catch (error) {
116
+ ctx.ui.notify(`Failed to refresh /jarvis models: ${error instanceof Error ? error.message : String(error)}`, "error");
117
+ return [];
118
+ }
119
+ };
120
+ const selectModelFromMenu = async (models, initialSearchInput) => {
121
+ models = models.filter((model) => model.api !== "pi-virtual");
122
+ if (models.length === 0) {
123
+ ctx.ui.notify("No /jarvis models are currently available from the main model registry.", "warning");
124
+ return undefined;
125
+ }
126
+ return ctx.ui.custom((tui, theme, keybindings, done) => new JarvisModelPicker(tui, theme, keybindings, models, (model) => done(model), () => done(undefined), initialSearchInput));
91
127
  };
92
128
  const rollbackSelection = async (previousSelection, previousSource) => {
93
129
  await applyJarvisModelSelection(state, previousSelection);
@@ -96,10 +132,12 @@ export default function jarvisExtension(pi) {
96
132
  const persistSelection = async (selection) => {
97
133
  const previousSelection = state.jarvisModelSelection;
98
134
  const previousSource = state.jarvisModelSelectionSource;
99
- await applyJarvisModelSelection(state, selection);
135
+ const stored = toStoredJarvisModelSelection(selection);
136
+ const resolved = resolveJarvisModelSelectionFromSettings(scope === "project" ? stored : loadJarvisModelSelectionForClear(ctx.cwd, "project", ctx), scope === "global" ? stored : loadJarvisModelSelectionForClear(ctx.cwd, "global", ctx), ctx.modelRegistry);
137
+ await applyJarvisModelSelection(state, resolved.selection);
100
138
  try {
101
- saveJarvisModelSelectionSetting(ctx.cwd, scope, toStoredJarvisModelSelection(selection));
102
- state.jarvisModelSelectionSource = scope;
139
+ saveJarvisModelSelectionSetting(ctx.cwd, scope, stored);
140
+ state.jarvisModelSelectionSource = resolved.source;
103
141
  }
104
142
  catch (error) {
105
143
  await rollbackSelection(previousSelection, previousSource);
@@ -131,7 +169,7 @@ export default function jarvisExtension(pi) {
131
169
  ctx.ui.notify(`Failed to pin /jarvis to ${formatModelLabel(model)} ${scopeLabel}: ${error instanceof Error ? error.message : String(error)}`, "error");
132
170
  return;
133
171
  }
134
- ctx.ui.notify(`Pinned /jarvis to ${formatModelLabel(model)} ${scopeLabel}. The main model is still ${formatModelLabel(state.model)}.`, "info");
172
+ ctx.ui.notify(`Saved /jarvis model ${formatModelLabel(model)} ${scopeLabel}. /jarvis is ${describeJarvisModelSelection(state)}. The main model is still ${formatModelLabel(state.model)}.`, "info");
135
173
  };
136
174
  if (clearScope) {
137
175
  let resolvedSelection;
@@ -156,35 +194,35 @@ export default function jarvisExtension(pi) {
156
194
  ctx.ui.notify(`Failed to switch /jarvis back to follow-main ${scopeLabel}: ${error instanceof Error ? error.message : String(error)}`, "error");
157
195
  return;
158
196
  }
159
- ctx.ui.notify(`Set /jarvis to follow the main model (${formatModelLabel(state.model)}) ${scopeLabel}.`, "info");
197
+ ctx.ui.notify(`Saved follow-main ${scopeLabel}. /jarvis is ${describeJarvisModelSelection(state)}.`, "info");
160
198
  return;
161
199
  }
162
200
  if (!request) {
163
- if (!ctx.hasUI) {
201
+ if (ctx.mode !== "tui") {
164
202
  ctx.ui.notify(`/jarvis is ${describeJarvisModelSelection(state)}. Use /jarvis-model [--project|--global] clear, /jarvis-model [--project|--global] follow-main, or /jarvis-model [--project|--global] <provider/model>.`, "info");
165
203
  return;
166
204
  }
167
- const selectedModel = await selectModelFromMenu();
205
+ const selectedModel = await selectModelFromMenu(await loadModels());
168
206
  if (!selectedModel) {
169
207
  return;
170
208
  }
171
209
  await pinSelectedModel(selectedModel);
172
210
  return;
173
211
  }
174
- const availableModels = getAvailableJarvisModels(ctx.modelRegistry);
212
+ const availableModels = await loadModels();
175
213
  const exactModel = findExactAvailableModelMatch(request, availableModels);
176
214
  if (exactModel) {
177
215
  await pinSelectedModel(exactModel);
178
216
  return;
179
217
  }
180
- if (!ctx.hasUI) {
218
+ if (ctx.mode !== "tui") {
181
219
  const errorMessage = availableModels.length === 0
182
220
  ? "No /jarvis models are currently available from the main model registry."
183
221
  : `Unknown /jarvis model "${request}". Use /jarvis-model [--project|--global] clear, /jarvis-model [--project|--global] follow-main, or an exact provider/model from the current model registry.`;
184
222
  ctx.ui.notify(errorMessage, "error");
185
223
  return;
186
224
  }
187
- const selectedModel = await selectModelFromMenu(request);
225
+ const selectedModel = await selectModelFromMenu(availableModels, request);
188
226
  if (!selectedModel) {
189
227
  return;
190
228
  }
@@ -212,10 +250,11 @@ export default function jarvisExtension(pi) {
212
250
  const persistSelection = async (selection) => {
213
251
  const previousSelection = state.jarvisThinkingSelection;
214
252
  const previousSource = state.jarvisThinkingSelectionSource;
215
- await applyJarvisThinkingSelection(state, selection);
253
+ const resolved = resolveJarvisThinkingSelectionFromSettings(scope === "project" ? selection : loadJarvisThinkingSelectionForClear(ctx.cwd, "project", ctx), scope === "global" ? selection : loadJarvisThinkingSelectionForClear(ctx.cwd, "global", ctx));
254
+ await applyJarvisThinkingSelection(state, resolved.selection);
216
255
  try {
217
256
  saveJarvisThinkingSelectionSetting(ctx.cwd, scope, selection);
218
- state.jarvisThinkingSelectionSource = scope;
257
+ state.jarvisThinkingSelectionSource = resolved.source;
219
258
  }
220
259
  catch (error) {
221
260
  await rollbackSelection(previousSelection, previousSource);
@@ -262,7 +301,7 @@ export default function jarvisExtension(pi) {
262
301
  selection = { mode: "pinned", thinkingLevel: normalizedRequest };
263
302
  }
264
303
  if (!selection) {
265
- ctx.ui.notify(`/jarvis is ${describeJarvisThinkingSelection(state)}. Use /jarvis-thinking [--project|--global] clear, auto, follow-main, off, minimal, low, medium, high, or xhigh.`, request ? "error" : "info");
304
+ ctx.ui.notify(`/jarvis is ${describeJarvisThinkingSelection(state)}. Use /jarvis-thinking [--project|--global] clear, auto, follow-main, off, minimal, low, medium, high, xhigh, or max.`, request ? "error" : "info");
266
305
  return;
267
306
  }
268
307
  try {
@@ -276,6 +315,7 @@ export default function jarvisExtension(pi) {
276
315
  },
277
316
  });
278
317
  pi.on("session_start", async (_event, ctx) => {
318
+ state.closeOverlay?.();
279
319
  state.bootGeneration += 1;
280
320
  state.runtime?.dispose();
281
321
  state.runtime = undefined;
@@ -324,6 +364,29 @@ export default function jarvisExtension(pi) {
324
364
  state.mainSession.handleAgentEnd();
325
365
  refreshMainContext(state);
326
366
  });
367
+ pi.on("agent_settled", async (_event, ctx) => {
368
+ updateContextState(pi, state, ctx);
369
+ state.mainSession.handleAgentSettled();
370
+ refreshMainContext(state);
371
+ });
372
+ pi.on("session_tree", async (_event, ctx) => {
373
+ const sessionRef = readJarvisSessionRef(ctx.sessionManager.getBranch());
374
+ if (sessionRef?.file !== state.sessionRef?.file) {
375
+ state.closeOverlay?.();
376
+ state.bootGeneration += 1;
377
+ state.runtime?.dispose();
378
+ state.runtime = undefined;
379
+ state.bootPromise = undefined;
380
+ state.flushPromise = undefined;
381
+ state.queuedMessages = [];
382
+ state.lastJarvisSeenMainContext = undefined;
383
+ state.sessionRef = sessionRef;
384
+ resetTransientAccessControls(state);
385
+ state.bridge.reset();
386
+ }
387
+ state.mainSession.reset(formatModelLabel(ctx.model));
388
+ updateContextState(pi, state, ctx);
389
+ });
327
390
  pi.on("message_start", async (event, ctx) => {
328
391
  updateContextState(pi, state, ctx);
329
392
  state.mainSession.handleMessageStart(event);
@@ -366,7 +429,17 @@ export default function jarvisExtension(pi) {
366
429
  state.bridge.notify(`Failed to sync /jarvis model: ${error instanceof Error ? error.message : String(error)}`, "error");
367
430
  }
368
431
  });
432
+ pi.on("thinking_level_select", async (_event, ctx) => {
433
+ updateContextState(pi, state, ctx);
434
+ try {
435
+ await syncRuntimeModelSelection(state);
436
+ }
437
+ catch (error) {
438
+ state.bridge.notify(`Failed to sync /jarvis thinking: ${error instanceof Error ? error.message : String(error)}`, "error");
439
+ }
440
+ });
369
441
  pi.on("session_shutdown", async () => {
442
+ state.closeOverlay?.();
370
443
  state.bootGeneration += 1;
371
444
  state.runtime?.dispose();
372
445
  state.runtime = undefined;
@@ -399,6 +472,7 @@ function updateContextState(pi, state, ctx) {
399
472
  }
400
473
  function refreshMainContext(state) {
401
474
  state.mainContext = buildMainSessionContext(state.mainSession.snapshot());
475
+ state.bridge.refresh();
402
476
  }
403
477
  function resetTransientAccessControls(state) {
404
478
  state.allowSideTools = false;
@@ -471,7 +545,7 @@ function parseJarvisThinkingCommand(args) {
471
545
  };
472
546
  }
473
547
  function isJarvisThinkingLevel(value) {
474
- return value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
548
+ return value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max";
475
549
  }
476
550
  function formatJarvisThinkingSelection(selection) {
477
551
  if (selection.mode === "auto") {
@@ -582,7 +656,9 @@ function loadJarvisModelSelectionForClear(cwd, scope, ctx) {
582
656
  return loadJarvisModelSelectionSetting(cwd, scope);
583
657
  }
584
658
  catch (error) {
585
- ctx.ui.notify(`Ignoring malformed ${scope} /jarvis model setting while clearing the requested scope: ${error instanceof Error ? error.message : String(error)}`, "warning");
659
+ if (!(error instanceof MalformedJarvisConfigError))
660
+ throw error;
661
+ ctx.ui.notify(`Ignoring malformed ${scope} /jarvis model setting while resolving settings: ${error instanceof Error ? error.message : String(error)}`, "warning");
586
662
  return undefined;
587
663
  }
588
664
  }
@@ -591,7 +667,9 @@ function loadJarvisThinkingSelectionForClear(cwd, scope, ctx) {
591
667
  return loadJarvisThinkingSelectionSetting(cwd, scope);
592
668
  }
593
669
  catch (error) {
594
- ctx.ui.notify(`Ignoring malformed ${scope} /jarvis thinking setting while clearing the requested scope: ${error instanceof Error ? error.message : String(error)}`, "warning");
670
+ if (!(error instanceof MalformedJarvisConfigError))
671
+ throw error;
672
+ ctx.ui.notify(`Ignoring malformed ${scope} /jarvis thinking setting while resolving settings: ${error instanceof Error ? error.message : String(error)}`, "warning");
595
673
  return undefined;
596
674
  }
597
675
  }
@@ -623,14 +701,9 @@ function describeJarvisModelSelection(state) {
623
701
  ? `following the main model (${activeModelLabel}; ${sourceLabel})`
624
702
  : `pinned to ${activeModelLabel} (${sourceLabel})`;
625
703
  }
626
- function getAvailableJarvisModels(modelRegistry) {
627
- modelRegistry.refresh();
628
- try {
629
- return modelRegistry.getAvailable();
630
- }
631
- catch {
632
- return [];
633
- }
704
+ async function getAvailableJarvisModels(modelRegistry) {
705
+ await modelRegistry.refresh();
706
+ return modelRegistry.getAvailable();
634
707
  }
635
708
  function findExactAvailableModelMatch(modelReference, availableModels) {
636
709
  const trimmedReference = modelReference.trim();
@@ -670,6 +743,9 @@ async function syncRuntimeModelSelection(state) {
670
743
  await state.runtime.syncModel(getDesiredJarvisModel(state), getDesiredJarvisThinkingLevel(state));
671
744
  }
672
745
  async function applyJarvisModelSelection(state, selection) {
746
+ if (selection.mode === "pinned" && selection.model.api === "pi-virtual") {
747
+ throw new Error("/jarvis requires a physical model; Pi's public registry does not expose virtual routing.");
748
+ }
673
749
  const previousSelection = state.jarvisModelSelection;
674
750
  state.jarvisModelSelection = selection;
675
751
  try {
@@ -721,14 +797,16 @@ async function executeJarvisSideCommand(pi, state, ctx, runtime, command) {
721
797
  });
722
798
  return runtime;
723
799
  }
724
- state.bootGeneration += 1;
800
+ const generation = ++state.bootGeneration;
725
801
  state.runtime?.dispose();
726
802
  state.runtime = undefined;
727
803
  state.bootPromise = undefined;
728
- state.flushPromise = undefined;
729
804
  state.lastJarvisSeenMainContext = undefined;
730
805
  resetTransientAccessControls(state);
806
+ state.bridge.reset();
731
807
  const sessionFile = await createSideSessionFile(ctx.cwd);
808
+ if (generation !== state.bootGeneration)
809
+ throw new StaleJarvisBootError();
732
810
  const sessionRef = createJarvisSessionRef(sessionFile);
733
811
  state.sessionRef = sessionRef;
734
812
  pi.appendEntry(JARVIS_SESSION_REF_CUSTOM_TYPE, sessionRef);
@@ -759,6 +837,8 @@ function parseJarvisSideCommand(message) {
759
837
  }
760
838
  if (text.startsWith("/tree ")) {
761
839
  const args = text.slice("/tree ".length).trim();
840
+ if (args === "--summarize")
841
+ throw new Error("Use /tree --summarize <entry-id> [instructions].");
762
842
  const summarizePrefix = "--summarize ";
763
843
  if (args.startsWith(summarizePrefix)) {
764
844
  const rest = args.slice(summarizePrefix.length).trim();
@@ -776,6 +856,7 @@ function parseJarvisSideCommand(message) {
776
856
  return undefined;
777
857
  }
778
858
  function createOverlayView(pi, state, ctx) {
859
+ const queue = state.queuedMessages;
779
860
  const syncToolAccess = () => {
780
861
  state.runtime?.setToolAccessEnabled(state.allowSideTools);
781
862
  };
@@ -814,6 +895,8 @@ function createOverlayView(pi, state, ctx) {
814
895
  },
815
896
  getDisplayEntries: () => getOverlayEntries(state),
816
897
  sendMessage: async (text) => {
898
+ if (state.queuedMessages !== queue)
899
+ return;
817
900
  queueMessage(state, text);
818
901
  await flushQueuedMessages(pi, state, ctx);
819
902
  },
@@ -875,49 +958,59 @@ function formatMainContextDeltaLabel(previousContext, currentContext) {
875
958
  return "no significant change";
876
959
  }
877
960
  async function flushQueuedMessages(pi, state, ctx) {
878
- updateContextState(pi, state, ctx);
879
- if (state.flushPromise) {
961
+ if (state.flushPromise)
880
962
  return state.flushPromise;
881
- }
882
- // flushQueuedMessages is awaited in fire-and-forget paths (the /jarvis command
883
- // handler's initial message flush and the overlay input submit). Any error
884
- // that propagates out of here becomes an unhandled promise rejection, so the
885
- // IIFE must catch and surface every failure via bridge.notify rather than
886
- // rethrowing.
887
- state.flushPromise = (async () => {
963
+ // Queue identity survives a side /new, but not a main-session replacement.
964
+ // A stale completion must never consume or unlock a replacement queue.
965
+ const queue = state.queuedMessages;
966
+ const isCurrent = () => state.queuedMessages === queue;
967
+ let flushPromise;
968
+ flushPromise = (async () => {
888
969
  try {
970
+ updateContextState(pi, state, ctx);
889
971
  let runtime = await ensureRuntime(pi, state, ctx);
890
- await runtime.syncModel(getDesiredJarvisModel(state), getDesiredJarvisThinkingLevel(state));
891
- while (state.queuedMessages.length > 0) {
892
- const message = state.queuedMessages[0];
893
- const command = parseJarvisSideCommand(message);
972
+ if (!isCurrent())
973
+ return;
974
+ while (queue.length > 0 && isCurrent()) {
975
+ // Consume once: a failed command must not poison the queue, and an
976
+ // uncertain provider/tool send must not be retried automatically.
977
+ const message = queue.shift();
894
978
  try {
979
+ const command = parseJarvisSideCommand(message);
895
980
  if (command) {
896
981
  runtime = await executeJarvisSideCommand(pi, state, ctx, runtime, command);
897
982
  }
898
983
  else {
984
+ await runtime.syncModel(getDesiredJarvisModel(state), getDesiredJarvisThinkingLevel(state));
985
+ if (!isCurrent())
986
+ return;
987
+ const seenContext = state.mainContext;
899
988
  await runtime.sendMessage(message);
900
- state.lastJarvisSeenMainContext = state.mainContext;
989
+ if (isCurrent())
990
+ state.lastJarvisSeenMainContext = seenContext;
901
991
  }
902
- state.queuedMessages.shift();
992
+ if (!isCurrent())
993
+ return;
903
994
  }
904
995
  catch (error) {
905
- const action = command ? `run ${message.split(/\s+/, 1)[0]}` : "send /jarvis message";
906
- state.bridge.notify(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`, "error");
907
- break;
996
+ if (!isCurrent() || isStaleJarvisBootError(error))
997
+ return;
998
+ state.bridge.notify(`Failed to process /jarvis input: ${error instanceof Error ? error.message : String(error)}. Input was not retried.`, "error");
908
999
  }
909
1000
  }
910
1001
  }
911
1002
  catch (error) {
912
- if (isStaleJarvisBootError(error)) {
1003
+ if (!isCurrent() || isStaleJarvisBootError(error))
913
1004
  return;
914
- }
915
- state.bridge.notify(`/jarvis startup failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1005
+ queue.length = 0;
1006
+ state.bridge.notify(`/jarvis startup failed: ${error instanceof Error ? error.message : String(error)}. Please submit again.`, "error");
916
1007
  }
917
1008
  })().finally(() => {
918
- state.flushPromise = undefined;
1009
+ if (state.flushPromise === flushPromise)
1010
+ state.flushPromise = undefined;
919
1011
  });
920
- return state.flushPromise;
1012
+ state.flushPromise = flushPromise;
1013
+ return flushPromise;
921
1014
  }
922
1015
  async function ensureRuntime(pi, state, ctx) {
923
1016
  if (state.runtime) {
@@ -944,6 +1037,7 @@ async function ensureRuntime(pi, state, ctx) {
944
1037
  const runtime = await JarvisSideSessionRuntime.create({
945
1038
  bridge: state.bridge,
946
1039
  cwd: ctx.cwd,
1040
+ projectTrusted: ctx.isProjectTrusted(),
947
1041
  modelRegistry: ctx.modelRegistry,
948
1042
  model: getDesiredJarvisModel(state),
949
1043
  jarvisModelModeProvider: () => state.jarvisModelSelection.mode,
@@ -951,12 +1045,14 @@ async function ensureRuntime(pi, state, ctx) {
951
1045
  sessionFile,
952
1046
  systemPromptProvider: () => state.systemPrompt,
953
1047
  mainContextProvider: () => state.mainContext,
954
- toolAccessProvider: () => state.allowSideTools,
1048
+ toolAccessProvider: () => isCurrentBoot() && state.allowSideTools,
955
1049
  communicationPermissionsProvider: () => ({
956
- allowFollowUpToMain: state.allowFollowUpToMain,
957
- allowSteerToMain: state.allowSteerToMain,
1050
+ allowFollowUpToMain: isCurrentBoot() && state.allowFollowUpToMain,
1051
+ allowSteerToMain: isCurrentBoot() && state.allowSteerToMain,
958
1052
  }),
959
1053
  sendFollowUpToMain: (message) => {
1054
+ if (!isCurrentBoot() || !state.allowFollowUpToMain)
1055
+ throw new Error("/jarvis follow-up permission expired.");
960
1056
  pi.sendUserMessage(message, { deliverAs: "followUp" });
961
1057
  },
962
1058
  // Route the confirmation through the /jarvis overlay itself via the
@@ -964,8 +1060,10 @@ async function ensureRuntime(pi, state, ctx) {
964
1060
  // the base layer (the pi editor container) and sit hidden behind the
965
1061
  // /jarvis overlay, leaving the side-session tool execute hung on a
966
1062
  // promise the user could never answer.
967
- confirmSteerToMain: (message) => state.bridge.requestConfirmation("Send /jarvis steer to main?", `This will steer the main agent with:\n\n${message}`),
1063
+ confirmSteerToMain: (message, signal) => state.bridge.requestConfirmation("Send /jarvis steer to main?", `This will steer the main agent with:\n\n${message}`, signal),
968
1064
  sendSteerToMain: (message) => {
1065
+ if (!isCurrentBoot() || !state.allowSteerToMain)
1066
+ throw new Error("/jarvis redirect permission expired.");
969
1067
  pi.sendUserMessage(message, { deliverAs: "steer" });
970
1068
  },
971
1069
  themeProvider: state.themeProvider,
@@ -978,6 +1076,8 @@ async function ensureRuntime(pi, state, ctx) {
978
1076
  state.bridge.setWorkingMessage(undefined);
979
1077
  return runtime;
980
1078
  })().catch((error) => {
1079
+ if (!isCurrentBoot())
1080
+ throw new StaleJarvisBootError();
981
1081
  state.bridge.setWorkingMessage(undefined);
982
1082
  throw error;
983
1083
  }).finally(() => {