@frockbot/plugin-shell 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/backend.ts CHANGED
@@ -328,7 +328,11 @@ import {
328
328
  type PendingBotInputV1,
329
329
  type RoutineInboxEntryV1,
330
330
  } from "@frockbot/plugin-routines/inbox";
331
- import type { RoutineScheduler } from "@frockbot/plugin-routines/scheduler";
331
+ import type {
332
+ RoutineFireOutcomeV1,
333
+ RoutineScheduler,
334
+ } from "@frockbot/plugin-routines/scheduler";
335
+ import type { RoutineFireV1 } from "@frockbot/plugin-routines/firing";
332
336
  import {
333
337
  RoutineNotFoundError,
334
338
  type RoutineStore,
@@ -395,6 +399,7 @@ import {
395
399
  isVisibleRunV1,
396
400
  projectClientRunLookupV1,
397
401
  projectClientRunV1,
402
+ projectClientRunOrDegradedV1,
398
403
  projectClientAnnouncementsV1,
399
404
  projectClientTurnV1,
400
405
  type ClientRunLookupV1,
@@ -441,6 +446,25 @@ import {
441
446
  } from "./unread.js";
442
447
  import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
443
448
 
449
+ /**
450
+ * The providers that can be asked what happened to a model request they never
451
+ * answered — that is, the ones whose Package registers an
452
+ * `LlmReconciliationCapability` (ADR 0028).
453
+ *
454
+ * It is a list rather than a lookup because recovery consults it inside the
455
+ * durable transaction that settles the run, where nothing may be mounted or
456
+ * awaited. The cost is that a provider Package which gains retrieval has to
457
+ * name itself here; the ADR carries that obligation, and the failure mode of
458
+ * forgetting is a Turn settled as failed rather than one wedged forever, which
459
+ * is the direction this deployment wants to be wrong in.
460
+ *
461
+ * Today: the in-process foundation provider, and nothing else. Ollama Cloud
462
+ * exposes no provider-bound retrieval (ADR 0010) and neither does Flock AI.
463
+ */
464
+ const RECONCILING_PROVIDER_IDS_V1: ReadonlySet<string> = new Set([
465
+ "foundation",
466
+ ]);
467
+
444
468
  export const BOT_CONFIGURATION_KEY = "bot-configuration";
445
469
  const CONFIGURATION_RECEIPT_PREFIX = "configuration-receipt:";
446
470
  const STOP_RECEIPT_PREFIX = "stop-receipt:";
@@ -779,6 +803,8 @@ export class ShellBotBackendContribution {
779
803
  deferScheduledWork: (transaction) =>
780
804
  this.deferScheduledWork(transaction),
781
805
  settleScheduledWork: () => this.settleScheduledWork(),
806
+ providerReconciles: (providerId) =>
807
+ RECONCILING_PROVIDER_IDS_V1.has(providerId),
782
808
  },
783
809
  });
784
810
  }
@@ -2975,17 +3001,24 @@ export class ShellBotBackendContribution {
2975
3001
  }
2976
3002
 
2977
3003
  private async settleScheduledWork(): Promise<void> {
2978
- await this.settleRoutineFirings();
2979
- await this.runOwedSubagentTurns();
2980
- await this.reconcileOverdueTasks();
2981
- await this.expireDueApprovals();
2982
- await this.replayPendingWakeNotifications();
2983
- await this.hostSettleScheduledWork?.();
2984
- // The alarm that woke this object has been consumed. Re-arm on whatever is
2985
- // owed next, or a Routine that fired once would never fire again.
2986
- await this.ctx.storage.transaction((transaction) =>
2987
- this.authority.refreshRecoveryAlarm(transaction),
2988
- );
3004
+ // The re-arm is in a `finally` because it is the object's only way back.
3005
+ // The alarm that woke this object has already been consumed by the
3006
+ // platform; a throw in any one settler used to skip the re-arm, and then
3007
+ // nothing — no Routine, no approval expiry, no owed subagent Turn — ever
3008
+ // woke this Bot again except by a caller's luck. One producer failing must
3009
+ // cost that producer its pass, never the clock.
3010
+ try {
3011
+ await this.settleRoutineFirings();
3012
+ await this.runOwedSubagentTurns();
3013
+ await this.reconcileOverdueTasks();
3014
+ await this.expireDueApprovals();
3015
+ await this.replayPendingWakeNotifications();
3016
+ await this.hostSettleScheduledWork?.();
3017
+ } finally {
3018
+ await this.ctx.storage.transaction((transaction) =>
3019
+ this.authority.refreshRecoveryAlarm(transaction),
3020
+ );
3021
+ }
2989
3022
  }
