@frockbot/plugin-shell 0.3.20 → 0.3.22

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.20",
3
+ "version": "0.3.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,42 +52,42 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@cordisjs/plugin-webui": "0.8.2",
55
- "@frockbot/agent-runtime": "0.3.20",
56
- "@frockbot/application-foundation": "0.3.20",
57
- "@frockbot/catalog-core": "0.3.20",
58
- "@frockbot/client-core": "0.3.20",
59
- "@frockbot/client-ui": "0.3.20",
60
- "@frockbot/computer-core": "0.3.20",
61
- "@frockbot/computer-host-protocol": "0.3.20",
62
- "@frockbot/configuration-core": "0.3.20",
63
- "@frockbot/connection-core": "0.3.20",
64
- "@frockbot/kernel-agent-loop": "0.3.20",
65
- "@frockbot/kernel-composition": "0.3.20",
66
- "@frockbot/kernel-contracts": "0.3.20",
67
- "@frockbot/kernel-do": "0.3.20",
68
- "@frockbot/machine-protocol": "0.3.20",
69
- "@frockbot/plugin-applets": "0.3.20",
70
- "@frockbot/plugin-authoring": "0.3.20",
71
- "@frockbot/plugin-bot-template": "0.3.20",
72
- "@frockbot/plugin-computer": "0.3.20",
73
- "@frockbot/plugin-flock": "0.3.20",
74
- "@frockbot/plugin-image": "0.3.20",
75
- "@frockbot/plugin-machine-messages": "0.3.20",
76
- "@frockbot/plugin-mcp": "0.3.20",
77
- "@frockbot/plugin-memory": "0.3.20",
78
- "@frockbot/plugin-package-catalog": "0.3.20",
79
- "@frockbot/plugin-routines": "0.3.20",
80
- "@frockbot/plugin-skills": "0.3.20",
81
- "@frockbot/plugin-subagents": "0.3.20",
82
- "@frockbot/plugin-user-machine": "0.3.20",
83
- "@frockbot/protocol": "0.3.20",
55
+ "@frockbot/agent-runtime": "0.3.22",
56
+ "@frockbot/application-foundation": "0.3.22",
57
+ "@frockbot/catalog-core": "0.3.22",
58
+ "@frockbot/client-core": "0.3.22",
59
+ "@frockbot/client-ui": "0.3.22",
60
+ "@frockbot/computer-core": "0.3.22",
61
+ "@frockbot/computer-host-protocol": "0.3.22",
62
+ "@frockbot/configuration-core": "0.3.22",
63
+ "@frockbot/connection-core": "0.3.22",
64
+ "@frockbot/kernel-agent-loop": "0.3.22",
65
+ "@frockbot/kernel-composition": "0.3.22",
66
+ "@frockbot/kernel-contracts": "0.3.22",
67
+ "@frockbot/kernel-do": "0.3.22",
68
+ "@frockbot/machine-protocol": "0.3.22",
69
+ "@frockbot/plugin-applets": "0.3.22",
70
+ "@frockbot/plugin-authoring": "0.3.22",
71
+ "@frockbot/plugin-bot-template": "0.3.22",
72
+ "@frockbot/plugin-computer": "0.3.22",
73
+ "@frockbot/plugin-flock": "0.3.22",
74
+ "@frockbot/plugin-image": "0.3.22",
75
+ "@frockbot/plugin-machine-messages": "0.3.22",
76
+ "@frockbot/plugin-mcp": "0.3.22",
77
+ "@frockbot/plugin-memory": "0.3.22",
78
+ "@frockbot/plugin-package-catalog": "0.3.22",
79
+ "@frockbot/plugin-routines": "0.3.22",
80
+ "@frockbot/plugin-skills": "0.3.22",
81
+ "@frockbot/plugin-subagents": "0.3.22",
82
+ "@frockbot/plugin-user-machine": "0.3.22",
83
+ "@frockbot/protocol": "0.3.22",
84
84
  "cordis": "4.0.0-rc.8",
85
85
  "vue": "3.5.41"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@cloudflare/workers-types": "latest",
89
- "@frockbot/plugin-prompt": "0.3.20",
90
- "@frockbot/plugin-tools": "0.3.20",
89
+ "@frockbot/plugin-prompt": "0.3.22",
90
+ "@frockbot/plugin-tools": "0.3.22",
91
91
  "@types/bun": "1.3.6",
92
92
  "@types/node": "26.2.0",
93
93
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type ToolCall,
8
8
  type ToolExecutionContext,
9
9
  type TurnTypeV1,
10
+ TURN_DEADLINE_MS_V1,
10
11
  } from "@frockbot/kernel-contracts";
11
12
  import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
