@frockbot/plugin-shell 0.3.12 → 0.3.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,6 +27,7 @@
27
27
  "./client/FrockBotApp.vue": "./src/client/FrockBotApp.vue",
28
28
  "./client/notify": "./src/client/notify.ts",
29
29
  "./client/styles.css": "./src/client/styles.css",
30
+ "./client/voice-worklet": "./src/client/voice-worklet.ts",
30
31
  "./focus": "./src/focus.ts",
31
32
  "./frockbot.json": "./frockbot.json",
32
33
  "./host": "./src/host.ts",
@@ -34,6 +35,7 @@
34
35
  "./notification-id": "./src/notification-id.ts",
35
36
  "./package.json": "./package.json",
36
37
  "./run-cursor": "./src/run-cursor.ts",
38
+ "./run-failure-copy": "./src/run-failure-copy.ts",
37
39
  "./run-protocol": "./src/run-protocol.ts",
38
40
  "./settings-links": "./src/settings-links.ts",
39
41
  "./shared": "./src/shared.ts",
@@ -49,42 +51,42 @@
49
51
  },
50
52
  "dependencies": {
51
53
  "@cordisjs/plugin-webui": "0.8.2",
52
- "@frockbot/agent-runtime": "0.3.12",
53
- "@frockbot/application-foundation": "0.3.12",
54
- "@frockbot/catalog-core": "0.3.12",
55
- "@frockbot/client-core": "0.3.12",
56
- "@frockbot/client-ui": "0.3.12",
57
- "@frockbot/computer-core": "0.3.12",
58
- "@frockbot/computer-host-protocol": "0.3.12",
59
- "@frockbot/configuration-core": "0.3.12",
60
- "@frockbot/connection-core": "0.3.12",
61
- "@frockbot/kernel-agent-loop": "0.3.12",
62
- "@frockbot/kernel-composition": "0.3.12",
63
- "@frockbot/kernel-contracts": "0.3.12",
64
- "@frockbot/kernel-do": "0.3.12",
65
- "@frockbot/machine-protocol": "0.3.12",
66
- "@frockbot/plugin-applets": "0.3.12",
67
- "@frockbot/plugin-authoring": "0.3.12",
68
- "@frockbot/plugin-bot-template": "0.3.12",
69
- "@frockbot/plugin-computer": "0.3.12",
70
- "@frockbot/plugin-flock": "0.3.12",
71
- "@frockbot/plugin-image": "0.3.12",
72
- "@frockbot/plugin-machine-messages": "0.3.12",
73
- "@frockbot/plugin-mcp": "0.3.12",
74
- "@frockbot/plugin-memory": "0.3.12",
75
- "@frockbot/plugin-package-catalog": "0.3.12",
76
- "@frockbot/plugin-routines": "0.3.12",
77
- "@frockbot/plugin-skills": "0.3.12",
78
- "@frockbot/plugin-subagents": "0.3.12",
79
- "@frockbot/plugin-user-machine": "0.3.12",
80
- "@frockbot/protocol": "0.3.12",
54
+ "@frockbot/agent-runtime": "0.3.14",
55
+ "@frockbot/application-foundation": "0.3.14",
56
+ "@frockbot/catalog-core": "0.3.14",
57
+ "@frockbot/client-core": "0.3.14",
58
+ "@frockbot/client-ui": "0.3.14",
59
+ "@frockbot/computer-core": "0.3.14",
60
+ "@frockbot/computer-host-protocol": "0.3.14",
61
+ "@frockbot/configuration-core": "0.3.14",
62
+ "@frockbot/connection-core": "0.3.14",
63
+ "@frockbot/kernel-agent-loop": "0.3.14",
64
+ "@frockbot/kernel-composition": "0.3.14",
65
+ "@frockbot/kernel-contracts": "0.3.14",
66
+ "@frockbot/kernel-do": "0.3.14",
67
+ "@frockbot/machine-protocol": "0.3.14",
68
+ "@frockbot/plugin-applets": "0.3.14",
69
+ "@frockbot/plugin-authoring": "0.3.14",
70
+ "@frockbot/plugin-bot-template": "0.3.14",
71
+ "@frockbot/plugin-computer": "0.3.14",
72
+ "@frockbot/plugin-flock": "0.3.14",
73
+ "@frockbot/plugin-image": "0.3.14",
74
+ "@frockbot/plugin-machine-messages": "0.3.14",
75
+ "@frockbot/plugin-mcp": "0.3.14",
76
+ "@frockbot/plugin-memory": "0.3.14",
77
+ "@frockbot/plugin-package-catalog": "0.3.14",
78
+ "@frockbot/plugin-routines": "0.3.14",
79
+ "@frockbot/plugin-skills": "0.3.14",
80
+ "@frockbot/plugin-subagents": "0.3.14",
81
+ "@frockbot/plugin-user-machine": "0.3.14",
82
+ "@frockbot/protocol": "0.3.14",
81
83
  "cordis": "4.0.0-rc.8",
82
84
  "vue": "3.5.41"
83
85
  },
