@frockbot/plugin-shell 0.3.9 → 0.3.11

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.9",
3
+ "version": "0.3.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,6 +30,7 @@
30
30
  "./frockbot.json": "./frockbot.json",
31
31
  "./host": "./src/host.ts",
32
32
  "./manifest": "./src/manifest.ts",
33
+ "./notification-id": "./src/notification-id.ts",
33
34
  "./package.json": "./package.json",
34
35
  "./run-cursor": "./src/run-cursor.ts",
35
36
  "./run-protocol": "./src/run-protocol.ts",
@@ -47,41 +48,42 @@
47
48
  },
48
49
  "dependencies": {
49
50
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.9",
51
- "@frockbot/application-foundation": "0.3.9",
52
- "@frockbot/catalog-core": "0.3.9",
53
- "@frockbot/client-core": "0.3.9",
54
- "@frockbot/client-ui": "0.3.9",
55
- "@frockbot/computer-core": "0.3.9",
56
- "@frockbot/computer-host-protocol": "0.3.9",
57
- "@frockbot/configuration-core": "0.3.9",
58
- "@frockbot/connection-core": "0.3.9",
59
- "@frockbot/kernel-agent-loop": "0.3.9",
60
- "@frockbot/kernel-composition": "0.3.9",
61
- "@frockbot/kernel-contracts": "0.3.9",
62
- "@frockbot/kernel-do": "0.3.9",
63
- "@frockbot/machine-protocol": "0.3.9",
64
- "@frockbot/plugin-applets": "0.3.9",
65
- "@frockbot/plugin-authoring": "0.3.9",
66
- "@frockbot/plugin-bot-template": "0.3.9",
67
- "@frockbot/plugin-computer": "0.3.9",
68
- "@frockbot/plugin-flock": "0.3.9",
69
- "@frockbot/plugin-image": "0.3.9",
70
- "@frockbot/plugin-machine-messages": "0.3.9",
71
- "@frockbot/plugin-mcp": "0.3.9",
72
- "@frockbot/plugin-memory": "0.3.9",
73
- "@frockbot/plugin-package-catalog": "0.3.9",
74
- "@frockbot/plugin-routines": "0.3.9",
75
- "@frockbot/plugin-skills": "0.3.9",
76
- "@frockbot/plugin-subagents": "0.3.9",
77
- "@frockbot/plugin-user-machine": "0.3.9",
78
- "@frockbot/protocol": "0.3.9",
51
+ "@frockbot/agent-runtime": "0.3.11",
52
+ "@frockbot/application-foundation": "0.3.11",
53
+ "@frockbot/catalog-core": "0.3.11",
54
+ "@frockbot/client-core": "0.3.11",
55
+ "@frockbot/client-ui": "0.3.11",
56
+ "@frockbot/computer-core": "0.3.11",
57
+ "@frockbot/computer-host-protocol": "0.3.11",
58
+ "@frockbot/configuration-core": "0.3.11",
59
+ "@frockbot/connection-core": "0.3.11",
60
+ "@frockbot/kernel-agent-loop": "0.3.11",
61
+ "@frockbot/kernel-composition": "0.3.11",
62
+ "@frockbot/kernel-contracts": "0.3.11",
63
+ "@frockbot/kernel-do": "0.3.11",
64
+ "@frockbot/machine-protocol": "0.3.11",
65
+ "@frockbot/plugin-applets": "0.3.11",
66
+ "@frockbot/plugin-authoring": "0.3.11",
67
+ "@frockbot/plugin-bot-template": "0.3.11",
68
+ "@frockbot/plugin-computer": "0.3.11",
69
+ "@frockbot/plugin-flock": "0.3.11",
70
+ "@frockbot/plugin-image": "0.3.11",
71
+ "@frockbot/plugin-machine-messages": "0.3.11",
72
+ "@frockbot/plugin-mcp": "0.3.11",
73
+ "@frockbot/plugin-memory": "0.3.11",
74
+ "@frockbot/plugin-package-catalog": "0.3.11",
75
+ "@frockbot/plugin-routines": "0.3.11",
76
+ "@frockbot/plugin-skills": "0.3.11",
77
+ "@frockbot/plugin-subagents": "0.3.11",
78
+ "@frockbot/plugin-user-machine": "0.3.11",
79
+ "@frockbot/protocol": "0.3.11",
79
80
  "cordis": "4.0.0-rc.8",
80
81
  "vue": "3.5.41"
81
82
  },
