@frockbot/plugin-shell 0.3.7 → 0.3.9

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.7",
3
+ "version": "0.3.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,41 +47,41 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.7",
51
- "@frockbot/application-foundation": "0.3.7",
52
- "@frockbot/catalog-core": "0.3.7",
53
- "@frockbot/client-core": "0.3.7",
54
- "@frockbot/client-ui": "0.3.7",
55
- "@frockbot/computer-core": "0.3.7",
56
- "@frockbot/computer-host-protocol": "0.3.7",
57
- "@frockbot/configuration-core": "0.3.7",
58
- "@frockbot/connection-core": "0.3.7",
59
- "@frockbot/kernel-agent-loop": "0.3.7",
60
- "@frockbot/kernel-composition": "0.3.7",
61
- "@frockbot/kernel-contracts": "0.3.7",
62
- "@frockbot/kernel-do": "0.3.7",
63
- "@frockbot/machine-protocol": "0.3.7",
64
- "@frockbot/plugin-applets": "0.3.7",
65
- "@frockbot/plugin-authoring": "0.3.7",
66
- "@frockbot/plugin-bot-template": "0.3.7",
67
- "@frockbot/plugin-computer": "0.3.7",
68
- "@frockbot/plugin-flock": "0.3.7",
69
- "@frockbot/plugin-image": "0.3.7",
70
- "@frockbot/plugin-machine-messages": "0.3.7",
71
- "@frockbot/plugin-mcp": "0.3.7",
72
- "@frockbot/plugin-memory": "0.3.7",
73
- "@frockbot/plugin-package-catalog": "0.3.7",
74
- "@frockbot/plugin-routines": "0.3.7",
75
- "@frockbot/plugin-skills": "0.3.7",
76
- "@frockbot/plugin-subagents": "0.3.7",
77
- "@frockbot/plugin-user-machine": "0.3.7",
78
- "@frockbot/protocol": "0.3.7",
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",
79
79
  "cordis": "4.0.0-rc.8",
80
80
  "vue": "3.5.41"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.7",
84
+ "@frockbot/plugin-tools": "0.3.9",
85
85
  "@types/bun": "1.3.6",
86
86
  "@types/node": "26.2.0",
87
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -301,6 +301,10 @@ export function routineInboxEntryViewV1(
301
301
  ...(entry.acknowledgedAt === undefined
302
302
  ? {}
303
303
  : { acknowledgedAt: entry.acknowledgedAt }),
304
+ ...(entry.repeatCount === undefined
305
+ ? {}
306
+ : { repeatCount: entry.repeatCount }),
307
+ ...(entry.failure === undefined ? {} : { failure: entry.failure }),
304
308
  };
305
309
  }
306
310
 