12
13
  import { ToolRegistry } from "@frockbot/plugin-tools";
@@ -19,6 +20,7 @@ import {
19
20
  PARENT_HANDOFF_CAPABILITY_V1,
20
21
  SEND_MESSAGE_ALIAS_V1,
21
22
  SEND_TO_USER_TOOL_V1,
23
+ TIME_BUDGET_WARNING_MS_V1,
22
24
  USER_VOICE_CAPABILITY_V1,
23
25
  WAKE_PARENT_TOOL_V1,
24
26
  } from "./agent.ts";
@@ -575,6 +577,36 @@ describe("the acknowledgement reaches the user", () => {
575
577
  }
576
578
  });
577
579
 
580
+ test("the last two minutes of a Turn tell the model to send a status", async () => {
581
+ const mounted = await mount();
582
+ try {
583
+ const assemble = (remainingMs: number) =>
584
+ mounted.root.systemPrompt.assemble({
585
+ sessionId: "session-1",
586
+ provider: "provider-1",
587
+ model: "model-1",
588
+ turnType: "chat",
589
+ step: { current: 61, max: 64 },
590
+ deadline: {
591
+ at: TURN_DEADLINE_MS_V1,
592
+ now: TURN_DEADLINE_MS_V1 - remainingMs,
593
+ },
594
+ });
595
+ const boundary = await assemble(TIME_BUDGET_WARNING_MS_V1);
596
+ expect(boundary.text).not.toContain("<time_budget>");
597
+ const warning = await assemble(TIME_BUDGET_WARNING_MS_V1 - 1);
598
+ expect(warning.text).toContain("<time_budget>");
599
+ expect(warning.text).toContain("fewer than 2 minutes left");
600
+ expect(warning.text).toContain("Do not start new work");
601
+ expect(warning.text).toContain(SEND_TO_USER_TOOL_V1);
602
+ expect(warning.text.indexOf("<time_budget>")).toBeGreaterThan(
603
+ warning.text.indexOf("<step_budget>"),
604
+ );
605
+ } finally {
606
+ await mounted.dispose();
607
+ }
608
+ });
609
+
578
610
  test("a step that already spoke is left alone, and a replay adds nothing", async () => {
579
611
  const mounted = await mount();
580
612
  try {
package/src/agent.ts CHANGED
@@ -234,6 +234,11 @@ export const STEP_BUDGET_PROMPT_SECTION_V1 = "step-budget";
234
234
  export const STEP_BUDGET_PROMPT_ORDER_V1 = 90;
235
235
  /** The section appears when this many steps or fewer remain after this one. */
236
236
  export const STEP_BUDGET_WARNING_STEPS_V1 = 3;
237
+ export const TIME_BUDGET_PROMPT_SECTION_V1 = "time-budget";
238
+ /** Immediately after the step warning, so the time warning is read last. */
239
+ export const TIME_BUDGET_PROMPT_ORDER_V1 = 91;
240
+ /** The section appears only once strictly fewer than two minutes remain. */
241
+ export const TIME_BUDGET_WARNING_MS_V1 = 2 * 60_000;
237
242
 
238
243
  /**
239
244
  * What the model is told when its reply is about to be stopped.
@@ -265,6 +270,23 @@ export function stepBudgetPromptTextV1(context: {
265
270
  ].join("\n");
266
271
  }
267
272
 
273
+ /** Warns by wall-clock budget even when the model has used few steps. */
274
+ export function timeBudgetPromptTextV1(context: {
275
+ deadline?: { at: number; now: number };
276
+ }): string {
277
+ const deadline = context.deadline;
278
+ if (!deadline) return "";
279
+ const remaining = deadline.at - deadline.now;
280
+ if (!Number.isFinite(remaining) || remaining >= TIME_BUDGET_WARNING_MS_V1) {
281
+ return "";
282
+ }
283
+ return [
284
+ "<time_budget>",
285
+ `This Turn has fewer than 2 minutes left before it is stopped automatically. Do not start new work. Call \`${SEND_TO_USER_TOOL_V1}\` now with a short status for the person: what is finished, what is not, and what they can do next.`,
286
+ "</time_budget>",
287
+ ].join("\n");
288
+ }
289
+
268
290
  export const CONVERSATION_PROMPT_TEXT_V1 = [
269
291
  "## Talking to the user",
270
292
  "",
@@ -469,6 +491,11 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
469
491
  order: STEP_BUDGET_PROMPT_ORDER_V1,
470
492
  render: (context) => stepBudgetPromptTextV1(context),
471
493
  }),
494
+ ctx.systemPrompt.register({
495
+ id: TIME_BUDGET_PROMPT_SECTION_V1,
496
+ order: TIME_BUDGET_PROMPT_ORDER_V1,
497
+ render: (context) => timeBudgetPromptTextV1(context),
498
+ }),
472
499
  ctx.tools.register(
473
500
  createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
474
501
  userVoice ? { admissionCeiling: userVoice } : undefined,
@@ -8,6 +8,7 @@ import {
8
8
  resolveAppletCompositionV1,
9
9
  APPLET_DIRECTORY_REVISION_SEEN_KEY,
10
10
  type AppletInstanceBindingV1,
11
+ type AppletSourceSyncOutcomeV1,
11
12
  type AppletUserDirectoryV1,
12
13
  } from "./backend-applets.js";
13
14
  import {
@@ -344,6 +345,7 @@ describe("ctx.applets", () => {
344
345
  directory?: Partial<AppletUserDirectoryV1>;
345
346
  instance?: Partial<AppletInstanceBindingV1>;
346
347
  storage?: ReturnType<typeof memoryStorage>;
348
+ synced?: AppletSourceSyncOutcomeV1;
347
349
  }) {
348
350
  const storage = options.storage ?? memoryStorage();
349
351
  const recorded: unknown[] = [];
@@ -455,6 +457,9 @@ describe("ctx.applets", () => {
455
457
  stat: () =>
456
458
  Promise.resolve({ status: "not-found", reason: "" }) as never,
457
459
  },
460
+ ...(options.synced
461
+ ? { syncSourceRootNow: () => Promise.resolve(options.synced!) }
462
+ : {}),
458
463
  composition: {
459
464
  current: () => bootstrap(),
460
465
  lastKnownGood: () => bootstrap(),
@@ -488,6 +493,39 @@ describe("ctx.applets", () => {
488
493
  );
489
494
  });
490
495
 
496
+ // Production, 2026-09-04: a 470 KB `dist/ui.html` the Bot had just built was
497
+ // reported as `not-found: run \`applet build\` … first`, and the Bot ran the
498
+ // build again, three times. The build was fine; the sync could not carry the
499
+ // file. The publish says which of the two it was now.
500
+ test("publish says a built file did not reach the Workspace rather than blaming the build", async () => {
501
+ const { host: capability } = host({
502
+ files: {},
503
+ synced: {
504
+ status: "degraded",
505
+ detail:
506
+ '1 sync operation failed at "dist/ui.html": unavailable: the Computer could not answer.',
507
+ },
508
+ });
509
+ const outcome = await capability.publish({ appletId: APPLET }, scope);
510
+ expect(outcome.status).toBe("failed");
511
+ const reason = outcome.status === "failed" ? outcome.reason : "";
512
+ expect(reason).toBe(
513
+ '"dist/server.js" is on the Computer but did not reach the Workspace: 1 sync operation failed at "dist/ui.html": unavailable: the Computer could not answer.',
514
+ );
515
+ expect(reason).not.toMatch(/applet build/);
516
+ });
517
+
518
+ test("publish still blames the build when the pull of the source root was clean", async () => {
519
+ const { host: capability } = host({
520
+ files: {},
521
+ synced: { status: "ok", detail: "" },
522
+ });
523
+ const outcome = await capability.publish({ appletId: APPLET }, scope);
524
+ expect(outcome.status === "failed" && outcome.reason).toMatch(
525
+ /applet build/,
526
+ );
527
+ });
528
+
491
529
  test("publish refuses a manifest whose hashes do not match the bytes", async () => {
492
530
  const { host: capability } = host({
493
531
  files: {
@@ -58,6 +58,20 @@ export const APPLET_PUBLISH_EFFECT_PREFIX = "applets:publish-effect:";
58
58
  */
59
59
  export const APPLET_DIRECTORY_REVISION_SEEN_KEY = "applets:directory-revision";
60
60
 
61
+ /**
62
+ * How the pre-publish pull of the Applet's source root went.
63
+ *
64
+ * `ok` and `skipped` mean the store now holds what the Computer holds. Anything
65
+ * else means the two may differ, and a `dist/` file the publish cannot find is
66
+ * then far more likely to be one the sync could not carry than one `applet
67
+ * build` never wrote.
68
+ */
69
+ export interface AppletSourceSyncOutcomeV1 {
70
+ status: "ok" | "degraded" | "unavailable" | "refused" | "skipped";
71
+ /** What the sync said went wrong, empty when it had nothing to say. */
72
+ detail: string;
73
+ }
74
+
61
75
  /** The three files `applet build` writes, read from the durable root. */
62
76
  export const APPLET_DIST_FILES_V1 = [
63
77
  "dist/server.js",
@@ -293,8 +307,12 @@ export interface AppletCapabilityHostOptionsV1 {
293
307
  * store is read as it stands — correct but possibly stale — and the Bot is
294
308
  * told so in the failure when the files are missing, rather than the publish
295
309
  * silently using old bytes.
310
+ *
311
+ * What it answers is how that pull went. A build that is on the Computer but
312
+ * did not reach the store is a different failure from a build that was never
313
+ * run, and the publish can only say which when it is told.
296
314
  */
297
- syncSourceRootNow?(appletId: string): Promise<void>;
315
+ syncSourceRootNow?(appletId: string): Promise<AppletSourceSyncOutcomeV1>;
298
316
  composition: Pick<CompositionStore, "current" | "lastKnownGood" | "propose">;
299
317
  now?(): Date;
300
318
  }
@@ -594,11 +612,20 @@ export function createAppletCapabilityHostV1(
594
612
  appletId: string,
595
613
  file: string,
596
614
  maximum: number,
615
+ synced?: AppletSourceSyncOutcomeV1,
597
616
  ): Promise<{ text: string } | { failure: string }> {
598
617
  const outcome = await options.workspace.read(
599
618
  appletDistPathV1(options.userId, appletId, file),
600
619
  );
601
620
  if (outcome.status !== "ok") {
621
+ // A pull that did not finish is the likelier explanation than an
622
+ // unbuilt Applet, and telling the Bot to build again would send it
623
+ // round a loop that cannot end.
624
+ if (synced && synced.status !== "ok" && synced.status !== "skipped") {
625
+ return {
626
+ failure: `"${file}" is on the Computer but did not reach the Workspace: ${synced.detail || "the sync could not carry it"}`,
627
+ };
628
+ }
602
629
  return {
603
630
  failure: `"${file}" is ${outcome.status}: run \`applet build\` in applets/${appletId} on the Computer first`,
604
631
  };
@@ -731,12 +758,13 @@ export function createAppletCapabilityHostV1(
731
758
 
732
759
  // Force a pull of the source root so the publish sees what the Bot just
733
760
  // built, not the last synced copy. See the seam note on the option.
734
- await options.syncSourceRootNow?.(input.appletId);
761
+ const synced = await options.syncSourceRootNow?.(input.appletId);
735
762
 
736
763
  const server = await readFile(
737
764
  input.appletId,
738
765
  "dist/server.js",
739
766
  APPLET_MAX_SERVER_BYTES_V1,
767
+ synced,
740
768
  );
741
769
  if ("failure" in server) {
742
770
  return settle(failed(input.appletId, "unbuilt", server.failure));
@@ -745,6 +773,7 @@ export function createAppletCapabilityHostV1(
745
773
  input.appletId,
746
774
  "dist/ui.html",
747
775
  APPLET_MAX_UI_BYTES_V1,
776
+ synced,
748
777
  );
749
778
  if ("failure" in ui) {
750
779
  return settle(failed(input.appletId, "unbuilt", ui.failure));
@@ -753,6 +782,7 @@ export function createAppletCapabilityHostV1(
753
782
  input.appletId,
754
783
  "dist/manifest.json",
755
784
  APPLET_MAX_MANIFEST_BYTES_V1,
785
+ synced,
756
786
  );
757
787
  if ("failure" in manifestFile) {
758
788
  return settle(failed(input.appletId, "unbuilt", manifestFile.failure));
package/src/backend.ts CHANGED
@@ -2492,7 +2492,9 @@ export class ShellBotBackendContribution {
2492
2492
  sessions: typeof active.mounted.runtime.root.sessions;
2493
2493
  };
2494
2494
  const computerIdentity = { userId: identity.userId };
2495
- if (!root.computers?.assignment(computerIdentity)) return;
2495
+ if (!root.computers?.assignment(computerIdentity)) {
2496
+ return { status: "skipped", detail: "" } as const;
2497
+ }
2496
2498
  const session = root.sessions.get(active.sessionId);
2497
2499
  const started = session?.events.findLast(
2498
2500
  (event) => event.type === "step/start",
@@ -2503,7 +2505,7 @@ export class ShellBotBackendContribution {
2503
2505
  { botId: identity.botId },
2504
2506
  { signal: active.signal },
2505
2507
  );
2506
- await syncWorkspaceRootNowV1({
2508
+ const summary = await syncWorkspaceRootNowV1({
2507
2509
  computer,
2508
2510
  sessions: root.sessions,
2509
2511
  sessionId: active.sessionId,
@@ -2514,6 +2516,7 @@ export class ShellBotBackendContribution {
2514
2516
  ),
2515
2517
  signal: active.signal,
2516
2518
  });
2519
+ return { status: summary.status, detail: summary.detail };
2517
2520
  }
2518
2521
  : undefined,
2519
2522
  composition: {