84
86
  "devDependencies": {
85
87
  "@cloudflare/workers-types": "latest",
86
- "@frockbot/plugin-prompt": "0.3.12",
87
- "@frockbot/plugin-tools": "0.3.12",
88
+ "@frockbot/plugin-prompt": "0.3.14",
89
+ "@frockbot/plugin-tools": "0.3.14",
88
90
  "@types/bun": "1.3.6",
89
91
  "@types/node": "26.2.0",
90
92
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -448,3 +448,81 @@ describe("the conversation prompt section", () => {
448
448
  }
449
449
  });
450
450
  });
451
+
452
+ // Two ends of one defect. The prompt tells the model to acknowledge with the
453
+ // call; the promotion catches the model that acknowledges in text anyway. The
454
+ // verification run watched a real model do exactly that — "On it — building
455
+ // the 2027 countdown applet now." as plain assistant text, then three tool
456
+ // calls — and nobody ever saw the line.
457
+ describe("the acknowledgement reaches the user", () => {
458
+ test("the prompt names the call, not just the line", () => {
459
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
460
+ "Your own text is not shown to the user",
461
+ );
462
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
463
+ "your first action is a `send_to_user` call",
464
+ );
465
+ });
466
+
467
+ test("assistant text in a tool-calling step is promoted to one send", async () => {
468
+ const mounted = await mount();
469
+ try {
470
+ await mounted.root.serial(
471
+ "agent/assistant-text",
472
+ { session: mounted.session } as never,
473
+ "On it — building the 2027 countdown applet now.",
474
+ { turn: 4, step: 2, requestId: "request-1" },
475
+ );
476
+
477
+ const sends = mounted.session.events.filter(
478
+ (event) => event.type === "send/to-user",
479
+ );
480
+ expect(sends).toHaveLength(1);
481
+ expect(sends[0]).toMatchObject({
482
+ turn: 4,
483
+ step: 2,
484
+ occurrenceId: "assistant-text:request-1",
485
+ payload: {
486
+ type: "text",
487
+ text: "On it — building the 2027 countdown applet now.",
488
+ },
489
+ });
490
+ } finally {
491
+ await mounted.dispose();
492
+ }
493
+ });
494
+
495
+ test("a step that already spoke is left alone, and a replay adds nothing", async () => {
496
+ const mounted = await mount();
497
+ try {
498
+ await invoke(
499
+ mounted,
500
+ "chat",
501
+ call(SEND_TO_USER_TOOL_V1, {
502
+ payload: { type: "text", text: "On it." },
503
+ }),
504
+ );
505
+ await mounted.root.serial(
506
+ "agent/assistant-text",
507
+ { session: mounted.session } as never,
508
+ "On it — building the countdown applet now.",
509
+ { turn: 4, step: 2, requestId: "request-1" },
510
+ );
511
+ // The same step replayed after an eviction promotes nothing new either.
512
+ await mounted.root.serial(
513
+ "agent/assistant-text",
514
+ { session: mounted.session } as never,
515
+ "On it — building the countdown applet now.",
516
+ { turn: 4, step: 2, requestId: "request-1" },
517
+ );
518
+
519
+ const sends = mounted.session.events.filter(
520
+ (event) => event.type === "send/to-user",
521
+ );
522
+ expect(sends).toHaveLength(1);
523
+ expect(sends[0]).toMatchObject({ payload: { text: "On it." } });
524
+ } finally {
525
+ await mounted.dispose();
526
+ }
527
+ });
528
+ });
package/src/agent.ts CHANGED
@@ -124,6 +124,60 @@ function openStepPositionV1(
124
124
  return { turn: started.turn, step: started.step };
125
125
  }