@@ -0,0 +1,222 @@
1
+ // The foreground `Task` wait (memory-v2 F4).
2
+ //
3
+ // A `Task {background:false}` that answered "dispatched" the instant anything
4
+ // went wrong read to the model as a call that had finished with no output. It
5
+ // then spent two more steps on `task_check` and `task_resume`. The wait must
6
+ // hold until the child settles, and give up only when the record is really
7
+ // unreadable.
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { UserSettingsViewV1 } from "@frockbot/configuration-core";
10
+ import type {
11
+ TaskOutcomeV1,
12
+ TaskRecordV1,
13
+ } from "@frockbot/plugin-subagents/records";
14
+ import { taskKeyV1 } from "@frockbot/plugin-subagents/storage-keys";
15
+ import type { ShellBotBackendHost } from "./backend.js";
16
+
17
+ const identity = { userId: "user-1", botId: "primary" };
18
+ const TASK_ID = "task-1";
19
+
20
+ class MemoryStorage {
21
+ readonly values = new Map<string, unknown>();
22
+ /** Reads to fail before the first success, to stand in for contention. */
23
+ failReads = 0;
24
+ reads = 0;
25
+
26
+ get<T>(key: string): Promise<T | undefined> {
27
+ this.reads += 1;
28
+ if (this.failReads > 0) {
29
+ this.failReads -= 1;
30
+ return Promise.reject(new Error("storage is busy"));
31
+ }
32
+ return Promise.resolve(
33
+ structuredClone(this.values.get(key)) as T | undefined,
34
+ );
35
+ }
36
+
37
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
38
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
39
+ else {
40
+ for (const [entry, item] of Object.entries(key)) {
41
+ this.values.set(entry, structuredClone(item));
42
+ }
43
+ }
44
+ return Promise.resolve();
45
+ }
46
+
47
+ delete(key: string): Promise<boolean> {
48
+ return Promise.resolve(this.values.delete(key));
49
+ }
50
+
51
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
52
+ return Promise.resolve(
53
+ new Map(
54
+ [...this.values.entries()].filter(([key]) =>
55
+ key.startsWith(options.prefix ?? ""),
56
+ ) as Array<[string, T]>,
57
+ ),
58
+ );
59
+ }
60
+
61
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
62
+ return callback(this);
63
+ }
64
+
65
+ setAlarm(): Promise<void> {
66
+ return Promise.resolve();
67
+ }
68
+
69
+ deleteAlarm(): Promise<void> {
70
+ return Promise.resolve();
71
+ }
72
+ }
73
+
74
+ const user: UserSettingsViewV1 = {
75
+ schemaVersion: 1,
76
+ revision: 0,
77
+ profile: { name: "User" },
78
+ packages: [],
79
+ connections: [],
80
+ };
81
+
82
+ function host(storage: MemoryStorage): ShellBotBackendHost {
83
+ return {
84
+ state: { storage } as unknown as DurableObjectState,
85
+ env: {
86
+ USER_CONFIGURATIONS: {
87
+ idFromName: () => "user-id",
88
+ get: () => ({ readConfiguration: () => Promise.resolve(user) }),
89
+ },
90
+ } as unknown as ShellBotBackendHost["env"],
91
+ };
92
+ }
93
+
94
+ interface Waiting {
95
+ sleeps: number;
96
+ materializeSettings(
97
+ identity: { userId: string; botId: string },
98
+ profile: { name: string },
99
+ ): Promise<unknown>;
100
+ wait(taskId: string): Promise<TaskOutcomeV1 | undefined>;
101
+ }
102
+
103
+ /**
104
+ * Exposes the wait and removes its sleep, which is the whole point of it.
105
+ *
106
+ * The class is reached through a dynamic import rather than a top-level one:
107
+ * the Shell's backend and the foundation application import each other, and a
108
+ * static import from a test module reads the class while it is still
109
+ * initializing.
110
+ */
111
+ async function waiting(host: ShellBotBackendHost): Promise<Waiting> {
112
+ // The application first: it is the half of the cycle that has to finish
113
+ // initializing before the Shell's class is readable.
114
+ await import("@frockbot/application-foundation/contributions");
115
+ const { ShellBotBackendContribution } = await import("./backend.js");
116
+ return new (class extends ShellBotBackendContribution {
117
+ sleeps = 0;
118
+
119
+ protected override sleep(): Promise<void> {
120
+ this.sleeps += 1;
121
+ return Promise.resolve();
122
+ }
123
+
124
+ wait(taskId: string): Promise<TaskOutcomeV1 | undefined> {
125
+ return this.awaitBlockingTask(identity, taskId, taskId);
126
+ }
127
+ })(host) as unknown as Waiting;
128
+ }
129
+
130
+ function record(overrides: Partial<TaskRecordV1> = {}): TaskRecordV1 {
131
+ return {
132
+ schemaVersion: 1,
133
+ taskId: TASK_ID,
134
+ type: "executor",
135
+ description: "read it",
136
+ promptDigest: "sha256:digest",
137
+ model: {
138
+ binding: {
139
+ packageId: "models",
140
+ capabilityId: "llm",
141
+ connectionId: "connection-1",
142
+ provider: "foundation",
143
+ providerModelId: "foundation-model",
144
+ },
145
+ slug: "foundation-model",
146
+ },
147
+ compositionGenerationId: "generation-1",
148
+ background: false,
149
+ depth: 1,
150
+ status: "running",
151
+ dispatch: {
152
+ runId: "run-1",
153
+ turnId: "turn-1",
154
+ sessionId: "user-1:primary",
155
+ },
156
+ childSessionId: `task:${TASK_ID}`,
157
+ attachments: [],
158
+ createdAt: "2026-09-03T00:00:00.000Z",
159
+ deadlineAt: "2026-09-03T00:30:00.000Z",
160
+ ...overrides,
161
+ };
162
+ }
163
+
164
+ async function fixture(
165
+ stored: TaskRecordV1,
166
+ ): Promise<{ storage: MemoryStorage; contribution: Waiting }> {
167
+ const storage = new MemoryStorage();
168
+ const contribution = await waiting(host(storage));
169
+ await contribution.materializeSettings(identity, { name: "Primary" });
170
+ storage.values.set(taskKeyV1(stored.taskId), structuredClone(stored));
171
+ return { storage, contribution };
172
+ }
173
+
174
+ describe("the foreground Task wait", () => {
175
+ test("answers with the child's settled outcome", async () => {
176
+ const { contribution } = await fixture(
177
+ record({
178
+ status: "completed",
179
+ outcome: {
180
+ status: "completed",
181
+ settledAt: "2026-09-03T00:00:10.000Z",
182
+ summary: "The root has six entries.",
183
+ },
184
+ }),
185
+ );
186
+
187
+ expect(await contribution.wait(TASK_ID)).toMatchObject({
188
+ status: "completed",
189
+ summary: "The root has six entries.",
190
+ });
191
+ });
192
+
193
+ // The regression: one failed read used to end the wait, and the Task tool
194
+ // then told the model the child was still running — for a child that had
195
+ // already settled.
196
+ test("rides out a transient read failure instead of giving up", async () => {
197
+ const { storage, contribution } = await fixture(
198
+ record({
199
+ status: "completed",
200
+ outcome: {
201
+ status: "completed",
202
+ settledAt: "2026-09-03T00:00:10.000Z",
203
+ summary: "The root has six entries.",
204
+ },
205
+ }),
206
+ );
207
+ storage.failReads = 2;
208
+
209
+ expect(await contribution.wait(TASK_ID)).toMatchObject({
210
+ status: "completed",
211
+ summary: "The root has six entries.",
212
+ });
213
+ expect(contribution.sleeps).toBeGreaterThan(0);
214
+ });
215
+
216
+ test("gives up when the record stays unreadable", async () => {
217
+ const { storage, contribution } = await fixture(record());
218
+ storage.failReads = Number.MAX_SAFE_INTEGER;
219
+
220
+ expect(await contribution.wait(TASK_ID)).toBeUndefined();
221
+ });
222
+ });
package/src/backend.ts CHANGED
@@ -214,6 +214,12 @@ import {
214
214
  } from "./backend-routines.js";