82
83
  "devDependencies": {
83
84
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.9",
85
+ "@frockbot/plugin-prompt": "0.3.11",
86
+ "@frockbot/plugin-tools": "0.3.11",
85
87
  "@types/bun": "1.3.6",
86
88
  "@types/node": "26.2.0",
87
89
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -8,9 +8,12 @@ import {
8
8
  type ToolExecutionContext,
9
9
  type TurnTypeV1,
10
10
  } from "@frockbot/kernel-contracts";
11
+ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
11
12
  import { ToolRegistry } from "@frockbot/plugin-tools";
12
13
  import { Context } from "cordis";
13
14
  import {
15
+ CONVERSATION_PROMPT_SECTION_V1,
16
+ CONVERSATION_PROMPT_TEXT_V1,
14
17
  shellAdmissionCeilingV1,
15
18
  shellAgentPlugin,
16
19
  PARENT_HANDOFF_CAPABILITY_V1,
@@ -31,6 +34,7 @@ interface Mounted {
31
34
  async function mount(): Promise<Mounted> {
32
35
  const root = new Context();
33
36
  await root.plugin(SessionStore);
37
+ await root.plugin(SystemPromptRegistry);
34
38
  await root.plugin(ToolRegistry);
35
39
  const session = root.sessions.create(SESSION_ID);
36
40
  session.appendBatch([
@@ -382,3 +386,65 @@ describe("wake_parent", () => {
382
386
  }
383
387
  });
384
388
  });
389
+
390
+ // The conversational contract is a prompt section and a tool description, and
391
+ // the two have to say the same thing: a model that read one and not the other
392
+ // would have half the rule.
393
+ describe("the conversation prompt section", () => {
394
+ test("is assembled into the system prompt the model reads", async () => {
395
+ const mounted = await mount();
396
+ try {
397
+ const assembled = await mounted.root.systemPrompt.assemble({
398
+ sessionId: SESSION_ID,
399
+ provider: "test",
400
+ model: "test-model",
401
+ turnType: "chat",
402
+ });
403
+
404
+ const section = assembled.sections.find(
405
+ (candidate) => candidate.id === CONVERSATION_PROMPT_SECTION_V1,
406
+ );
407
+ expect(section?.text).toBe(CONVERSATION_PROMPT_TEXT_V1);
408
+ expect(assembled.text).toContain(CONVERSATION_PROMPT_TEXT_V1);
409
+ } finally {
410
+ await mounted.dispose();
411
+ }
412
+ });
413
+
414
+ test("says acknowledge first, then beats, and never the steps between", () => {
415
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain("On it.");
416
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
417
+ "send only on a real beat: the result, a decision only the user can make, or a blocker",
418
+ );
419
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
420
+ "Never narrate what you are doing",
421
+ );
422
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
423
+ "Never leave a question or a request hanging",
424
+ );
425
+ // Short enough to be read as guidance rather than skimmed as a document.
426
+ expect(CONVERSATION_PROMPT_TEXT_V1.split("\n").length).toBeLessThanOrEqual(
427
+ 12,
428
+ );
429
+ });
430
+
431
+ test("matches what the send tool's own description tells the model", async () => {
432
+ const mounted = await mount();
433
+ try {
434
+ const schema = mounted.root.tools
435
+ .schemas({ turnType: "chat" })
436
+ .find((tool) => tool.name === SEND_TO_USER_TOOL_V1);
437
+ const description = schema?.description ?? "";
438
+
439
+ expect(description).toContain("only way to say anything the user sees");
440
+ // When to call it, not only what it does.
441
+ expect(description).toContain("Call it once, immediately");
442
+ expect(description).toContain("Call it again only on a real");
443
+ expect(description).toContain("narrate a step or a tool");
444
+ expect(description).toContain("never end your Turn leaving the");
445
+ expect(description).toContain("keep it short");
446
+ } finally {
447
+ await mounted.dispose();
448
+ }
449
+ });
450
+ });
package/src/agent.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  //
4
4
  // Two tools, and no authority of its own:
5
5
  //
6
+ // 0. One prompt section, `conversation`: when to speak and when not to. It
7
+ // is contributed beside the tool so the section and the tool description
8
+ // cannot drift into telling the model two different things.
9
+ //
6
10
  // 1. `send_to_user` (legacy alias `send_message`) — parity register row 57b.
7
11
  // One tool carrying the typed payload union, admitted on chat turns only,
8
12
  // recording each send as `send/to-user` on the durable log. Row 57c: a
@@ -127,8 +131,38 @@ function sendAcknowledgement(payload: SendToUserPayloadV1): string {
127
131
  }
128
132
  }
129
133
 
134
+ /**
135
+ * The Bot's conversational contract, in the words the model reads.
136
+ *
137
+ * It lives here rather than in a prompt Package because it is the same rule
138
+ * the send tool's own description states: one place to write it, so the
139
+ * section and the tool cannot drift into telling the model two things. The
140
+ * Shell already owns the voice; it owns how the voice is used.
141
+ */
142
+ export const CONVERSATION_PROMPT_SECTION_V1 = "conversation";
143
+ /** Ordered after identity (0), before anything a Package contributes. */
144
+ export const CONVERSATION_PROMPT_ORDER_V1 = 1;
145
+
146
+ export const CONVERSATION_PROMPT_TEXT_V1 = [
147
+ "## Talking to the user",
148
+ "",
149
+ "Everything the user sees is a `send_to_user` call; nothing else reaches them.",
150
+ '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.',
151
+ "After that, send only on a real beat: the result, a decision only the user can make, or a blocker you cannot get past.",
152
+ "Never narrate what you are doing, what you are about to do, or which tool you are using.",
153
+ "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.",
154
+ "When the work is finished, send the result itself, not an account of how you got it.",
155
+ "Keep every message short — a line or two, no preamble and no sign-off.",
156
+ "Don't say the same thing twice.",
157
+ ].join("\n");
158
+
130
159
  const SEND_TO_USER_DESCRIPTION = [
131
160
  "Speak to the user. This is the only way to say anything the user sees.",
161
+ "Call it once, immediately, with one short line when the request will take",
162
+ "more than a moment, then work in silence. Call it again only on a real",
163
+ "beat: the result, a decision only the user can make, or a blocker. Do not",
164
+ "call it to narrate a step or a tool, and never end your Turn leaving the",
165
+ "user's question unanswered. Each call is one message; keep it short.",
132
166
  "The payload is one of:",
133
167
  '{"type":"text","text":"…"}',
134
168
  '{"type":"attachment","url":"https://…","name":"…","mediaType":"…"}',
@@ -295,6 +329,13 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
295
329
  const userVoice = shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1);
296
330
  const parentHandoff = shellAdmissionCeilingV1(PARENT_HANDOFF_CAPABILITY_V1);
297
331
  const disposers = [
332
+ // The voice and the rules for using it are contributed together, so a
333
+ // Composition that admits the send tool always carries the contract.
334
+ ctx.systemPrompt.register({
335
+ id: CONVERSATION_PROMPT_SECTION_V1,
336
+ order: CONVERSATION_PROMPT_ORDER_V1,
337
+ render: () => CONVERSATION_PROMPT_TEXT_V1,
338
+ }),
298
339
  ctx.tools.register(
299
340
  createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
300
341
  userVoice ? { admissionCeiling: userVoice } : undefined,
@@ -327,6 +368,6 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
327
368
  for (const dispose of disposers.toReversed()) dispose();
328
369
  };
329
370
  };