126
126
 
127
+ /** The longest acknowledgement worth promoting; longer is not an ack. */
128
+ export const PROMOTED_ASSISTANT_TEXT_LIMIT_V1 = 600;
129
+
130
+ /** The occurrence id a promoted send is recorded under, per model request. */
131
+ export function promotedSendOccurrenceIdV1(requestId: string): string {
132
+ return `assistant-text:${requestId}`;
133
+ }
134
+
135
+ /**
136
+ * Promotes a step's assistant text to a send when the model wrote to the
137
+ * person and then called tools without sending.
138
+ *
139
+ * The verification run is the whole argument for this. The model did exactly
140
+ * what the contract asks — "On it — building the 2027 countdown applet now.",
141
+ * one short line, no narration — and then called its tools. It went nowhere:
142
+ * the thread draws one bubble per send (issue 153) and there was no send, so
143
+ * the person watched a spinner and then a failure, with the acknowledgement
144
+ * they were owed sitting in the log where only the debug surface could see it.
145
+ *
146
+ * A step that already sent is left alone: the model's own text is scratch
147
+ * space once it has spoken, and promoting it there is how the same line
148
+ * arrives twice. The occurrence id is derived from the model request, so a
149
+ * step replayed after an eviction promotes the same text to the same
150
+ * occurrence rather than a second bubble.
151
+ *
152
+ * The prompt asks for the call and this does not replace it — a model that
153
+ * calls `send_to_user` never reaches here.
154
+ */
155
+ export async function promoteAssistantTextToSendV1(
156
+ session: Session,
157
+ text: string,
158
+ position: { turn: number; step: number; requestId: string },
159
+ ): Promise<void> {
160
+ const trimmed = text.trim();
161
+ if (trimmed.length === 0 || trimmed.length > PROMOTED_ASSISTANT_TEXT_LIMIT_V1)
162
+ return;
163
+ const occurrenceId = promotedSendOccurrenceIdV1(position.requestId);
164
+ const spokeThisStep = session.events.some(
165
+ (event) =>
166
+ event.type === "send/to-user" &&
167
+ event.turn === position.turn &&
168
+ event.step === position.step,
169
+ );
170
+ if (spokeThisStep) return;
171
+ session.append({
172
+ type: "send/to-user",
173
+ turn: position.turn,
174
+ step: position.step,
175
+ occurrenceId,
176
+ payload: { type: "text", text: trimmed },
177
+ });
178
+ await session.flush();
179
+ }
180
+
127
181
  /** What a recorded send tells the model it did. */