215
215
  import { RoutineInboxStore } from "@frockbot/plugin-routines/inbox-store";
216
216
  import {
217
+ ROUTINE_INBOX_LIMIT,
218
+ ROUTINE_INBOX_PREFIX,
219
+ } from "@frockbot/plugin-routines/storage-keys";
220
+ import {
221
+ decodeRoutineInboxEntryV1,
222
+ routineFailureSentenceV1,
217
223
  subagentAttributionV1,
218
224
  ROUTINE_INBOX_TEXT_MAX,
219
225
  ROUTINE_WAKE_TITLE_MAX,
@@ -437,6 +443,7 @@ import {
437
443
  optionalSidebarMessagePreviewV1,
438
444
  optionalUnreadStateV1,
439
445
  projectBotUnreadViewV1,
446
+ sidebarMessagePreviewFromRunsV1,
440
447
  SIDEBAR_PREVIEW_KEY,
441
448
  unreadReceiptKeyV1,
442
449
  UNREAD_COUNT_CAP,
@@ -444,6 +451,8 @@ import {
444
451
  type BotUnreadCommandV1,
445
452
  type BotUnreadReceiptV1,
446
453
  type BotUnreadViewV1,
454
+ type SidebarMessagePreviewV1,
455
+ type SidebarPreviewRunV1,
447
456
  } from "./unread.js";
448
457
  import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
449
458
 
@@ -3102,10 +3111,10 @@ export class ShellBotBackendContribution {
3102
3111
  runId: fire.fireId,
3103
3112
  createdAt: new Date().toISOString(),
3104
3113
  title: `${settings.profile.name} could not run a Routine`,
3105
- body: (outcome.summary ?? "The firing ended without saying why.").slice(
3106
- 0,
3107
- 240,
3108
- ),
3114
+ // The same sentence the inbox entry carries. A notification is the one
3115
+ // surface a person reads without asking for it, so it is the last place
3116
+ // a kernel invariant belongs.
3117
+ body: routineFailureSentenceV1(outcome.summary).slice(0, 240),
3109
3118
  });
3110
3119
  }
