@frockbot/plugin-shell 0.0.0 → 0.1.1

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 (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,459 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ // The Shell's half of subagent dispatch: the seams between the parent Bot
3
+ // Durable Object, which is the authority, and the Subagent Durable Object,
4
+ // which is only an execution host (ADR 0017).
5
+ //
6
+ // Nothing here decides policy. The bounds and the records are the Subagents
7
+ // Package's; the Composition, the model binding and the admitted Turn are the
8
+ // Shell's; the Durable Object addressing is a binding this module is handed.
9
+ // What this module owns is the *order*: intent before effect, one settle per
10
+ // task, and a deadline that belongs to the parent's one alarm.
11
+
12
+ import {
13
+ decodeTaskOutcomeV1,
14
+ isTaskIdV1,
15
+ subagentExactKeys,
16
+ subagentText,
17
+ subagentTimestamp,
18
+ SubagentDecodeError,
19
+ TASK_MESSAGE_MAX_V1,
20
+ TASK_PROMPT_MAX_BYTES_V1,
21
+ TASK_TYPES_V1,
22
+ decodeTaskModelV1,
23
+ utf8ByteLengthV1,
24
+ type TaskModelV1,
25
+ type TaskOutcomeV1,
26
+ type TaskTypeV1,
27
+ } from "@frockbot/plugin-subagents/records";
28
+ import {
29
+ subagentDurableObjectNameV1,
30
+ taskSessionIdV1,
31
+ } from "@frockbot/plugin-subagents/storage-keys";
32
+ import type { BotIdentity } from "@frockbot/kernel-do";
33
+
34
+ /** The parent Turn that dispatched a task, as the child records it. */
35
+ export interface SubagentParentV1 {
36
+ userId: string;
37
+ botId: string;
38
+ runId: string;
39
+ turnId: string;
40
+ sessionId: string;
41
+ }
42
+
43
+ /**
44
+ * What the parent hands the child, and what the child then holds.
45
+ *
46
+ * It is the child's own durable state and the only thing in the child that
47
+ * outlives its Turn. Child-local by design (plan decision 2): journaling every
48
+ * child event back into the parent would double the write cost and make the
49
+ * parent a bottleneck for no added guarantee, and the parent already holds the
50
+ * record of admission and terminal state that recovery reads.
51
+ */
52
+ export interface SubagentTaskContextV1 {
53
+ schemaVersion: 1;
54
+ taskId: string;
55
+ type: TaskTypeV1;
56
+ parent: SubagentParentV1;
57
+ compositionGenerationId: string;
58
+ model: TaskModelV1;
59
+ prompt: string;
60
+ sessionId: string;
61
+ status: "queued" | "running" | "settled";
62
+ acceptedAt: string;
63
+ outcome?: TaskOutcomeV1;
64
+ }
65
+
66
+ function record(value: unknown, label: string): Record<string, unknown> {
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
68
+ throw new SubagentDecodeError(`${label} must be an object`);
69
+ }
70
+ return value as Record<string, unknown>;
71
+ }
72
+
73
+ export function decodeSubagentParentV1(
74
+ value: unknown,
75
+ label = "subagent parent",
76
+ ): SubagentParentV1 {
77
+ const candidate = record(value, label);
78
+ subagentExactKeys(
79
+ candidate,
80
+ ["userId", "botId", "runId", "turnId", "sessionId"],
81
+ [],
82
+ label,
83
+ );
84
+ return {
85
+ userId: subagentText(candidate.userId, 128, `${label}.userId`),
86
+ botId: subagentText(candidate.botId, 128, `${label}.botId`),
87
+ runId: subagentText(candidate.runId, 128, `${label}.runId`),
88
+ turnId: subagentText(candidate.turnId, 256, `${label}.turnId`),
89
+ sessionId: subagentText(candidate.sessionId, 256, `${label}.sessionId`),
90
+ };
91
+ }
92
+
93
+ /** The exact `runTask` payload, decoded at the Subagent object's door. */
94
+ export interface SubagentRunTaskRequestV1 {
95
+ taskId: string;
96
+ type: TaskTypeV1;
97
+ parent: SubagentParentV1;
98
+ compositionGenerationId: string;
99
+ model: TaskModelV1;
100
+ prompt: string;
101
+ /**
102
+ * The Session the child Turn runs on. Absent on a first dispatch, where it
103
+ * is the task's own; present on a resume, where it is the *resumed* task's,
104
+ * because the whole point of resuming is that the child picks its own
105
+ * transcript up from its own cursor rather than starting blank again.
106
+ */
107
+ sessionId?: string;
108
+ }
109
+
110
+ export function decodeSubagentRunTaskRequestV1(
111
+ value: unknown,
112
+ ): SubagentRunTaskRequestV1 {
113
+ const label = "runTask request";
114
+ const candidate = record(value, label);
115
+ subagentExactKeys(
116
+ candidate,
117
+ ["taskId", "type", "parent", "compositionGenerationId", "model", "prompt"],
118
+ ["sessionId"],
119
+ label,
120
+ );
121
+ if (!isTaskIdV1(candidate.taskId)) {
122
+ throw new SubagentDecodeError(`${label}.taskId is invalid`);
123
+ }
124
+ const type = TASK_TYPES_V1.find((known) => known === candidate.type);
125
+ if (!type) throw new SubagentDecodeError(`${label}.type is invalid`);
126
+ const prompt = subagentText(
127
+ candidate.prompt,
128
+ TASK_PROMPT_MAX_BYTES_V1,
129
+ `${label}.prompt`,
130
+ );
131
+ if (utf8ByteLengthV1(prompt) > TASK_PROMPT_MAX_BYTES_V1) {
132
+ throw new SubagentDecodeError(`${label}.prompt is too large`);
133
+ }
134
+ return {
135
+ taskId: candidate.taskId,
136
+ type,
137
+ parent: decodeSubagentParentV1(candidate.parent, `${label}.parent`),
138
+ compositionGenerationId: subagentText(
139
+ candidate.compositionGenerationId,
140
+ 256,
141
+ `${label}.compositionGenerationId`,
142
+ ),
143
+ model: decodeTaskModelV1(candidate.model, `${label}.model`),
144
+ prompt,
145
+ ...(candidate.sessionId === undefined
146
+ ? {}
147
+ : {
148
+ sessionId: subagentText(
149
+ candidate.sessionId,
150
+ 256,
151
+ `${label}.sessionId`,
152
+ ),
153
+ }),
154
+ };
155
+ }
156
+
157
+ export function subagentTaskContextV1(
158
+ request: SubagentRunTaskRequestV1,
159
+ acceptedAt: string,
160
+ ): SubagentTaskContextV1 {
161
+ return {
162
+ schemaVersion: 1,
163
+ taskId: request.taskId,
164
+ type: request.type,
165
+ parent: request.parent,
166
+ compositionGenerationId: request.compositionGenerationId,
167
+ model: request.model,
168
+ prompt: request.prompt,
169
+ sessionId: request.sessionId ?? taskSessionIdV1(request.taskId),
170
+ status: "queued",
171
+ acceptedAt,
172
+ };
173
+ }
174
+
175
+ export function decodeSubagentTaskContextV1(
176
+ value: unknown,
177
+ ): SubagentTaskContextV1 {
178
+ const label = "subagent task context";
179
+ const candidate = record(value, label);
180
+ subagentExactKeys(
181
+ candidate,
182
+ [
183
+ "schemaVersion",
184
+ "taskId",
185
+ "type",
186
+ "parent",
187
+ "compositionGenerationId",
188
+ "model",
189
+ "prompt",
190
+ "sessionId",
191
+ "status",
192
+ "acceptedAt",
193
+ ],
194
+ ["outcome"],
195
+ label,
196
+ );
197
+ if (candidate.schemaVersion !== 1) {
198
+ throw new SubagentDecodeError(`${label} schemaVersion is unsupported`);
199
+ }
200
+ if (!isTaskIdV1(candidate.taskId)) {
201
+ throw new SubagentDecodeError(`${label}.taskId is invalid`);
202
+ }
203
+ const type = TASK_TYPES_V1.find((known) => known === candidate.type);
204
+ if (!type) throw new SubagentDecodeError(`${label}.type is invalid`);
205
+ if (
206
+ candidate.status !== "queued" &&
207
+ candidate.status !== "running" &&
208
+ candidate.status !== "settled"
209
+ ) {
210
+ throw new SubagentDecodeError(`${label}.status is invalid`);
211
+ }
212
+ return {
213
+ schemaVersion: 1,
214
+ taskId: candidate.taskId,
215
+ type,
216
+ parent: decodeSubagentParentV1(candidate.parent, `${label}.parent`),
217
+ compositionGenerationId: subagentText(
218
+ candidate.compositionGenerationId,
219
+ 256,
220
+ `${label}.compositionGenerationId`,
221
+ ),
222
+ model: decodeTaskModelV1(candidate.model, `${label}.model`),
223
+ prompt: subagentText(
224
+ candidate.prompt,
225
+ TASK_PROMPT_MAX_BYTES_V1,
226
+ `${label}.prompt`,
227
+ ),
228
+ sessionId: subagentText(candidate.sessionId, 256, `${label}.sessionId`),
229
+ status: candidate.status,
230
+ acceptedAt: subagentTimestamp(candidate.acceptedAt, `${label}.acceptedAt`),
231
+ ...(candidate.outcome === undefined
232
+ ? {}
233
+ : {
234
+ outcome: decodeTaskOutcomeV1(candidate.outcome, `${label}.outcome`),
235
+ }),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * The Durable Object addressing this Package needs and deliberately does not
241
+ * hold: the Subagent object for one task, and the parent object of a child.
242
+ * The Bot Durable Object supplies it, exactly as it supplies the authority.
243
+ */
244
+ export interface SubagentDurableBindingV1 {
245
+ /**
246
+ * Hands one task to its Subagent Durable Object. The call returns as soon as
247
+ * the child has *recorded* the task and armed its own alarm — never after the
248
+ * Turn has run, so a dispatch never blocks the Turn that made it and no
249
+ * promise is left floating in an object that may be evicted.
250
+ */
251
+ accept(
252
+ identity: BotIdentity,
253
+ anchorTaskId: string,
254
+ request: SubagentRunTaskRequestV1,
255
+ ): Promise<{ childSessionId: string }>;
256
+ /** Asks a child what became of a task, for deadline reconciliation. */
257
+ probe(
258
+ identity: BotIdentity,
259
+ anchorTaskId: string,
260
+ taskId: string,
261
+ ): Promise<SubagentTaskContextV1 | undefined>;
262
+ /**
263
+ * Explicit, authenticated cancellation, carried to the execution host.
264
+ *
265
+ * The parent has already recorded the intent when this is called, so a child
266
+ * that cannot be reached does not keep the task alive: the parent settles it
267
+ * `stopped` either way, and the child reads its own cancelled context back.
268
+ */
269
+ stop(
270
+ identity: BotIdentity,
271
+ anchorTaskId: string,
272
+ taskId: string,
273
+ ): Promise<void>;
274
+ /** Records one terminal outcome on the parent, from the child. */
275
+ settleOnParent(
276
+ parent: SubagentParentV1,
277
+ taskId: string,
278
+ outcome: TaskOutcomeV1,
279
+ ): Promise<void>;
280
+ /**
281
+ * Claims the messages the parent has queued for one task, on the child's
282
+ * behalf, and marks them delivered in the parent's own transaction.
283
+ *
284
+ * The queue lives in the parent because the parent is the authority; the
285
+ * reader is the child, because the child is the one running. This is the one
286
+ * call that crosses in that direction *during* a Turn, and it is bounded by
287
+ * the queue's own bound (16).
288
+ */
289
+ claimMessagesOnParent(
290
+ parent: SubagentParentV1,
291
+ taskId: string,
292
+ ): Promise<readonly { seq: number; message: string }[]>;
293
+ }
294
+
295
+ /**
296
+ * The exact shape a claim answers with, decoded at the child's door: the
297
+ * parent is trusted, the wire is not, and a message reaches a model.
298
+ */
299
+ export function decodeClaimedTaskMessagesV1(
300
+ value: unknown,
301
+ ): { seq: number; message: string }[] {
302
+ const answer = value as { messages?: unknown } | null | undefined;
303
+ if (!answer || !Array.isArray(answer.messages)) return [];
304
+ return answer.messages.slice(0, CLAIMED_TASK_MESSAGE_LIMIT).map((entry) => {
305
+ const candidate = record(entry, "claimed task message");
306
+ if (!Number.isSafeInteger(candidate.seq) || (candidate.seq as number) < 0) {
307
+ throw new SubagentDecodeError("claimed task message seq is invalid");
308
+ }
309
+ return {
310
+ seq: candidate.seq as number,
311
+ message: subagentText(
312
+ candidate.message,
313
+ TASK_MESSAGE_MAX_V1,
314
+ "claimed task message",
315
+ ),
316
+ };
317
+ });
318
+ }
319
+
320
+ /** The queue's own bound, restated where the wire is decoded. */
321
+ const CLAIMED_TASK_MESSAGE_LIMIT = 16;
322
+
323
+ const TASK_ID_CHARACTER = /[^a-zA-Z0-9._-]/g;
324
+
325
+ /**
326
+ * The task id one tool call mints.
327
+ *
328
+ * Derived from the Turn's effect identifier, so a reconciled or retried call
329
+ * finds the task it already dispatched instead of dispatching a second child.
330
+ */
331
+ export function subagentTaskIdV1(effectId: string): string {
332
+ const sanitized = effectId.replace(TASK_ID_CHARACTER, "-").slice(0, 120);
333
+ return `tk-${sanitized || "call"}`;
334
+ }
335
+
336
+ /** The outcome a settled child run hands its parent. */
337
+ export function subagentOutcomeForRunV1(
338
+ run: { status: string; responseText?: string; failure?: string } | undefined,
339
+ settledAt: string,
340
+ thrown?: unknown,
341
+ ): TaskOutcomeV1 {
342
+ if (thrown !== undefined) {
343
+ return {
344
+ status: "failed",
345
+ settledAt,
346
+ failure: thrown instanceof Error ? thrown.message : String(thrown),
347
+ };
348
+ }
349
+ if (!run) {
350
+ return {
351
+ status: "failed",
352
+ settledAt,
353
+ failure: "the subagent Turn recorded no run",
354
+ };
355
+ }
356
+ if (run.status === "cancelled") {
357
+ return {
358
+ status: "stopped",
359
+ settledAt,
360
+ ...(run.failure === undefined ? {} : { failure: run.failure }),
361
+ };
362
+ }
363
+ if (run.status === "completed") {
364
+ const summary = run.responseText?.trim();
365
+ return {
366
+ status: "completed",
367
+ settledAt,
368
+ ...(summary ? { summary } : {}),
369
+ };
370
+ }
371
+ return {
372
+ status: "failed",
373
+ settledAt,
374
+ failure: run.failure ?? `the subagent's run is ${run.status}`,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * The `BOT_STATES` namespace, as this Package's subagent binding.
380
+ *
381
+ * The Subagent Durable Object is the *same class* in the *same namespace* as
382
+ * the Bot's own object, named `<userId>:<botId>#task:<taskId>` (ADR 0017) —
383
+ * so there is no migration, no second identity, and no way for a caller to
384
+ * reach one: `#` is outside `PUBLIC_IDENTIFIER_PATTERN`, so no Bot id can
385
+ * contain it and the suffix can only ever be minted here.
386
+ */
387
+ export function createBotSubagentDurableBindingV1(
388
+ namespace: DurableObjectNamespace,
389
+ ): SubagentDurableBindingV1 {
390
+ const stub = (name: string) =>
391
+ // SAFETY: this namespace is bound to the Bot Durable Object class;
392
+ // generated Worker types do not expose its RPC surface.
393
+ namespace.get(namespace.idFromName(name)) as unknown as {
394
+ runTask(input: unknown): Promise<unknown>;
395
+ readSubagentTask(input: unknown): Promise<unknown>;
396
+ settleTask(input: unknown): Promise<unknown>;
397
+ stopSubagentTask(input: unknown): Promise<unknown>;
398
+ claimTaskMessages(input: unknown): Promise<unknown>;
399
+ };
400
+ return {
401
+ accept: async (identity, anchorTaskId, request) => {
402
+ const answer = (await stub(
403
+ subagentDurableObjectNameV1({ ...identity, taskId: anchorTaskId }),
404
+ ).runTask({
405
+ schemaVersion: 1,
406
+ userId: identity.userId,
407
+ botId: identity.botId,
408
+ request,
409
+ })) as { childSessionId?: unknown };
410
+ const childSessionId = answer?.childSessionId;
411
+ if (typeof childSessionId !== "string" || childSessionId.length === 0) {
412
+ throw new Error("the Subagent Durable Object accepted no session");
413
+ }
414
+ return { childSessionId };
415
+ },
416
+ probe: async (identity, anchorTaskId, taskId) => {
417
+ const answer = await stub(
418
+ subagentDurableObjectNameV1({ ...identity, taskId: anchorTaskId }),
419
+ ).readSubagentTask({
420
+ schemaVersion: 1,
421
+ userId: identity.userId,
422
+ botId: identity.botId,
423
+ taskId,
424
+ });
425
+ if (answer === undefined || answer === null) return undefined;
426
+ return decodeSubagentTaskContextV1(JSON.parse(JSON.stringify(answer)));
427
+ },
428
+ stop: async (identity, anchorTaskId, taskId) => {
429
+ await stub(
430
+ subagentDurableObjectNameV1({ ...identity, taskId: anchorTaskId }),
431
+ ).stopSubagentTask({
432
+ schemaVersion: 1,
433
+ userId: identity.userId,
434
+ botId: identity.botId,
435
+ taskId,
436
+ });
437
+ },
438
+ claimMessagesOnParent: async (parent, taskId) => {
439
+ const answer = (await stub(
440
+ `${parent.userId}:${parent.botId}`,
441
+ ).claimTaskMessages({
442
+ schemaVersion: 1,
443
+ userId: parent.userId,
444
+ botId: parent.botId,
445
+ taskId,
446
+ })) as { messages?: unknown };
447
+ return decodeClaimedTaskMessagesV1(answer);
448
+ },
449
+ settleOnParent: async (parent, taskId, outcome) => {
450
+ await stub(`${parent.userId}:${parent.botId}`).settleTask({
451
+ schemaVersion: 1,
452
+ userId: parent.userId,
453
+ botId: parent.botId,
454
+ taskId,
455
+ outcome,
456
+ });
457
+ },
458
+ };
459
+ }