128
182
  function sendAcknowledgement(payload: SendToUserPayloadV1): string {
129
183
  switch (payload.type) {
@@ -160,7 +214,11 @@ export const CONVERSATION_PROMPT_TEXT_V1 = [
160
214
  "## Talking to the user",
161
215
  "",
162
216
  "Everything the user sees is a `send_to_user` call; nothing else reaches them.",
163
- 'When a request will take more than a moment, send one short line first "On it." or "Looking into that." then go quiet and work.',
217
+ // The first line said sends are the only thing the user sees, and a model
218
+ // still wrote its acknowledgement as plain assistant text and went straight
219
+ // on to call tools — nobody saw it. So the acknowledgement names the call.
220
+ "Your own text is not shown to the user. Writing a line in your reply instead of calling `send_to_user` means nobody reads it.",
221
+ 'When a request will take more than a moment, your first action is a `send_to_user` call with one short line — "On it." or "Looking into that." — and then you go quiet and work.',
164
222
  "After that, send only on a real beat: the result, a decision only the user can make, or a blocker you cannot get past.",
165
223
  "Never narrate what you are doing, what you are about to do, or which tool you are using.",
166
224
  "Never leave a question or a request hanging: before you stop, the user must have the answer, the result, or the reason there isn't one.",
@@ -361,6 +419,12 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
361
419
  createWakeParentTool(ctx.sessions),
362
420
  parentHandoff ? { admissionCeiling: parentHandoff } : undefined,
363
421
  ),
422
+ // The safety net under the prompt: a model that acknowledges the request
423
+ // in its own text instead of calling `send_to_user` still reaches the
424
+ // person. See `promoteAssistantTextToSendV1`.
425
+ ctx.on("agent/assistant-text", async (agent, text, position) => {
426
+ await promoteAssistantTextToSendV1(agent.session, text, position);
427
+ }),
364
428
  // ADR 0030. `ctx.inject` rather than a declared dependency: a host that
365
429
  // mounts the Shell without a model still gets its tools and its transcript
366
430
  // seam, and simply never compacts. The hook is evaluated after `turn/end`
@@ -701,16 +701,18 @@ describe("generic per-Turn model resolution", () => {
701
701
  };
702
702
  for (const bot of bots) {
703
703
  const runId = `${bot.botId}-disabled-package`;
704
- await expect(
705
- bot.contribution.run({
706
- userId: "user-1",
707
- botId: bot.botId,
708
- runId,
709
- sessionId: `user-1:${bot.botId}`,
710
- acceptedAt: "2026-09-02T00:01:00.000Z",
711
- text: "must fail",
712
- }),
713
- ).rejects.toThrow("Turn this model's plugin back on in Plugins");
704
+ // "Closed" is the durable record, not a rejected call. The Turn settles
705
+ // itself and the caller is handed that settlement: rethrowing over the
706
+ // top of it is what answered 500 and logged an uncaught error for a
707
+ // failure the Bot had already recorded properly.
708
+ await bot.contribution.run({
709
+ userId: "user-1",
710
+ botId: bot.botId,
711
+ runId,
712
+ sessionId: `user-1:${bot.botId}`,
713
+ acceptedAt: "2026-09-02T00:01:00.000Z",
714
+ text: "must fail",
715
+ });
714
716
  expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
715
717
  status: "failed",
716
718
  failure: expect.stringContaining(
@@ -723,16 +725,14 @@ describe("generic per-Turn model resolution", () => {
723
725
  user.connections[0] = { ...user.connections[0]!, state: "revoked" };
724
726
  for (const bot of bots) {
725
727
  const runId = `${bot.botId}-revoked-connection`;
726
- await expect(
727
- bot.contribution.run({
728
- userId: "user-1",
729
- botId: bot.botId,
730
- runId,
731
- sessionId: `user-1:${bot.botId}`,
732
- acceptedAt: "2026-09-02T00:02:00.000Z",
733
- text: "must fail",
734
- }),
735
- ).rejects.toThrow("needs reconnecting");
728
+ await bot.contribution.run({
729
+ userId: "user-1",
730
+ botId: bot.botId,
731
+ runId,
732
+ sessionId: `user-1:${bot.botId}`,
733
+ acceptedAt: "2026-09-02T00:02:00.000Z",
734
+ text: "must fail",
735
+ });
736
736
  expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
737
737
  status: "failed",
738
738
  failure: expect.stringContaining("needs reconnecting"),
@@ -344,16 +344,16 @@ describe("Bot recovery", () => {
344
344
  };
345
345
 
346
346
  failRequests = true;
347
- await expect(
348
- host().run({
349
- userId: "user-1",
350
- botId: "primary",
351
- runId: "ollama-run-uncertain",
352
- sessionId: "user-1:primary",
353
- acceptedAt: "2026-08-30T00:02:00.000Z",
354
- text: "uncertain",
355
- }),
356
- ).rejects.toThrow("response lost");
347
+ // The call resolves: the Turn settled itself, and the caller is handed
348
+ // that settlement rather than a rejection thrown over the top of it.
349
+ await host().run({
350
+ userId: "user-1",
351
+ botId: "primary",
352
+ runId: "ollama-run-uncertain",
353
+ sessionId: "user-1:primary",
354
+ acceptedAt: "2026-08-30T00:02:00.000Z",
355
+ text: "uncertain",
356
+ });
357
357
  // Ollama keeps no addressable copy of a completion, so a failure raised
358
358
  // before the first stream event is definitive rather than uncertain: the
359
359
  // run settles as a failed Turn instead of parking on a retrieval this
@@ -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. */