@frockbot/plugin-shell 0.3.11 → 0.3.13

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 (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
package/src/backend.ts CHANGED
@@ -52,6 +52,8 @@ import {
52
52
  RUN_ADMISSION_FENCE_PREFIX,
53
53
  RUN_INDEX_PREFIX,
54
54
  RUN_PREFIX,
55
+ CONVERSATION_BUSY_MESSAGE_V1,
56
+ isConversationBusyV1,
55
57
  type BotIdentity,
56
58
  type BotDurableAuthorityOptions,
57
59
  type BotTurnExecutionInput,
@@ -389,6 +391,7 @@ import {
389
391
  projectPackageIframeCompositionV1,
390
392
  } from "./composition-views.js";
391
393
  import { executeBotTurn, executeDirectToolTurn } from "./backend-runner.js";
394
+ import { yieldCompactionWorkV1 } from "./compaction-scheduler.js";
392
395
  import {
393
396
  shellTerminalRecordsV1,
394
397
  supersededTurnRecordsV1,
@@ -410,6 +413,7 @@ import {
410
413
  projectClientTurnV1,
411
414
  type ClientRunLookupV1,
412
415
  type ClientConversationListV1,
416
+ type ClientConversationOutcomeV1,
413
417
  type ClientRunListV1,
414
418
  type ClientRunStopReceiptV1,
415
419
  type ClientRunV1,
@@ -475,7 +479,7 @@ import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contrib
475
479
  * is the direction this deployment wants to be wrong in.
476
480
  *
477
481
  * Today: the in-process foundation provider, and nothing else. Ollama Cloud
478
- * exposes no provider-bound retrieval (ADR 0010) and neither does Flock AI.
482
+ * exposes no provider-bound retrieval (ADR 0010) and neither does Frock AI.
479
483
  */
480
484
  const RECONCILING_PROVIDER_IDS_V1: ReadonlySet<string> = new Set([
481
485
  "foundation",
@@ -588,8 +592,8 @@ export interface BotStateEnv {
588
592
  MEMORY_INDEX: VectorizeIndex;
589
593
  /** The native AI binding consumed through the image Package adapter. */
590
594
  AI?: NativeAiBindingV1;
591
- /** The Flock AI Gateway adapter constructed by the Cloudflare host. */
592
- FLOCK_AI?: {
595
+ /** The Frock AI Gateway adapter constructed by the Cloudflare host. */
596
+ FROCK_AI?: {
593
597
  autoRoute: string;
594
598
  runChatCompletion(
595
599
  gatewayModel: string,
@@ -1135,14 +1139,43 @@ export class ShellBotBackendContribution {
1135
1139
  if (expired.length > 0) await transaction.delete(expired);
1136
1140
  }
1137
1141
 
1138
- /** The announcements the Session shows, oldest first. */
1142
+ /**
1143
+ * The announcements the Session shows, oldest first.
1144
+ *
1145
+ * Two sources, and deliberately so. A rename or a settled task has no live
1146
+ * Session to be appended to, so it lives in this object's own bounded
1147
+ * announcement log. A compaction (ADR 0030) is already a durable event on
1148
+ * the conversation's session log — appending a second copy of it here would
1149
+ * be two records of one fact — so it is read back from there instead.
1150
+ */
1139
1151
  async listAnnouncements(): Promise<SessionEvent[]> {
1140
1152
  const stored = await this.ctx.storage.list<unknown>({
1141
1153
  prefix: BOT_ANNOUNCEMENT_PREFIX,
1142
1154
  });
1143
- return [...stored.entries()]
1155
+ const announcements = [...stored.entries()]
1144
1156
  .sort(([left], [right]) => left.localeCompare(right))
1145
1157
  .map(([, value]) => decodeSessionEvent(value));
1158
+ const session =
1159
+ (await this.ctx.storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? [];
1160
+ for (const event of session) {
1161
+ if (event.type === "conversation/compacted") announcements.push(event);
1162
+ }
1163
+ return announcements
1164
+ .sort((left, right) => left.timestamp.localeCompare(right.timestamp))
1165
+ .slice(-BOT_ANNOUNCEMENT_RETENTION);
1166
+ }
1167
+
1168
+ /**
1169
+ * The announcements as the transcript reads them, each already carrying the
1170
+ * timestamp of the place it belongs rather than the moment it was written.
1171
+ */
1172
+ private async projectAnnouncementPage() {
1173
+ const session =
1174
+ (await this.ctx.storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? [];
1175
+ return projectClientAnnouncementsV1(
1176
+ await this.listAnnouncements(),
1177
+ session,
1178
+ );
1146
1179
  }
1147
1180
 
1148
1181
  private async refreshRecoveryAlarm(
@@ -1158,6 +1191,9 @@ export class ShellBotBackendContribution {
1158
1191
  }
1159
1192
 
1160
1193
  async run(command: OwnedBotTurnCommand): Promise<ClientTurnV1> {
1194
+ // Before the authority reads the session log, so a compaction detached
1195
+ // from the previous Turn has already handed the log back (ADR 0030).
1196
+ await yieldCompactionWorkV1(command.sessionId);
1161
1197
  // Before admission, so the pin this Turn takes already carries whatever the
1162
1198
  // User's Applet directory says now.
1163
1199
  await this.resolveAppletComposition(
@@ -1593,6 +1629,10 @@ export class ShellBotBackendContribution {
1593
1629
  private async executeTurn(
1594
1630
  input: BotTurnExecutionInput<BotSettingsViewV1>,
1595
1631
  ): Promise<BotTurnCompletion> {
1632
+ // ADR 0030: a compaction detached from the previous Turn yields to this
1633
+ // one rather than holding it. Free when none is running, and an abort when
1634
+ // one is, so this Turn is the only writer of the session log.
1635
+ await yieldCompactionWorkV1(input.command.sessionId);
1596
1636
  const settings = input.configurationSnapshot;
1597
1637
  const turn = {
1598
1638
  runId: input.command.runId,
@@ -4764,11 +4804,11 @@ export class ShellBotBackendContribution {
4764
4804
  bindingPackageId,
4765
4805
  effectId,
4766
4806
  ),
4767
- ...(this.env.FLOCK_AI
4807
+ ...(this.env.FROCK_AI
4768
4808
  ? {
4769
- flockAiAutoRoute: this.env.FLOCK_AI.autoRoute,
4770
- runFlockAiChatCompletion: (gatewayModel, body) =>
4771
- this.env.FLOCK_AI!.runChatCompletion(gatewayModel, body),
4809
+ frockAiAutoRoute: this.env.FROCK_AI.autoRoute,
4810
+ runFrockAiChatCompletion: (gatewayModel, body) =>
4811
+ this.env.FROCK_AI!.runChatCompletion(gatewayModel, body),
4772
4812
  }
4773
4813
  : {}),
4774
4814
  fetch: this.outboundFetch,
@@ -5402,12 +5442,15 @@ export class ShellBotBackendContribution {
5402
5442
  * so an older run that is somehow still marked running is a reconciliation
5403
5443
  * problem and not something a ring should report. A read that fails is no
5404
5444
  * ring — liveness is never worth failing a sidebar poll for.
5445
+ *
5446
+ * The record's `status` is not the test and never was. `resolveRunWorking`
5447
+ * holds the rule — running, inside the Turn deadline, and a Turn the log has
5448
+ * not already closed — and settles the record when it finds one that only
5449
+ * claims to be running, which is why this read is also the repair.
5405
5450
  */
5406
5451
  private async isWorking(runId: string | undefined): Promise<boolean> {
5407
- if (runId === undefined) return false;
5408
5452
  try {
5409
- const run = await this.authority.readRun(runId);
5410
- return run?.status === "running";
5453
+ return await this.authority.resolveRunWorking(runId);
5411
5454
  } catch {
5412
5455
  return false;
5413
5456
  }
@@ -5637,6 +5680,16 @@ export class ShellBotBackendContribution {
5637
5680
  limit: CLIENT_RUN_PAGE_LIMIT + 1,
5638
5681
  ...(query.before ? { before: query.before } : {}),
5639
5682
  });
5683
+ // The open chat draws its own activity ring from whichever run this page
5684
+ // projects as `running`, so it owes the same liveness rule the sidebar row
5685
+ // does — and from the same helper, or the two surfaces disagree about the
5686
+ // same Bot. Only the newest run and the active marker are asked: a Turn
5687
+ // further back cannot be the one anybody is waiting on, and a transcript
5688
+ // read is not the place to walk a Bot's whole history looking for
5689
+ // leftovers.
5690
+ if (!query.before) {
5691
+ await this.isWorking(activeRunId ?? candidates[0]?.runId);
5692
+ }
5640
5693
 
5641
5694
  const selected = new Map<string, { cursor?: string; run: ClientRunV1 }>();
5642
5695
  if (activeRunId) {
@@ -5706,9 +5759,7 @@ export class ShellBotBackendContribution {
5706
5759
  : { truncated: false },
5707
5760
  // Announcements belong to the Session, not to a page of Turns, so only
5708
5761
  // the newest page carries them.
5709
- query.before
5710
- ? []
5711
- : projectClientAnnouncementsV1(await this.listAnnouncements()),
5762
+ query.before ? [] : await this.projectAnnouncementPage(),
5712
5763
  );
5713
5764
  if (clientRunListWireBytes(page) > CLIENT_RUN_LIST_MAX_BYTES) {
5714
5765
  throw new Error("required run projections exceed the wire byte limit");
@@ -5739,10 +5790,26 @@ export class ShellBotBackendContribution {
5739
5790
  */
5740
5791
  async startConversation(
5741
5792
  identity: BotIdentity,
5742
- ): Promise<ClientConversationListV1> {
5793
+ ): Promise<ClientConversationOutcomeV1> {
5743
5794
  await this.validateIdentity(identity);
5744
- await this.authority.startConversation(identity);
5745
- return this.listConversations();
5795
+ try {
5796
+ await this.authority.startConversation(identity);
5797
+ } catch (error) {
5798
+ // The one refusal this can give travels as data. Everything else is a
5799
+ // genuine failure and still throws, so the boundary above answers 500.
5800
+ if (isConversationBusyV1(error)) {
5801
+ return {
5802
+ status: "refused",
5803
+ schemaVersion: 1,
5804
+ reason:
5805
+ error instanceof Error
5806
+ ? error.message
5807
+ : CONVERSATION_BUSY_MESSAGE_V1,
5808
+ };
5809
+ }
5810
+ throw error;
5811
+ }
5812
+ return { status: "started", ...(await this.listConversations()) };
5746
5813
  }
5747
5814
 
5748
5815
  async lookupRun(input: unknown): Promise<ClientRunLookupV1> {
@@ -18,7 +18,10 @@ import { UiIcon, UiIconButton, UiSkeleton } from "@frockbot/client-ui";
18
18
  import { computed, inject, onBeforeUnmount, ref, watch } from "vue";
19
19
  import { frockBotWebDataKey } from "../shared.js";
20
20
  import { appletsBridgeStateV2 } from "./applets-state.js";
21
- import { mostRecentlyChangedFileV1 } from "./applets-client.js";
21
+ import {
22
+ appletSourceFingerprintV1,
23
+ mostRecentlyChangedFileV1,
24
+ } from "./applets-client.js";
22
25
  import { packageIframePagesForSlotV1 } from "./package-iframe-entries.js";
23
26
  import PackageIframeHost from "./PackageIframeHost.vue";
24
27
 
@@ -70,20 +73,30 @@ watch(appletId, () => {
70
73
  userSelectedTab.value = undefined;
71
74
  followedTab.value = "code";
72
75
  });
73
- // A generation becoming active is the moment the Applet is worth looking at.
76
+ // A generation becoming active is the moment the Applet is worth looking at
77
+ // and so is opening a page on an Applet that already has one. This runs
78
+ // immediately because a second window, or a reload, mounts the canvas with the
79
+ // viewer credential already in hand: waiting for a *change* left those pages
80
+ // on the code view of an Applet that has been live for minutes.
74
81
  watch(
75
82
  () => viewer.value?.generationId,
76
83
  (generationId) => {
77
84
  if (generationId) followedTab.value = "app";
78
85
  },
86
+ { immediate: true },
79
87
  );
80
- // A Turn writing source is the moment the code is.
88
+ // A Turn writing source is the moment the code is. The fingerprint is what
89
+ // says a file changed: the canvas re-reads the source on every poll and gets a
90
+ // fresh view object each time, so a watcher on the view itself fired on Turns
91
+ // that wrote nothing — an Applet's own tool being called, say — and threw the
92
+ // User off the live Applet mid-use. The first source to arrive is a load, not
93
+ // a write, so it never moves the tab either.
81
94
  watch(
82
- () => source.value?.files.map((file) => file.changedAt ?? file.generationId),
83
- () => {
95
+ () => appletSourceFingerprintV1(source.value),
96
+ (fingerprint, previous) => {
97
+ if (!previous || fingerprint === previous) return;
84
98
  if (isRunning.value) followedTab.value = "code";
85
99
  },
86
- { deep: true },
87
100
  );
88
101
 
89
102
  /** The file the code view is on, following the most recently changed one. */