3111
3120
  // -------------------------------------------------------------------------
@@ -3345,11 +3354,38 @@ export class ShellBotBackendContribution {
3345
3354
  }
3346
3355
  if (admission.status === "replayed") {
3347
3356
  // The same tool call, reconciled or retried: the task it already
3348
- // dispatched is the answer, never a second child.
3357
+ // dispatched is the answer, never a second child. A foreground call
3358
+ // still waits for it — the caller asked for the result, and returning
3359
+ // "dispatched" the instant a replay is recognised is what made a
3360
+ // `background:false` Task look like it completed with no output.
3361
+ const replayed = admission.record;
3362
+ const settled =
3363
+ replayed.outcome ??
3364
+ (request.background
3365
+ ? undefined
3366
+ : await this.awaitBlockingTask(
3367
+ identity,
3368
+ taskAnchorIdV1(replayed.childSessionId),
3369
+ replayed.taskId,
3370
+ ));
3371
+ if (settled) {
3372
+ return {
3373
+ status: "settled",
3374
+ taskId: replayed.taskId,
3375
+ model: replayed.model.slug,
3376
+ taskStatus: settled.status,
3377
+ ...(settled.summary === undefined
3378
+ ? {}
3379
+ : { summary: settled.summary }),
3380
+ ...(settled.failure === undefined
3381
+ ? {}
3382
+ : { failure: settled.failure }),
3383
+ };
3384
+ }
3349
3385
  return {
3350
3386
  status: "dispatched",
3351
- taskId: admission.record.taskId,
3352
- model: admission.record.model.slug,
3387
+ taskId: replayed.taskId,
3388
+ model: replayed.model.slug,
3353
3389
  };
3354
3390
  }