2990
3023
 
2991
3024
  /**
@@ -3005,21 +3038,73 @@ export class ShellBotBackendContribution {
3005
3038
  // here whenever the Turn is executing in this isolate, but a durable active
3006
3039
  // run outlives an eviction, and admitting a firing against one would burn
3007
3040
  // the occurrence on an error instead of holding the debt.
3008
- if (await this.authority.readActiveRunId()) return;
3041
+ //
3042
+ // Returning was not enough: the debt stayed past-due, so `deadlines()`
3043
+ // re-armed on a moment already gone and the alarm spun straight back into
3044
+ // this same bail-out — which is how a Routine racing a long chat Turn
3045
+ // failed once a minute for ever. The hold is what turns the bail-out into
3046
+ // a deferral: `dueAt` does not move, so the firing still lands.
3047
+ if (await this.authority.readActiveRunId()) {
3048
+ await this.ctx.storage.transaction((transaction) =>
3049
+ this.routineScheduler.defer(transaction),
3050
+ );
3051
+ return;
3052
+ }
3009
3053
  await this.routineScheduler.settle(async (fire) => {
3010
- try {
3011
- await this.authority.run(
3012
- routineTurnCommandV1(identity, fire, new Date().toISOString()),
3013
- );
3014
- } catch (error) {
3015
- return routineFireOutcomeV1(
3016
- await this.authority.readStoredRun(fire.fireId),
3017
- error,
3018
- );
3019
- }
3054
+ const outcome = await this.runOneFiring(identity, fire);
3055
+ await this.notifyFailedFiring(identity, fire, outcome);
3056
+ return outcome;
3057
+ });
3058
+ }
3059
+
3060
+ private async runOneFiring(
3061
+ identity: BotIdentity,
3062
+ fire: RoutineFireV1,
3063
+ ): Promise<RoutineFireOutcomeV1> {
3064
+ try {
3065
+ await this.authority.run(
3066
+ routineTurnCommandV1(identity, fire, new Date().toISOString()),
3067
+ );
3068
+ } catch (error) {
3020
3069
  return routineFireOutcomeV1(
3021
3070
  await this.authority.readStoredRun(fire.fireId),
3071
+ error,
3022
3072
  );
3073
+ }
3074
+ return routineFireOutcomeV1(
3075
+ await this.authority.readStoredRun(fire.fireId),
3076
+ );
3077
+ }
3078
+
3079
+ /**
3080
+ * Tell the person that a firing did not work.
3081
+ *
3082
+ * The scheduler has already written the durable completion-inbox entry in
3083
+ * the transaction that settled the firing; this is the delivery half — the
3084
+ * same seam a hand-off uses, so a Routine that breaks reaches the same place
3085
+ * a Routine that finishes does instead of only a `failed` row nobody opens.
3086
+ * `notifications.enabled` is honoured: it is the mute on updates, and a
3087
+ * broken Routine is an update, not a decision the Bot is waiting on.
3088
+ */
3089
+ private async notifyFailedFiring(
3090
+ identity: BotIdentity,
3091
+ fire: RoutineFireV1,
3092
+ outcome: RoutineFireOutcomeV1,
3093
+ ): Promise<void> {
3094
+ if (outcome.status === "ok") return;
3095
+ const settings = await this.getSettings(identity);
3096
+ if (!settings.notifications.enabled) return;
3097
+ await this.authority.recordNotification({
3098
+ // The same id shape the completion path uses, so one firing is one
3099
+ // intent however many times the alarm retries it.
3100
+ notificationId: `routine-failed:${fire.fireId}`,
3101
+ runId: fire.fireId,
3102
+ createdAt: new Date().toISOString(),
3103
+ title: `${settings.profile.name} could not run a Routine`,
3104
+ body: (outcome.summary ?? "The firing ended without saying why.").slice(
3105
+ 0,
3106
+ 240,
3107
+ ),
3023
3108
  });
3024
3109
  }