330
- shellAgentPlugin.inject = ["tools", "sessions"];
371
+ shellAgentPlugin.inject = ["tools", "sessions", "systemPrompt"];
331
372
 
332
373
  export default shellAgentPlugin;
@@ -8,6 +8,7 @@ import type {
8
8
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
9
9
  import { createShellBotBackendContribution } from "./backend.js";
10
10
  import { createIsolateCapabilityHost } from "./backend-isolate.js";
11
+ import { notificationIdV1 } from "./notification-id.js";
11
12
 
12
13
  class MemoryStorage {
13
14
  readonly values = new Map<string, unknown>();
@@ -600,8 +601,15 @@ describe("generic per-Turn model resolution", () => {
600
601
  });
601
602
  await expect(contribution.listNotifications()).resolves.toEqual([
602
603
  expect.objectContaining({
603
- notificationId:
604
- "package-connection-unavailable:run-1:bot-authored:flock-ai-ambient",
604
+ // Colons would fail the acknowledge decoder, so the mint replaces
605
+ // them and folds in a digest of the raw parts; a notification nobody
606
+ // can acknowledge 400s forever.
607
+ notificationId: notificationIdV1(
608
+ "package-connection-unavailable",
609
+ "run-1",
610
+ "bot-authored",
611
+ "flock-ai-ambient",
612
+ ),
605
613
  title: "Connection unavailable",
606
614
  }),
607
615
  ]);
@@ -320,7 +320,7 @@ export function createPackageCatalogHost(
320
320
  change.action === "remove"
321
321
  ? change.input.packageId
322
322
  : change.input.catalogId,
323
- ...(change.action === "remove"
323
+ ...(change.action === "remove" || change.input.contentHash === undefined
324
324
  ? {}
325
325
  : { contentHash: change.input.contentHash }),
326
326
  }),
@@ -591,6 +591,13 @@ export function createPackageCatalogHost(
591
591
  let user: UserSettingsViewV1;
592
592
  let base: CompositionGenerationV1;
593
593
  let intent: PackageCatalogChangeIntentV1;
594
+ /**
595
+ * The entry names a compiled-in Package that is already required core in
596
+ * this Bot's Composition, so installing it is a User settings change and
597
+ * nothing else. Set on the install path and re-derived on a resumed
598
+ * intent, which carries no bundle hash precisely when this is true.
599
+ */
600
+ let firstParty = false;
594
601
  try {
595
602
  user = await options.user.read();
596
603
  base = await options.composition.lastKnownGood();
@@ -611,6 +618,12 @@ export function createPackageCatalogHost(
611
618
  );
612
619
  entry = resumed;
613
620
  intent = existingIntent;
621
+ firstParty =
622
+ existingIntent.action !== "remove" &&
623
+ !resumed.bundle &&
624
+ base.members.find(
625
+ (candidate) => candidate.packageId === resumed.packageId,
626
+ )?.provenance.kind === "first-party";
614
627
  } else if (action === "remove") {
615
628
  const packageId = request.change.input.packageId;
616
629
  const member = base.members.find(
@@ -673,21 +686,46 @@ export function createPackageCatalogHost(
673
686
  const resolved = await pinnedEntry(request.change.input.catalogId);
674
687
  user = resolved.user;
675
688
  entry = resolved.entry;
676
- if (!entry.bundle) {
677
- throw new Error(
678
- `Catalog entry "${entry.catalogId}" does not carry installable code`,
679
- );
680
- }
681
- if (entry.bundle.contentHash !== request.change.input.contentHash) {
689
+ // A first-party entry names reviewed compiled-in code and publishes
690
+ // no bundle: there is no artifact to verify and no hash to pin. The
691
+ // Plugins page has always installed such an entry; refusing it here
692
+ // is what made `package_install` by chat disagree with the UI (F4).
693
+ if (entry.bundle) {
694
+ if (entry.bundle.contentHash !== request.change.input.contentHash) {
695
+ throw new Error(
696
+ `Catalog entry "${entry.catalogId}" has content hash "${entry.bundle.contentHash}", not "${request.change.input.contentHash}"`,
697
+ );
698
+ }
699
+ await verifyArtifact(entry);
700
+ } else if (request.change.input.contentHash !== undefined) {
682
701
  throw new Error(
683
- `Catalog entry "${entry.catalogId}" has content hash "${entry.bundle.contentHash}", not "${request.change.input.contentHash}"`,
702
+ `Catalog entry "${entry.catalogId}" does not carry a Package bundle`,
684
703
  );
685
704
  }
686
- await verifyArtifact(entry);
687
705
  const member = base.members.find(
688
706
  (candidate) => candidate.packageId === entry.packageId,
689
707
  );
690
- if (action === "install" && member) {
708
+ // Installing a first-party entry changes the *User's* settings, not
709
+ // the Composition: the compiled-in Package is already required core
710
+ // in the bootstrap generation (every one of them is), and
711
+ // `assertRequiredCoreSet` refuses any proposal that restates a
712
+ // first-party member with catalog provenance. So "already a member"
713
+ // is the normal state here, not a conflict — the guard below is
714
+ // about a *bundle-backed* entry, whose code really would be added
715
+ // twice.
716
+ firstParty =
717
+ !entry.bundle && member?.provenance.kind === "first-party";
718
+ if (firstParty) {
719
+ // The one integrity check left for a member nothing re-hashes at
720
+ // mount: the entry must name the manifest the application actually
721
+ // compiled in, or the Catalog has drifted from this deployment.
722
+ if (member!.manifestHash !== entry.manifestHash) {
723
+ throw new Error(
724
+ `Catalog entry "${entry.catalogId}" names manifest "${entry.manifestHash}", but this deployment ships "${member!.manifestHash}"`,
725
+ );
726
+ }
727
+ }
728
+ if (action === "install" && member && !firstParty) {
691
729
  throw new Error(
692
730
  `Package "${entry.packageId}" is already in this Bot's Composition`,
693
731
  );
@@ -716,7 +754,7 @@ export function createPackageCatalogHost(
716
754
  version: entry.version,
717
755
  catalogId: entry.catalogId,
718
756
  catalogGeneration: resolved.generation,
719
- contentHash: entry.bundle.contentHash,
757
+ ...(entry.bundle ? { contentHash: entry.bundle.contentHash } : {}),
720
758
  manifestHash: entry.manifestHash,
721
759
  summary:
722
760
  request.change.input.summary ??
@@ -744,16 +782,27 @@ export function createPackageCatalogHost(
744
782
  // Intent and manifest before the User settings effect. The manifest key
745
783
  // is the existing isolate loader seam used by authored Packages.
746
784
  if (!existingIntent) {
785
+ const bundle = entry.bundle;
747
786
  await options.storage.put({
748
787
  [packageCatalogChangeIntentKey(request.effectId)]: intent,
749
- [authorshipManifestKey(intent.manifestHash)]: {
750
- schemaVersion: 1,
751
- manifestHash: intent.manifestHash,
752
- packageId: intent.packageId,
753
- version: intent.version,
754
- manifest: entry.bundle!.manifest,
755
- createdAt: intent.recordedAt,
756
- } satisfies AuthoredManifestRecordV1,
788
+ // A bundle-less entry stores no manifest document, because it adds
789
+ // no Composition member to mount one for: the compiled-in Package is
790
+ // already required core, running the manifest this deployment
791
+ // shipped. The entry's `manifestHash` was checked against that
792
+ // member's above, so a Catalog that has drifted from this deployment
793
+ // is refused rather than silently recorded.
794
+ ...(bundle
795
+ ? {
796
+ [authorshipManifestKey(intent.manifestHash)]: {
797
+ schemaVersion: 1,
798
+ manifestHash: intent.manifestHash,
799
+ packageId: intent.packageId,
800
+ version: intent.version,
801
+ manifest: bundle.manifest,
802
+ createdAt: intent.recordedAt,
803
+ } satisfies AuthoredManifestRecordV1,
804
+ }
805
+ : {}),
757
806
  });
758
807
  existingIntent = intent;
759
808
  }
@@ -776,7 +825,9 @@ export function createPackageCatalogHost(
776
825
  version: intent.version,
777
826
  catalogId: intent.catalogId,
778
827
  catalogGeneration: intent.catalogGeneration,
779
- contentHash: intent.contentHash,
828
+ ...(intent.contentHash === undefined
829
+ ? {}
830
+ : { contentHash: intent.contentHash }),
780
831
  };
781
832
  const receipt = await options.user.execute(command);
782
833
  if (receipt.status === "rejected") {
@@ -785,6 +836,30 @@ export function createPackageCatalogHost(
785
836
 
786
837
  const parent =
787
838
  (await options.composition.read(intent.baseGenerationId)) ?? base;
839
+ // A first-party entry contributes no member: it is already required core
840
+ // in the bootstrap generation, and restating it with catalog provenance
841
+ // is exactly what `assertRequiredCoreSet` refuses. The install is the
842
+ // User settings command above, which is all the Plugins page does too,
843
+ // so the Composition is left on the generation it was already running.
844
+ if (firstParty) {
845
+ const outcome: PackageCatalogChangeOutcomeRecordV1 = {
846
+ schemaVersion: 1,
847
+ status: "recorded",
848
+ effectId: request.effectId,
849
+ action,
850
+ packageId: intent.packageId,
851
+ displayName: intent.displayName,
852
+ version: intent.version,
853
+ generationId: parent.generationId,
854
+ missingConnectionTypes: intent.missingConnectionTypes,
855
+ recordedAt: intent.recordedAt,
856
+ };
857
+ await options.storage.put({
858
+ [packageCatalogChangeOutcomeKey(request.effectId)]: outcome,
859
+ });
860
+ return recordedOutcome(outcome);
861
+ }
862
+
788
863
  const members = parent.members.filter(
789
864
  (member) => member.packageId !== intent.packageId,
790
865
  );
@@ -850,7 +925,9 @@ export function createPackageCatalogHost(
850
925
  packageId: intent.packageId,
851
926
  displayName: intent.displayName,
852
927
  ...(action === "remove" ? {} : { version: intent.version }),
853
- ...(action === "remove" ? {} : { contentHash: intent.contentHash }),
928
+ ...(action === "remove" || intent.contentHash === undefined
929
+ ? {}
930
+ : { contentHash: intent.contentHash }),
854
931
  generationId: generation.generationId,
855
932
  missingConnectionTypes: intent.missingConnectionTypes,
856
933
  recordedAt: intent.recordedAt,
package/src/backend.ts CHANGED
@@ -415,6 +415,12 @@ import {
415
415
  type ClientRunV1,
416
416
  type ClientTurnV1,
417
417
  } from "./run-protocol.js";
418
+ import { notificationIdV1 } from "./notification-id.js";
419
+ import {
420
+ type CompositionManifestSourcesV1,
421
+ compositionMemberManifestDocumentV1,
422
+ compositionMemberManifestV1,
423
+ } from "./composition-manifest.js";
418
424
  import {
419
425
  BOT_DEBUG_DEFAULT_RUN_LIMIT_V1,
420
426
  BOT_DEBUG_EVENT_BYTES_V1,
@@ -1233,15 +1239,47 @@ export class ShellBotBackendContribution {
1233
1239
  return { schemaVersion: 1, skills: entries };
1234
1240
  }
1235
1241
 
1236
- /** The one durable manifest lookup used by mounts, commands, and UI views. */
1237
- private async readCompositionMemberManifest(
1242
+ /** The two places this Bot's manifests live; see `composition-manifest.ts`. */
1243
+ private compositionManifestSources(): CompositionManifestSourcesV1 {
1244
+ return {
1245
+ stored: (manifestHash) =>
1246
+ this.ctx.storage.get<AuthoredManifestRecordV1>(
1247
+ authorshipManifestKey(manifestHash),
1248
+ ),
1249
+ application: (member) => this.readApplicationMemberManifest(member),
1250
+ };
1251
+ }
1252
+
1253
+ /**
1254
+ * The manifest a **mount** is handed: the stored document, byte-for-byte
1255
+ * what `manifestHash` was taken over at authoring time.
1256
+ *
1257
+ * Decoding rebuilds the object (`decodeV5` always writes a `configuration`
1258
+ * key, for one), so a decoded manifest does not canonicalize back to the
1259
+ * recorded hash. Every mount re-verifies that hash
1260
+ * (`botIsolatePackageDescriptorV1`), so the raw document is the only thing
1261
+ * that can be handed to it.
1262
+ */
1263
+ private readCompositionMemberManifestDocument(
1264
+ member: CompositionMemberV1,
1265
+ ): Promise<unknown | undefined> {
1266
+ return compositionMemberManifestDocumentV1(
1267
+ member,
1268
+ this.compositionManifestSources(),
1269
+ );
1270
+ }
1271
+
1272
+ /**
1273
+ * The same manifest as the typed shape, for the callers that are not mounts:
1274
+ * commands and UI views, which read fields rather than re-hash the document.
1275
+ */
1276
+ private readCompositionMemberManifest(
1238
1277
  member: CompositionMemberV1,
1239
1278
  ): Promise<FrockBotManifest | undefined> {
1240
- const stored = await this.ctx.storage.get<AuthoredManifestRecordV1>(
1241
- authorshipManifestKey(member.manifestHash),
1279
+ return compositionMemberManifestV1(
1280
+ member,
1281
+ this.compositionManifestSources(),
1242
1282
  );
1243
- if (stored) return decodeFrockBotManifest(stored.manifest);
1244
- return await this.readApplicationMemberManifest(member);
1245
1283
  }
1246
1284
 
1247
1285
  /**
@@ -1270,6 +1308,19 @@ export class ShellBotBackendContribution {
1270
1308
  return declared.manifest;
1271
1309
  }
1272
1310
 
1311
+ /** The stored manifest document a mount hashes, or a modelled failure. */
1312
+ private async requireCompositionMemberManifestDocument(
1313
+ member: CompositionMemberV1,
1314
+ ): Promise<unknown> {
1315
+ const document = await this.readCompositionMemberManifestDocument(member);
1316
+ if (document === undefined) {
1317
+ throw new Error(
1318
+ `package "${member.packageId}" manifest "${member.manifestHash}" is unavailable`,
1319
+ );
1320
+ }
1321
+ return document;
1322
+ }
1323
+
1273
1324
  private async requireCompositionMemberManifest(
1274
1325
  member: CompositionMemberV1,
1275
1326
  ): Promise<FrockBotManifest> {
@@ -1832,7 +1883,11 @@ export class ShellBotBackendContribution {
1832
1883
  fallback: CompositionGenerationV1,
1833
1884
  ): Promise<void> {
1834
1885
  await this.authority.recordNotification({
1835
- notificationId: `composition-failure:${failure.generationId}:${failure.attempt}`,
1886
+ notificationId: notificationIdV1(
1887
+ "composition-failure",
1888
+ failure.generationId,
1889
+ failure.attempt,
1890
+ ),
1836
1891
  runId,
1837
1892
  createdAt: failure.at,
1838
1893
  title: `${settings.profile.name} kept its last working Packages`,
@@ -1883,7 +1938,8 @@ export class ShellBotBackendContribution {
1883
1938
  artifacts,
1884
1939
  this.bundledPackageArtifacts,
1885
1940
  ),
1886
- manifestFor: (member) => this.requireCompositionMemberManifest(member),
1941
+ manifestFor: (member) =>
1942
+ this.requireCompositionMemberManifestDocument(member),
1887
1943
  capabilitiesFor: (member) =>
1888
1944
  mintCapabilities({
1889
1945
  props: {
@@ -2692,7 +2748,12 @@ export class ShellBotBackendContribution {
2692
2748
  );
2693
2749
  if (!connection?.generation) {
2694
2750
  await this.authority.recordNotification({
2695
- notificationId: `package-connection-unavailable:${input.runId}:${input.packageId}:${input.request}`,
2751
+ notificationId: notificationIdV1(
2752
+ "package-connection-unavailable",
2753
+ input.runId,
2754
+ input.packageId,
2755
+ input.request,
2756
+ ),
2696
2757
  runId: input.runId,
2697
2758
  createdAt: new Date().toISOString(),
2698
2759
  title: "Connection unavailable",
@@ -2720,7 +2781,11 @@ export class ShellBotBackendContribution {
2720
2781
  }
2721
2782
  const request = decodeIsolateNotificationRequestV1(input.request);
2722
2783
  await this.authority.recordNotification({
2723
- notificationId: `package:${input.packageId}:${request.notificationId}`,
2784
+ notificationId: notificationIdV1(
2785
+ "package",
2786
+ input.packageId,
2787
+ request.notificationId,
2788
+ ),
2724
2789
  runId: input.runId,
2725
2790
  createdAt: new Date().toISOString(),
2726
2791
  title: request.title,
@@ -3107,7 +3172,7 @@ export class ShellBotBackendContribution {
3107
3172
  await this.authority.recordNotification({
3108
3173
  // The same id shape the completion path uses, so one firing is one
3109
3174
  // intent however many times the alarm retries it.
3110
- notificationId: `routine-failed:${fire.fireId}`,
3175
+ notificationId: notificationIdV1("routine-failed", fire.fireId),
3111
3176
  runId: fire.fireId,
3112
3177
  createdAt: new Date().toISOString(),
3113
3178
  title: `${settings.profile.name} could not run a Routine`,
@@ -3660,7 +3725,7 @@ export class ShellBotBackendContribution {
3660
3725
  }
3661
3726
  if (!settings.notifications.enabled) return;
3662
3727
  await this.authority.recordNotification({
3663
- notificationId: `task-settled:${task.taskId}`,
3728
+ notificationId: notificationIdV1("task-settled", task.taskId),
3664
3729
  runId: task.taskId,
3665
3730
  createdAt: at,
3666
3731
  title: `${settings.profile.name} finished a subagent task`,
@@ -5324,9 +5389,30 @@ export class ShellBotBackendContribution {
5324
5389
  index.map((entry) => entry.cursor),
5325
5390
  await this.sidebarPreview(storedPreview, index),
5326
5391
  failures,
5392
+ await this.isWorking(index[0]?.runId),
5327
5393
  );
5328
5394
  }
5329
5395
 
5396
+ /**
5397
+ * Whether the Bot's newest admitted run is still going.
5398
+ *
5399
+ * The sidebar draws this as an activity ring, so somebody in another
5400
+ * conversation can see a Bot working rather than reading a quiet row as a
5401
+ * stalled one. It is the newest run only: a Bot admits one Turn at a time,
5402
+ * so an older run that is somehow still marked running is a reconciliation
5403
+ * problem and not something a ring should report. A read that fails is no
5404
+ * ring — liveness is never worth failing a sidebar poll for.
5405
+ */
5406
+ private async isWorking(runId: string | undefined): Promise<boolean> {
5407
+ if (runId === undefined) return false;
5408
+ try {
5409
+ const run = await this.authority.readRun(runId);
5410
+ return run?.status === "running";
5411
+ } catch {
5412
+ return false;
5413
+ }
5414
+ }
5415
+
5330
5416
  /**
5331
5417
  * How many stored runs a read will open to recover a missing preview. The
5332
5418
  * newest settled chat Turn is almost always the first entry; the bound is
@@ -5464,7 +5550,7 @@ export class ShellBotBackendContribution {
5464
5550
  // `automation_completion_inbox` row".
5465
5551
  if (handoff === undefined) return undefined;
5466
5552
  return {
5467
- notificationId: `routine-wake:${result.runId}`,
5553
+ notificationId: notificationIdV1("routine-wake", result.runId),
5468
5554
  runId: result.runId,
5469
5555
  createdAt: new Date().toISOString(),
5470
5556
  title: `${settings.profile.name} finished a Routine`,