3355
3391
  const reservation = await this.subagentSlots(identity).reserve({
@@ -3451,20 +3487,27 @@ export class ShellBotBackendContribution {
3451
3487
  * Durable Object already does inside a Turn, and the outbound probe is the
3452
3488
  * same call reconciliation makes.
3453
3489
  */
3454
- private async awaitBlockingTask(
3490
+ protected async awaitBlockingTask(
3455
3491
  identity: BotIdentity,
3456
3492
  anchorTaskId: string,
3457
3493
  taskId: string,
3458
3494
  ): Promise<TaskOutcomeV1 | undefined> {
3459
3495
  const binding = this.subagentBinding;
3460
3496
  const deadline = Date.now() + TASK_BLOCKING_TIMEOUT_MS_V1;
3497
+ // A read that fails once is transient storage contention, not an answer:
3498
+ // abandoning the wait on the first one returned "still running" for a
3499
+ // child that was about to settle, and taught the model to poll. Only a
3500
+ // record that stays unreadable ends the wait early.
3501
+ const readFailureLimit = 3;
3502
+ let readFailures = 0;
3461
3503
  for (;;) {
3462
3504
  try {
3463
3505
  const record = await this.tasks.read(taskId);
3506
+ readFailures = 0;
3464
3507
  if (record.outcome) return record.outcome;
3465
3508
  } catch {
3466
- // A record that cannot be read is not a reason to hold the Turn.
3467
- return undefined;
3509
+ readFailures += 1;
3510
+ if (readFailures >= readFailureLimit) return undefined;
3468
3511
  }
3469
3512
  if (binding) {
3470
3513
  try {
@@ -5254,18 +5297,67 @@ export class ShellBotBackendContribution {
5254
5297
  ]),
5255
5298
  );
5256
5299
  const state = optionalUnreadStateV1(storedState);
5257
- const preview = optionalSidebarMessagePreviewV1(storedPreview);
5258
5300
  const index = await this.authority.listRunIndex({
5259
5301
  limit: UNREAD_COUNT_CAP + 1,
5260
5302
  });
5303
+ // Counted straight off the keys rather than through `RoutineInboxStore`:
5304
+ // its `list()` trims the inbox, and the unread fan-out is a read every
5305
+ // sidebar poll makes for every Bot — it must not write, least of all into
5306
+ // an object that is running a Turn. An undecodable row is skipped, because
5307
+ // a badge is never worth failing a read for.
5308
+ const stored = await this.ctx.storage.list<unknown>({
5309
+ prefix: ROUTINE_INBOX_PREFIX,
5310
+ limit: ROUTINE_INBOX_LIMIT,
5311
+ });
5312
+ let failures = 0;
5313
+ for (const value of stored.values()) {
5314
+ try {
5315
+ const entry = decodeRoutineInboxEntryV1(value);
5316
+ if (entry.failure === true && !entry.acknowledged) failures += 1;
5317
+ } catch {
5318
+ continue;
5319
+ }
5320
+ }
5261
5321
  return projectBotUnreadViewV1(
5262
5322
  identity.botId,
5263
5323
  state,
5264
5324
  index.map((entry) => entry.cursor),
5265
- preview,
5325
+ await this.sidebarPreview(storedPreview, index),
5326
+ failures,
5266
5327
  );
5267
5328
  }
5268
5329
 
5330
+ /**
5331
+ * How many stored runs a read will open to recover a missing preview. The
5332
+ * newest settled chat Turn is almost always the first entry; the bound is
5333
+ * what keeps a Bot whose recent Turns are all automations from turning one
5334
+ * sidebar read into a scan of its whole history.
5335
+ */
5336
+ private static readonly SIDEBAR_PREVIEW_BACKFILL_RUNS_V1 = 5;
5337
+
5338
+ /**
5339
+ * The preview record, or the same line derived from the runs when there is
5340
+ * none. A Bot whose Turns settled before the preview projection existed has
5341
+ * a full transcript and no record, and the row read "No messages yet" over
5342
+ * it. A read never writes what it derives: the next settlement stores it.
5343
+ */
5344
+ private async sidebarPreview(
5345
+ storedPreview: unknown,
5346
+ index: readonly { runId: string }[],
5347
+ ): Promise<SidebarMessagePreviewV1 | undefined> {
5348
+ const stored = optionalSidebarMessagePreviewV1(storedPreview);
5349
+ if (stored) return stored;
5350
+ const runs: SidebarPreviewRunV1[] = [];
5351
+ for (const entry of index.slice(
5352
+ 0,
5353
+ ShellBotBackendContribution.SIDEBAR_PREVIEW_BACKFILL_RUNS_V1,
5354
+ )) {
5355
+ const run = await this.authority.readRun(entry.runId);
5356
+ if (run) runs.push(run);
5357
+ }
5358
+ return sidebarMessagePreviewFromRunsV1(runs);
5359
+ }
5360
+
5269
5361
  /**
5270
5362
  * `bot/mark-read` and `bot/mark-unread`. Idempotent on the command id and
5271
5363
  * monotonic in the cursor, so a replay or an out-of-order delivery can only
@@ -5294,9 +5386,7 @@ export class ShellBotBackendContribution {
5294
5386
  }
5295
5387
  return {
5296
5388
  state: optionalUnreadStateV1(existing.state),
5297
- preview: optionalSidebarMessagePreviewV1(
5298
- await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5299
- ),
5389
+ preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5300
5390
  };
5301
5391
  }
5302
5392
  const current = optionalUnreadStateV1(
@@ -5320,9 +5410,7 @@ export class ShellBotBackendContribution {
5320
5410
  });
5321
5411
  return {
5322
5412
  state: next,
5323
- preview: optionalSidebarMessagePreviewV1(
5324
- await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5325
- ),
5413
+ preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5326
5414
  };
5327
5415
  });
5328
5416
  const index = await this.authority.listRunIndex({
@@ -5336,7 +5424,10 @@ export class ShellBotBackendContribution {
5336
5424
  identity.botId,
5337
5425
  stored.state,
5338
5426
  index.map((entry) => entry.cursor),
5339
- stored.preview,
5427
+ // The open Bot is the one that gets marked read, so this receipt is
5428
+ // the sidebar row it renders from: it owes the same derived preview
5429
+ // the fan-out gives every other Bot.
5430
+ await this.sidebarPreview(stored.preview, index),
5340
5431
  ),
5341
5432
  };
5342
5433
  }
@@ -26,6 +26,12 @@ import {
26
26
  type WebToolActivity,
27
27
  } from "../shared.js";
28
28
  import { ComposerDraftStore } from "./composer-draft.js";
29
+ import {
30
+ TURN_TEXT_MAX_CHARACTERS_V1,
31
+ turnTextCounterVisibleV1,
32
+ turnTextRemainingV1,
33
+ turnTextTooLongV1,
34
+ } from "./turn-limits.js";
29
35
  import SendPayloadView from "./SendPayloadView.vue";
30
36
  import AppletCanvas from "./AppletCanvas.vue";
31
37
  import PackageIframeHost from "./PackageIframeHost.vue";
@@ -305,12 +311,31 @@ const composerPlaceholder = computed(() => {
305
311
  if (!state.value.modelReady) return state.value.modelLabel;
306
312
  return botName.value ? `Message ${botName.value}` : "Message";
307
313
  });
314
+ /*
315
+ * The send route's size rule, enforced where the person can still do something
316
+ * about it. Past the limit the server answers 413 and nothing is sent, so the
317
+ * composer says how much is left before the button closes rather than letting
318
+ * a long message make the round trip to be refused.
319
+ */
320
+ const draftText = computed(() => draft.value.trim());
321
+ const draftTooLong = computed(() => turnTextTooLongV1(draftText.value));
322
+ const draftCounterVisible = computed(() =>
323
+ turnTextCounterVisibleV1(draftText.value),
324
+ );
325
+ const draftCounterLabel = computed(() => {
326
+ const remaining = turnTextRemainingV1(draftText.value);
327
+ const count = Math.abs(remaining).toLocaleString("en-US");
328
+ return remaining < 0
329
+ ? `${count} characters over the ${TURN_TEXT_MAX_CHARACTERS_V1.toLocaleString("en-US")} limit`
330
+ : `${count} characters left`;
331
+ });
308
332
  const canSend = computed(
309
333
  () =>
310
334
  state.value.connection === "ready" &&
311
335
  state.value.modelReady &&
312
336
  Boolean(state.value.activeBotId) &&
313
- draft.value.trim().length > 0,
337
+ draftText.value.length > 0 &&
338
+ !draftTooLong.value,
314
339
  );
315
340
  /**
316
341
  * Stop takes the button only while there is nothing to send. The moment the
@@ -937,6 +962,20 @@ function handleComposerKeydown(event: KeyboardEvent): void {
937
962
  <p v-if="message.notice" class="message-notice">
938
963
  {{ message.notice }}
939
964
  </p>
965
+ <!--
966
+ The way out of an ending the person cannot otherwise act on:
967
+ the client could not reach the Bot, their text is back in the
968
+ composer, and this sends it again.
969
+ -->
970
+ <button
971
+ v-if="message.retry === 'resend'"
972
+ type="button"
973
+ class="message-retry"
974
+ :disabled="!canSend"
975
+ @click="sendMessage()"
976
+ >
977
+ Retry
978
+ </button>
940
979
  <template v-for="tool in message.tools" :key="tool.id">
941
980
  <PackageIframeHost
942
981
  v-for="entry in iframeEntriesFor(tool)"
@@ -1188,6 +1227,19 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1188
1227
  @click="refreshSkillPopover"
1189
1228
  @blur="closeSkillPopover"
1190
1229
  />
1230
+ <!--
1231
+ Silent until the budget is nearly spent, then it says how much is
1232
+ left — and, past the limit, how much has to go before the send
1233
+ button opens again.
1234
+ -->
1235
+ <p
1236
+ v-if="draftCounterVisible"
1237
+ class="composer-counter"
1238
+ :class="{ 'composer-counter-over': draftTooLong }"
1239
+ aria-live="polite"
1240
+ >
1241
+ {{ draftCounterLabel }}
1242
+ </p>
1191
1243
  </div>
1192
1244
  <!--
1193
1245
  Start a new conversation. Sits beside the composer because that is