3025
3110
  // -------------------------------------------------------------------------
@@ -4508,13 +4593,13 @@ export class ShellBotBackendContribution {
4508
4593
  if (!effectiveModel) {
4509
4594
  throw new Error(
4510
4595
  effective.binding?.failure ??
4511
- "No model is configured; enable a model Package or restore the platform model",
4596
+ "No model is set up yet. Choose one in Models.",
4512
4597
  );
4513
4598
  }
4514
4599
  const binding: ResolvedModelBindingV1 = effective.binding ?? {
4515
4600
  model: structuredClone(effectiveModel),
4516
4601
  state: "unavailable",
4517
- failure: "The resolved model Connection is unavailable",
4602
+ failure: "This Bot's model isn't available. Pick one in Models.",
4518
4603
  };
4519
4604
  if (
4520
4605
  binding.state === "unavailable" ||
@@ -4523,7 +4608,8 @@ export class ShellBotBackendContribution {
4523
4608
  !binding.packageId
4524
4609
  ) {
4525
4610
  throw new Error(
4526
- binding.failure ?? "The resolved model Connection is unavailable",
4611
+ binding.failure ??
4612
+ "This Bot's model isn't available. Pick one in Models.",
4527
4613
  );
4528
4614
  }
4529
4615
  if (
@@ -4536,7 +4622,9 @@ export class ShellBotBackendContribution {
4536
4622
  admittedRequest.modelBinding.connectionGeneration !==
4537
4623
  binding.connection.generation)
4538
4624
  ) {
4539
- throw new Error("Admitted model binding is unavailable");
4625
+ throw new Error(
4626
+ "This Bot's model changed mid-reply. Send your message again.",
4627
+ );
4540
4628
  }
4541
4629
  const bindingPackageId = binding.packageId;
4542
4630
  agentPackages.push(
@@ -5370,7 +5458,9 @@ export class ShellBotBackendContribution {
5370
5458
  // still not part of the conversation: the visible transcript never
5371
5459
  // shows one, running or settled.
5372
5460
  if (active && isVisibleRunV1(active))
5373
- selected.set(active.runId, { run: projectClientRunV1(active) });
5461
+ selected.set(active.runId, {
5462
+ run: projectClientRunOrDegradedV1(active),
5463
+ });
5374
5464
  }
5375
5465
  const available = candidates.slice(0, CLIENT_RUN_PAGE_LIMIT);
5376
5466
  let stoppedEarly = false;
@@ -5382,7 +5472,7 @@ export class ShellBotBackendContribution {
5382
5472
  }
5383
5473
  const stored = await this.authority.readStoredRun(candidate.runId);
5384
5474
  if (!stored || !isVisibleRunV1(stored)) continue;
5385
- const projected = projectClientRunV1(stored);
5475
+ const projected = projectClientRunOrDegradedV1(stored);
5386
5476
  const tentative = [
5387
5477
  ...selected.values(),
5388
5478
  { cursor: candidate.cursor, run: projected },
@@ -147,7 +147,7 @@ function generationLabel(generationId: string): string {
147
147
  hour: "numeric",
148
148
  minute: "2-digit",
149
149
  });
150
- return `Live since ${when} · ${hash.slice(0, 7)}`;
150
+ return `Live since ${when}`;
151
151
  }
152
152
 
153
153
  function selectTab(next: "app" | "code"): void {
@@ -199,10 +199,8 @@ async function clearFocus(): Promise<void> {
199
199
  /></span>
200
200
  <div class="applet-canvas-title">
201
201
  <strong>{{ applet?.displayName ?? "Applet" }}</strong>
202
- <small v-if="viewer" :title="viewer.generationId">{{
203
- generationLabel(viewer.generationId)
204
- }}</small>
205
- <small v-else>No published version yet</small>
202
+ <small v-if="viewer">{{ generationLabel(viewer.generationId) }}</small>
203
+ <small v-else>Not published yet</small>
206
204
  </div>
207
205
  <div
208
206
  v-if="canShowApp"
@@ -288,7 +288,7 @@ const threadHeading = computed(() => {
288
288
  const threadHint = computed(() => {
289
289
  if (!hasBot.value) return "Add your first sheep to start a conversation.";
290
290
  if (state.value.modelReady) {
291
- return "Start with a conversation. Cordis plugins can add the rest.";
291
+ return "Say anything to get started.";
292
292
  }
293
293
  return state.value.modelLabel;
294
294
  });
@@ -405,6 +405,69 @@ function taskChipsOf(message: WebChatMessage): Array<{
405
405
  });
406
406
  }
407
407
 
408
+ /**
409
+ * The tools this Turn ran, as the thread draws them.
410
+ *
411
+ * A tool whose Package draws its own surface is shown by that surface; every
412
+ * other one is a chip, because a Turn that spends a minute making tool calls
413
+ * used to show the User nothing at all but a spinning avatar.
414
+ */
415
+ function toolChipsOf(message: WebChatMessage): WebToolActivity[] {
416
+ return message.tools.filter((tool) => iframeEntriesFor(tool).length === 0);
417
+ }
418
+
419
+ /**
420
+ * What a chip calls a tool, in the User's words rather than the model's.
421
+ *
422
+ * A tool name is an identifier — `send_to_user`, `user-Github--acme/search_issues`
423
+ * — and the transcript is a conversation, so the chip drops the namespace,
424
+ * un-snakes the rest and capitalises it.
425
+ */
426
+ function toolChipLabel(tool: WebToolActivity): string {
427
+ const bare = tool.name.split("/").pop() ?? tool.name;
428
+ const words = bare.replace(/[_.-]+/g, " ").trim();
429
+ if (words.length === 0) return tool.name;
430
+ return words.charAt(0).toUpperCase() + words.slice(1);
431
+ }
432
+
433
+ /**
434
+ * Whether a chip is drawn as a failure.
435
+ *
436
+ * A tool call the model recovered from is not a failure the User has anything
437
+ * to do with: a refused call followed by a Turn that went on to finish is the
438
+ * Bot correcting itself, and colouring it red reports a broken Turn that
439
+ * worked. Only a Turn that itself ended badly keeps the failed state.
440
+ */
441
+ function toolChipState(
442
+ tool: WebToolActivity,
443
+ message: WebChatMessage,
444
+ ): "running" | "completed" | "failed" | "retried" {
445
+ if (tool.status !== "failed") return tool.status;
446
+ return message.status === "completed" || message.status === "streaming"
447
+ ? "retried"
448
+ : "failed";
449
+ }
450
+
451
+ /** What a chip says a tool is doing. Its status, in the User's words. */
452
+ function toolChipStatus(
453
+ tool: WebToolActivity,
454
+ message: WebChatMessage,
455
+ ): string {
456
+ const state = toolChipState(tool, message);
457
+ if (state === "running") return "running";
458
+ if (state === "retried") return "retried";
459
+ return state === "failed" ? "failed" : "done";
460
+ }
461
+
462
+ /** Which tool chips the User has opened. Local, and per chip. */
463
+ const expandedTools = ref(new Set<string>());
464
+
465
+ function toggleTool(toolId: string): void {
466
+ const next = new Set(expandedTools.value);
467
+ if (!next.delete(toolId)) next.add(toolId);
468
+ expandedTools.value = next;
469
+ }
470
+
408
471
  /** Which chips the User has opened. Local, and per chip. */
409
472
  const expandedTasks = ref(new Set<string>());
410
473
 
@@ -431,6 +494,7 @@ function isVisible(message: WebChatMessage): boolean {
431
494
  // visible act was dispatching a subagent.
432
495
  return (
433
496
  message.text.length > 0 ||
497
+ (message.notice?.length ?? 0) > 0 ||
434
498
  message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
435
499
  message.sends.length > 0 ||
436
500
  (message.tasks?.length ?? 0) > 0 ||
@@ -856,105 +920,159 @@ function handleComposerKeydown(event: KeyboardEvent): void {
856
920
  /></span>
857
921
  <k-slot name="frockbot.bot-avatar" />
858
922
  </div>
859
- <div v-if="message.text" class="message-bubble">
860
- <UiMarkdown :text="message.text" />
861
- </div>
862
- <template v-for="tool in message.tools" :key="tool.id">
863
- <PackageIframeHost
864
- v-for="entry in iframeEntriesFor(tool)"
865
- :key="`${tool.id}:${entry.contribution.packageId}:${entry.page.id}`"
866
- class="message-package-iframe"
867
- :contribution="entry.contribution"
868
- :page="entry.page"
869
- :slot="entry.slot"
870
- :states="{ [`tool:${tool.name}`]: toolResultState(tool) }"
871
- />
872
- </template>
873
923
  <!--
924
+ Everything the Turn produced stacks in one column beside the
925
+ avatar. The row holds exactly two children — avatar, column —
926
+ so a bubble, a notice and a chip are stacked lines rather than
927
+ side-by-side columns squeezing the reply to a few pixels.
928
+ -->
929
+ <div class="message-column">
930
+ <div v-if="message.text" class="message-bubble">
931
+ <UiMarkdown :text="message.text" />
932
+ </div>
933
+ <!--
934
+ Why the Turn ends where it does, under whatever it had already
935
+ said rather than in place of it.
936
+ -->
937
+ <p v-if="message.notice" class="message-notice">
938
+ {{ message.notice }}
939
+ </p>
940
+ <template v-for="tool in message.tools" :key="tool.id">
941
+ <PackageIframeHost
942
+ v-for="entry in iframeEntriesFor(tool)"
943
+ :key="`${tool.id}:${entry.contribution.packageId}:${entry.page.id}`"
944
+ class="message-package-iframe"
945
+ :contribution="entry.contribution"
946
+ :page="entry.page"
947
+ :slot="entry.slot"
948
+ :states="{ [`tool:${tool.name}`]: toolResultState(tool) }"
949
+ />
950
+ </template>
951
+ <!--
874
952
  A binary a tool filed in the Workspace, drawn from the
875
953
  Workspace read route. The thread carries the path, never the
876
954
  bytes, so a long conversation costs paths and the image is
877
955
  fetched only when it is on screen.
878
956
  -->
879
- <div
880
- v-if="attachmentsOf(message).length > 0"
881
- class="message-attachments"
882
- >
883
- <a
884
- v-for="attachment in attachmentsOf(message)"
885
- :key="attachment.contentHash"
886
- :href="workspaceFileUrl(attachment.path)"
887
- target="_blank"
888
- rel="noreferrer"
957
+ <div
958
+ v-if="attachmentsOf(message).length > 0"
959
+ class="message-attachments"
889
960
  >
890
- <img
891
- :src="workspaceFileUrl(attachment.path)"
892
- :alt="`Attachment from ${message.runId}`"
893
- loading="lazy"
894
- />
895
- </a>
896
- </div>
897
- <!--
961
+ <a
962
+ v-for="attachment in attachmentsOf(message)"
963
+ :key="attachment.contentHash"
964
+ :href="workspaceFileUrl(attachment.path)"
965
+ target="_blank"
966
+ rel="noreferrer"
967
+ >
968
+ <img
969
+ :src="workspaceFileUrl(attachment.path)"
970
+ :alt="`Attachment from ${message.runId}`"
971
+ loading="lazy"
972
+ />
973
+ </a>
974
+ </div>
975
+ <!--
898
976
  Sends sit beside the derived text rather than inside it: each
899
977
  payload is its own block, and a widget-ended Turn has no text
900
978
  bubble at all.
901
979
  -->
902
- <div v-if="message.sends.length > 0" class="message-sends">
903
- <SendPayloadView
904
- v-for="(send, sendIndex) in message.sends"
905
- :key="sendIndex"
906
- :send="send"
907
- />
908
- </div>
909
- <!--
980
+ <div v-if="message.sends.length > 0" class="message-sends">
981
+ <SendPayloadView
982
+ v-for="(send, sendIndex) in message.sends"
983
+ :key="sendIndex"
984
+ :send="send"
985
+ />
986
+ </div>
987
+ <!--
988
+ What the Bot did, while it is doing it. The chip is the
989
+ conversation's whole account of an ordinary tool call: its
990
+ name, whether it is running, and — when the User opens it —
991
+ what it returned.
992
+ -->
993
+ <div
994
+ v-if="toolChipsOf(message).length > 0"
995
+ class="message-tools"
996
+ >
997
+ <button
998
+ v-for="tool in toolChipsOf(message)"
999
+ :key="tool.id"
1000
+ type="button"
1001
+ class="tool-chip"
1002
+ :class="`tool-chip-${toolChipState(tool, message)}`"
1003
+ :aria-expanded="expandedTools.has(tool.id)"
1004
+ @click="toggleTool(tool.id)"
1005
+ >
1006
+ <span class="tool-chip-name">{{
1007
+ toolChipLabel(tool)
1008
+ }}</span>
1009
+ <span class="tool-chip-status">{{
1010
+ toolChipStatus(tool, message)
1011
+ }}</span>
1012
+ <span
1013
+ v-if="
1014
+ expandedTools.has(tool.id) && tool.text !== undefined
1015
+ "
1016
+ class="tool-chip-result"
1017
+ >{{ tool.text }}</span
1018
+ >
1019
+ </button>
1020
+ </div>
1021
+ <!--
910
1022
  The subagents this Turn dispatched. The child's own Session is
911
1023
  never in this transcript, so the chip is the whole of what the
912
1024
  conversation says about it; opening one shows the summary the
913
1025
  child handed back and nothing else.
914
1026
  -->
915
- <div v-if="taskChipsOf(message).length > 0" class="message-tasks">
916
- <button
917
- v-for="entry in taskChipsOf(message)"
918
- :key="entry.chip.taskId"
919
- type="button"
920
- class="task-chip"
921
- :class="`task-chip-${entry.status}`"
922
- :aria-expanded="expandedTasks.has(entry.chip.taskId)"
923
- @click="toggleTask(entry.chip.taskId)"
1027
+ <div
1028
+ v-if="taskChipsOf(message).length > 0"
1029
+ class="message-tasks"
924
1030
  >
925
- <span class="task-chip-type">{{ entry.chip.taskType }}</span>
926
- <span class="task-chip-description">{{
927
- entry.chip.description
928
- }}</span>
929
- <span class="task-chip-status">{{ entry.status }}</span>
930
- <span class="task-chip-model">{{ entry.chip.model }}</span>
931
- <span
932
- v-if="expandedTasks.has(entry.chip.taskId)"
933
- class="task-chip-summary"
934
- >{{
935
- entry.summary ??
936
- "This subagent has not reported a summary yet."
937
- }}</span
1031
+ <button
1032
+ v-for="entry in taskChipsOf(message)"
1033
+ :key="entry.chip.taskId"
1034
+ type="button"
1035
+ class="task-chip"
1036
+ :class="`task-chip-${entry.status}`"
1037
+ :aria-expanded="expandedTasks.has(entry.chip.taskId)"
1038
+ @click="toggleTask(entry.chip.taskId)"
938
1039
  >
939
- <!--
1040
+ <span class="task-chip-type">{{
1041
+ entry.chip.taskType
1042
+ }}</span>
1043
+ <span class="task-chip-description">{{
1044
+ entry.chip.description
1045
+ }}</span>
1046
+ <span class="task-chip-status">{{ entry.status }}</span>
1047
+ <span class="task-chip-model">{{ entry.chip.model }}</span>
1048
+ <span
1049
+ v-if="expandedTasks.has(entry.chip.taskId)"
1050
+ class="task-chip-summary"
1051
+ >{{
1052
+ entry.summary ??
1053
+ "This subagent has not reported a summary yet."
1054
+ }}</span
1055
+ >
1056
+ <!--
940
1057
  Cancellation is the User's, explicit and authenticated. It
941
1058
  is offered only while the subagent is live: a settled one
942
1059
  has an outcome, and stopping it would rewrite it.
943
1060
  -->
944
- <span
945
- v-if="
946
- expandedTasks.has(entry.chip.taskId) &&
947
- isTaskLive(entry.status)
948
- "
949
- class="task-chip-stop"
950
- role="button"
951
- tabindex="0"
952
- @click.stop="stopTask(entry.chip.taskId)"
953
- @keydown.enter.stop.prevent="stopTask(entry.chip.taskId)"
954
- @keydown.space.stop.prevent="stopTask(entry.chip.taskId)"
955
- >Stop this subagent</span
956
- >
957
- </button>
1061
+ <span
1062
+ v-if="
1063
+ expandedTasks.has(entry.chip.taskId) &&
1064
+ isTaskLive(entry.status)
1065
+ "
1066
+ class="task-chip-stop"
1067
+ role="button"
1068
+ tabindex="0"
1069
+ @click.stop="stopTask(entry.chip.taskId)"
1070
+ @keydown.enter.stop.prevent="stopTask(entry.chip.taskId)"
1071
+ @keydown.space.stop.prevent="stopTask(entry.chip.taskId)"
1072
+ >Stop this subagent</span
1073
+ >
1074
+ </button>
1075
+ </div>
958
1076
  </div>
959
1077
  </template>
960
1078
  <div v-else class="message-bubble">{{ message.text }}</div>
@@ -985,7 +1103,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
985
1103
  type="button"
986
1104
  @click="web.resumeRun(state.activeRun.runId)"
987
1105
  >
988
- Resolve Turn
1106
+ Try again
989
1107
  </button>
990
1108
  </div>
991
1109
  </Transition>
@@ -34,6 +34,13 @@ const providedWeb = inject(frockBotWebDataKey);
34
34
  if (!providedWeb) throw new Error("Package iframe host data was not provided");
35
35
  const web = providedWeb;
36
36
  const frame = ref<HTMLIFrameElement>();
37
+ /** Where a page came from, said plainly. First-party pages say nothing. */
38
+ const provenanceLabel = computed(() => {
39
+ if (props.contribution.provenance === "Bot-authored")
40
+ return "Built by this Bot";
41
+ if (props.contribution.provenance === "User-installed") return "Added by you";
42
+ return undefined;
43
+ });
37
44
  const height = ref(240);
38
45
  const failure = ref<string>();
39
46
  const lastStateWireByName = new Map<string, string>();
@@ -123,7 +130,9 @@ function post(message: PackageIframeHostMessageV2): void {
123
130
  postPackageIframeHostMessage(target, message, lastStateWireByName);
124
131
  } catch (error) {
125
132
  failure.value =
126
- error instanceof Error ? error.message : "Package page state is invalid";
133
+ error instanceof Error
134
+ ? error.message
135
+ : "This page sent something FrockBot couldn't read.";
127
136
  }
128
137
  }
129
138
 
@@ -179,7 +188,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
179
188
  }
180
189
  if (message.type === "focus") {
181
190
  if (!packageIframeFocusAllowedV2(props.contribution)) {
182
- failure.value = "This Package cannot change the focused Applet.";
191
+ failure.value = "This plugin can't change which Applet is open.";
183
192
  return;
184
193
  }
185
194
  failure.value = undefined;
@@ -189,7 +198,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
189
198
  if (message.type === "openExternal") {
190
199
  const origin = catalog.value?.artifactOrigin;
191
200
  if (!origin || !packageIframeExternalUrlAllowedV2(message.url, origin)) {
192
- failure.value = "This Package page can only open its own pages.";
201
+ failure.value = "This plugin can only open its own pages.";
193
202
  return;
194
203
  }
195
204
  failure.value = undefined;
@@ -197,7 +206,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
197
206
  return;
198
207
  }
199
208
  if (!packageIframeToolAllowedV1(props.contribution, message.name)) {
200
- failure.value = `This Package did not declare ${message.name}.`;
209
+ failure.value = `This plugin isn't allowed to use ${message.name}.`;
201
210
  return;
202
211
  }
203
212
  failure.value = undefined;
@@ -232,12 +241,12 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
232
241
  <section class="package-iframe-frame" :class="`package-iframe-${layout}`">
233
242
  <header v-if="attribution" class="package-iframe-attribution">
234
243
  <strong>{{ contribution.displayName }}</strong>
235
- <span>{{ contribution.provenance }} Package</span>
244
+ <span v-if="provenanceLabel">{{ provenanceLabel }}</span>
236
245
  </header>
237
246
  <!-- Load eagerly because lazy iframes defer the init/resize handshake until the browser decides the frame is near the viewport, which headless Chromium may never do. -->
238
247
  <iframe
239
248
  ref="frame"
240
- :title="`${contribution.displayName} Package page`"
249
+ :title="contribution.displayName"
241
250
  :src="source"
242
251
  :style="layout === 'fill' ? undefined : { height: `${height}px` }"
243
252
  sandbox="allow-scripts"