@frockbot/plugin-shell 0.0.0 → 0.1.0

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,106 @@
1
+ // Kernel run-recovery planning, bound to the Shell Package's run codec.
2
+ import {
3
+ Session,
4
+ type SessionEvent,
5
+ validateToolOccurrenceJournal,
6
+ } from "@frockbot/kernel-contracts";
7
+ import {
8
+ latestModelRequestJournalState,
9
+ planBotRunRecovery as planKernelBotRunRecovery,
10
+ type BotRunRecoveryPlan,
11
+ } from "@frockbot/kernel-do";
12
+ import {
13
+ requireStoredRunV1,
14
+ storedRunCodecV1,
15
+ type StoredRun,
16
+ } from "./backend-contracts.js";
17
+
18
+ export {
19
+ eventsForFailedRun,
20
+ latestModelRequestJournalState,
21
+ type BotRunRecoveryPlan,
22
+ type ModelRequestJournalState,
23
+ } from "@frockbot/kernel-do";
24
+
25
+ /** How a stopped run's unresolved effects settle. */
26
+ export type StoppedRunRecoveryPlan =
27
+ { kind: "cancel"; events: SessionEvent[] } | { kind: "reconcile" };
28
+
29
+ /**
30
+ * Classifies the unresolved effects of a stopped run from their durable
31
+ * admission records. With no compatibility data, a missing admission is the
32
+ * same definitive no-start outcome as `fenced`; only explicit `admitted`
33
+ * remains uncertain.
34
+ */
35
+ export function planStoppedRunRecovery(
36
+ run: StoredRun,
37
+ latest: readonly SessionEvent[],
38
+ ): StoppedRunRecoveryPlan {
39
+ requireStoredRunV1(run);
40
+ if (!run.stopRequestedAt) {
41
+ throw new Error(`run "${run.runId}" has no durable stop intent`);
42
+ }
43
+ const admissionFor = (kind: "model" | "tool", effectId: string) => {
44
+ const admission = run.effectAdmissions.find(
45
+ (candidate) => candidate.effectId === effectId,
46
+ );
47
+ if (admission && admission.kind !== kind) {
48
+ throw new Error(
49
+ `effect admission "${effectId}" collides with ${admission.kind}`,
50
+ );
51
+ }
52
+ return admission?.outcome;
53
+ };
54
+
55
+ const model = latestModelRequestJournalState(run.events);
56
+ const tools = validateToolOccurrenceJournal(run.events);
57
+ const openTools = [...tools.values()].filter(
58
+ (entry) => entry.intent !== undefined && entry.result === undefined,
59
+ );
60
+ if (
61
+ (model.status === "unresolved" &&
62
+ admissionFor("model", model.request.request.requestId) === "admitted") ||
63
+ openTools.some(
64
+ (entry) =>
65
+ admissionFor("tool", entry.occurrence.occurrenceId) === "admitted",
66
+ )
67
+ ) {
68
+ return { kind: "reconcile" };
69
+ }
70
+
71
+ const session = new Session(run.sessionId, () => {}, latest);
72
+ if (model.status === "unresolved") {
73
+ session.append({
74
+ type: "model/effect-not-started",
75
+ turn: model.request.turn,
76
+ step: model.request.step,
77
+ requestId: model.request.request.requestId,
78
+ reason: "Durable Stop fenced provider execution before admission",
79
+ });
80
+ }
81
+ for (const entry of openTools) {
82
+ const intent = entry.intent!;
83
+ session.append({
84
+ type: "tool/result",
85
+ turn: intent.turn,
86
+ step: intent.step,
87
+ occurrenceId: intent.occurrenceId,
88
+ name: intent.name,
89
+ content: "Durable Stop fenced tool execution before admission.",
90
+ isError: true,
91
+ status: "interrupted",
92
+ });
93
+ }
94
+ session.reconcileInterrupted();
95
+ return {
96
+ kind: "cancel",
97
+ events: [...session.events.slice(run.previousEventCount)],
98
+ };
99
+ }
100
+
101
+ export function planBotRunRecovery(
102
+ run: StoredRun,
103
+ latest: readonly SessionEvent[],
104
+ ): BotRunRecoveryPlan {
105
+ return planKernelBotRunRecovery(run, latest, storedRunCodecV1);
106
+ }
@@ -0,0 +1,375 @@
1
+ // The Bot Durable Object's half of the Routines seam.
2
+ //
3
+ // "The Bot's Durable Object is the authority for everything Bot-scoped: …
4
+ // durable scheduling, Routines, Assignments." The Routines Package holds the
5
+ // records, the codecs, the command semantics and the scheduler; this module
6
+ // supplies the two things the Package cannot own — the Durable Object's storage,
7
+ // and the one call that admits a Turn.
8
+ //
9
+ // FIRING IS AN IN-OBJECT CALL. `authority.run` is a method on the kernel
10
+ // authority, reached from `settleScheduledWork` inside the object. No HTTP path
11
+ // and no RPC reaches it, so nothing outside the Bot can cause a Routine to run
12
+ // as an automation Turn.
13
+ //
14
+ // HIBERNATION. Nothing here reaches a Computer. "The Agent loop, Memory,
15
+ // Skills, Package composition, and Routines function correctly while the
16
+ // Computer is hibernated and do not wake it": a Routine is Durable Object
17
+ // storage, an alarm, and a Turn.
18
+ import {
19
+ RoutineScheduler,
20
+ type RoutineFireOutcomeV1,
21
+ } from "@frockbot/plugin-routines/scheduler";
22
+ import {
23
+ routineSessionIdV1,
24
+ type RoutineFireV1,
25
+ } from "@frockbot/plugin-routines/firing";
26
+ import {
27
+ RoutineStore,
28
+ type RoutineHookMinterV1,
29
+ type RoutineStorageV1,
30
+ } from "@frockbot/plugin-routines/store";
31
+ import {
32
+ mintRoutineHookTokenV1,
33
+ routineHookDigestV1,
34
+ } from "@frockbot/plugin-routines/hook";
35
+ import { routineHookPathV1 } from "@frockbot/plugin-routines/shared";
36
+ import type { RoutinesRuntimeHostV1 } from "@frockbot/plugin-routines/agent";
37
+ import { routineHandoffTextV1 } from "@frockbot/plugin-routines/inbox";
38
+ import {
39
+ routineTerminalRecordsV1,
40
+ type RoutineTerminalRecordsV1,
41
+ } from "@frockbot/plugin-routines/inbox-store";
42
+ import { decodeRoutineRecordV1 } from "@frockbot/plugin-routines/records";
43
+ import { routineKeyV1 } from "@frockbot/plugin-routines/storage-keys";
44
+ import {
45
+ ROUTINE_RUN_EVENT_MAX,
46
+ type RoutineInboxEntryViewV1,
47
+ type RoutineRunDetailViewV1,
48
+ } from "@frockbot/plugin-routines/shared";
49
+ import type { RoutineInboxEntryV1 } from "@frockbot/plugin-routines/inbox";
50
+ import type { SessionEvent } from "@frockbot/kernel-contracts";
51
+
52
+ /** The Bot and User whose Routines a caller may reach. */
53
+ export interface BotRoutinesIdentity {
54
+ userId: string;
55
+ botId: string;
56
+ }
57
+
58
+ /** The run, Turn, and Session a Bot-authored Routine records as its writer. */
59
+ export interface BotRoutinesTurn {
60
+ runId: string;
61
+ turnId: string;
62
+ sessionId: string;
63
+ }
64
+
65
+ /**
66
+ * The Routines authority for one Bot Durable Object: the record store and the
67
+ * scheduler that fires it, built together because the command path needs the
68
+ * scheduler (`routine/run`) and the scheduler needs the records.
69
+ *
70
+ * `DurableObjectState.storage` already satisfies `RoutineStorageV1`; naming the
71
+ * narrow seam here is what keeps the Package testable without a Durable Object.
72
+ */
73
+ export function createBotRoutines(
74
+ storage: RoutineStorageV1,
75
+ hookKeys?: RoutineHookMinterV1,
76
+ ): {
77
+ store: RoutineStore;
78
+ scheduler: RoutineScheduler;
79
+ } {
80
+ const scheduler = new RoutineScheduler(storage);
81
+ return {
82
+ scheduler,
83
+ store: new RoutineStore(storage, {
84
+ firings: scheduler,
85
+ ...(hookKeys ? { hookKeys } : {}),
86
+ }),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * The webhook key minter for one Bot.
92
+ *
93
+ * The token is derived from the Worker secret and the Routine's identity, so it
94
+ * is reproducible and never stored; what the Bot keeps is its digest. Without
95
+ * the secret there is no minter at all, and a webhook Routine is refused with
96
+ * that reason rather than given a key that cannot be verified.
97
+ */
98
+ export function createBotRoutineHookMinter(
99
+ identity: () => Promise<BotRoutinesIdentity | undefined>,
100
+ secret: string | undefined,
101
+ ): RoutineHookMinterV1 | undefined {
102
+ if (!secret) return undefined;
103
+ return {
104
+ async mint({ routineId, keyVersion }) {
105
+ // The Bot's durable identity, not a constructor argument: a Durable
106
+ // Object learns who it is from its own storage, and a key that named the
107
+ // wrong Bot would verify at the edge against an object that never holds it.
108
+ const owner = await identity();
109
+ if (!owner) {
110
+ throw new Error("this Bot has no durable identity to key a webhook to");
111
+ }
112
+ const token = await mintRoutineHookTokenV1(secret, {
113
+ u: owner.userId,
114
+ b: owner.botId,
115
+ r: routineId,
116
+ v: keyVersion,
117
+ });
118
+ return {
119
+ token,
120
+ digest: await routineHookDigestV1(token),
121
+ path: routineHookPathV1(owner.botId, routineId),
122
+ };
123
+ },
124
+ };
125
+ }
126
+
127
+ /** Kept for callers that only want the record store. */
128
+ export function createBotRoutineStore(storage: RoutineStorageV1): RoutineStore {
129
+ return createBotRoutines(storage).store;
130
+ }
131
+
132
+ /**
133
+ * The Turn command one firing is admitted as.
134
+ *
135
+ * `turnType: "automation"` is the ceiling the firing runs under, and
136
+ * `origin` names the Routine and the firing, so the run stays attributable
137
+ * after the bounded run log has trimmed its index row away. The Session is the
138
+ * Routine's own — never the User's visible conversation.
139
+ */
140
+ export function routineTurnCommandV1(
141
+ identity: BotRoutinesIdentity,
142
+ fire: RoutineFireV1,
143
+ acceptedAt: string,
144
+ ) {
145
+ return {
146
+ userId: identity.userId,
147
+ botId: identity.botId,
148
+ runId: fire.fireId,
149
+ sessionId: routineSessionIdV1(fire.routineId),
150
+ acceptedAt,
151
+ text: fire.cue,
152
+ turnType: "automation" as const,
153
+ origin: {
154
+ kind: "routine" as const,
155
+ routineId: fire.routineId,
156
+ fireId: fire.fireId,
157
+ trigger: fire.trigger,
158
+ },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * What the run log records for a firing, read off the durable run rather than
164
+ * off the completion value: the run record is the authority for whether the
165
+ * Turn succeeded, and it survives an eviction that loses the value.
166
+ */
167
+ export function routineFireOutcomeV1(
168
+ run: { status: string; failure?: string; responseText?: string } | undefined,
169
+ thrown?: unknown,
170
+ ): RoutineFireOutcomeV1 {
171
+ if (thrown !== undefined) {
172
+ return {
173
+ status: "failed",
174
+ summary: thrown instanceof Error ? thrown.message : String(thrown),
175
+ };
176
+ }
177
+ if (!run) return { status: "failed", summary: "the firing recorded no run" };
178
+ if (run.status === "cancelled") {
179
+ return {
180
+ status: "cancelled",
181
+ ...(run.failure === undefined ? {} : { summary: run.failure }),
182
+ };
183
+ }
184
+ if (run.status === "completed") {
185
+ // A Turn that ended by handing off writes no assistant message, so its
186
+ // response text is empty; the log records the outcome and leaves the
187
+ // summary off rather than carrying an empty one.
188
+ return {
189
+ status: "ok",
190
+ ...(run.responseText === undefined || run.responseText.trim().length === 0
191
+ ? {}
192
+ : { summary: run.responseText }),
193
+ };
194
+ }
195
+ return {
196
+ status: "failed",
197
+ summary: run.failure ?? `the firing's run is ${run.status}`,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * The Routines seam one admitted Turn runs under. A Turn is required: a Bot
203
+ * writes a Routine only inside a Turn whose Session and Turn its provenance can
204
+ * name, exactly as it writes a Skill or authors a Package.
205
+ */
206
+ export function createBotRoutinesHost(
207
+ identity: BotRoutinesIdentity,
208
+ turn: BotRoutinesTurn,
209
+ store: RoutineStore,
210
+ ): RoutinesRuntimeHostV1 {
211
+ return {
212
+ botId: identity.botId,
213
+ writer: {
214
+ sessionId: turn.sessionId,
215
+ turnId: turn.turnId,
216
+ runId: turn.runId,
217
+ },
218
+ list: () => store.list(identity.botId),
219
+ execute: (command, writer) => store.execute(command, writer),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * The Routine a settled run belongs to, or `undefined` when it is an ordinary
225
+ * conversational Turn. Read off the durable admission record, so it survives an
226
+ * eviction and a trimmed run log alike.
227
+ */
228
+ export function settledRoutineOriginV1(run: {
229
+ admission?: {
230
+ turnType?: string;
231
+ origin?: { kind: string; routineId?: string };
232
+ };
233
+ }): { routineId: string } | undefined {
234
+ if (run.admission?.turnType !== "automation") return undefined;
235
+ const origin = run.admission.origin;
236
+ // A `subagent` origin reaches here on no path today — it is not an
237
+ // `automation` Turn — but the kind is checked rather than assumed, and the id
238
+ // it carries is checked with it.
239
+ if (!origin || origin.kind !== "routine" || !origin.routineId) {
240
+ return undefined;
241
+ }
242
+ return { routineId: origin.routineId };
243
+ }
244
+
245
+ /**
246
+ * The records one settled automation Turn contributes to the transaction that
247
+ * settles it: its completion-inbox entry, and — only when the Turn called
248
+ * `wake_parent` — the pending input the Bot's next conversational Turn is owed.
249
+ *
250
+ * A failed or cancelled firing reaches none of this: `completeStoredRun` is the
251
+ * only caller, so a firing that did not complete leaves a `failed` run-log
252
+ * entry and no inbox entry, which is the row's "durable, visible failure".
253
+ */
254
+ export async function routineTerminalRecordsForRunV1(input: {
255
+ run: {
256
+ runId: string;
257
+ events: readonly { type: string }[];
258
+ responseText?: string;
259
+ admission?: {
260
+ turnType?: string;
261
+ origin?: { kind: string; routineId?: string };
262
+ };
263
+ };
264
+ read<T>(key: string): Promise<T | undefined>;
265
+ now: string;
266
+ }): Promise<RoutineTerminalRecordsV1 | undefined> {
267
+ const origin = settledRoutineOriginV1(input.run);
268
+ if (!origin) return undefined;
269
+ const stored = await input.read<unknown>(routineKeyV1(origin.routineId));
270
+ const name =
271
+ stored === undefined
272
+ ? origin.routineId
273
+ : decodeRoutineRecordV1(stored).name;
274
+ const handoff = routineHandoffTextV1(input.run.events);
275
+ return routineTerminalRecordsV1({
276
+ runId: input.run.runId,
277
+ routineId: origin.routineId,
278
+ routineName: name,
279
+ now: input.now,
280
+ read: input.read,
281
+ ...(handoff === undefined ? {} : { handoff }),
282
+ ...(input.run.responseText === undefined
283
+ ? {}
284
+ : { responseText: input.run.responseText }),
285
+ });
286
+ }
287
+
288
+ /** One inbox entry, as the hosted client is told it. Never the wake id. */
289
+ export function routineInboxEntryViewV1(
290
+ entry: RoutineInboxEntryV1,
291
+ ): RoutineInboxEntryViewV1 {
292
+ return {
293
+ schemaVersion: 1,
294
+ entryId: entry.entryId,
295
+ runId: entry.runId,
296
+ routineId: entry.routineId,
297
+ text: entry.text,
298
+ attribution: entry.attribution,
299
+ createdAt: entry.createdAt,
300
+ acknowledged: entry.acknowledged,
301
+ ...(entry.acknowledgedAt === undefined
302
+ ? {}
303
+ : { acknowledgedAt: entry.acknowledgedAt }),
304
+ };
305
+ }
306
+
307
+ /** What one event of an automation run says, in one line, for the run log. */
308
+ function routineRunEventSummaryV1(event: SessionEvent): string | undefined {
309
+ switch (event.type) {
310
+ case "turn/start":
311
+ return "The firing's Turn started.";
312
+ case "user/message":
313
+ return `Cue: ${event.text}`;
314
+ case "assistant/message":
315
+ return event.toolCalls.length === 0
316
+ ? `Assistant: ${event.text}`
317
+ : `Assistant called ${event.toolCalls.map((call) => call.name).join(", ")}.`;
318
+ case "tool/result":
319
+ return `${event.name} ${event.isError ? "failed" : "returned"}: ${event.content}`;
320
+ case "wake/parent":
321
+ return `Handed off to the conversation: ${event.message}`;
322
+ case "turn/end":
323
+ return `The firing's Turn ended: ${event.outcome}${
324
+ event.reason === undefined ? "" : ` (${event.reason})`
325
+ }`;
326
+ default:
327
+ return undefined;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * One automation run, read-only.
333
+ *
334
+ * It carries no model request and no tool input: the run log answers "what did
335
+ * this firing do", and the durable log stays the place a full reconstruction
336
+ * comes from.
337
+ */
338
+ export function routineRunDetailViewV1(
339
+ botId: string,
340
+ routineId: string,
341
+ run: {
342
+ runId: string;
343
+ status: string;
344
+ acceptedAt: string;
345
+ input: string;
346
+ events: readonly SessionEvent[];
347
+ responseText?: string;
348
+ failure?: string;
349
+ },
350
+ ): RoutineRunDetailViewV1 {
351
+ const events: RoutineRunDetailViewV1["events"] = [];
352
+ for (const event of run.events) {
353
+ const summary = routineRunEventSummaryV1(event);
354
+ if (summary === undefined) continue;
355
+ events.push({
356
+ type: event.type,
357
+ at: event.timestamp,
358
+ summary: summary.slice(0, 2_000),
359
+ });
360
+ }
361
+ const outcome = run.failure ?? run.responseText;
362
+ return {
363
+ schemaVersion: 1,
364
+ botId,
365
+ routineId,
366
+ runId: run.runId,
367
+ status: run.status,
368
+ admittedAt: run.acceptedAt,
369
+ input: run.input.slice(0, 16_000),
370
+ events: events.slice(-ROUTINE_RUN_EVENT_MAX),
371
+ ...(outcome === undefined || outcome.length === 0
372
+ ? {}
373
+ : { outcome: outcome.slice(0, 4_000) }),
374
+ };
375
+ }