@fungi.computer/shed 0.0.1 → 0.2.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.
package/README.md CHANGED
@@ -78,11 +78,11 @@ Agents call Shed through Oyster's MCP endpoint, or at
78
78
  `POST $OYSTER_URL/apps/shed/methods/<name>` with a key; see
79
79
  [Git, keys and MCP](https://github.com/fungi-computer/oyster#git-keys-and-mcp).
80
80
 
81
- | Method | What it does |
82
- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- |
83
- | `runCommand({command, cwd?, timeoutMs?})` | Runs a shell command for up to 45 seconds and returns its exit status and output (64 KiB per stream). |
84
- | `listProcesses({})` | Lists up to 64 processes on the Computer. Starts nothing. |
85
- | `listPorts({})` | Lists up to 64 forwarded ports. Starts nothing. |
81
+ | Method | What it does |
82
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
83
+ | `runCommand({command, cwd?, timeoutMs?})` | Runs a shell command for up to 45 seconds. Returns `stdout`, `stderr`, `exitCode`, `stdoutTruncated` and `stderrTruncated` (64 KiB per stream), or `{ error: code }`. A budget refusal also supplies `kind` and `retryAfterMs`. Workflow Git commands use `$FUNGI_GIT_REMOTE`. |
84
+ | `listProcesses({})` | Lists up to 64 processes on the Computer. Starts nothing. |
85
+ | `listPorts({})` | Lists up to 64 forwarded ports. Starts nothing. |
86
86
 
87
87
  Workflows (see [Trellis](https://github.com/fungi-computer/trellis)) use a
88
88
  second set for longer work:
@@ -121,3 +121,15 @@ agent tools are in [backend/methods.ts](backend/methods.ts), and
121
121
  ## License
122
122
 
123
123
  MIT. See [LICENSE](LICENSE).
124
+
125
+ ## Local film replay
126
+
127
+ `TerminalPanel` optionally accepts `onTerminalReady(handle)` after Ghostty and
128
+ its font metrics are ready. The typed `TerminalFilmHandle` exposes `write`,
129
+ `reset` and `resize` with Ghostty's ordinary signatures, plus
130
+ `await handle.paint()` to finish drawing the current buffer to the canvas before
131
+ capturing it. The handle follows theme/width renderer replacements and throws
132
+ after unmount. Production callers omit the callback; terminal sessions and input
133
+ stay owned by Shed. Replay callers should reset, resize, write their frame, then
134
+ await `paint()`; a native write callback alone is not a canvas completion
135
+ signal.
@@ -1,3 +1,4 @@
1
+ import { resultRows } from "@fungi.computer/agar/method-presentation";
1
2
  import { processesGill } from "./gills.js";
2
3
  import {
3
4
  processPageInputSchema,
@@ -6,9 +7,10 @@ import {
6
7
  } from "./process-page.js";
7
8
  import * as z from "zod";
8
9
  import { Base64 } from "effect/encoding";
9
- import { computerPortInputSchema } from "@fungi.computer/computer/operations";
10
+ import { computerPortInputSchema } from "@fungi.computer/computer-contract";
10
11
  import {
11
12
  runShedCommand,
13
+ computerCall,
12
14
  shedRunCommandInputSchema,
13
15
  shedRunInputSchema,
14
16
  shedStartInputSchema,
@@ -30,7 +32,7 @@ import {
30
32
  computerProcessSchema,
31
33
  processIdInputSchema,
32
34
  processKillOutputSchema,
33
- } from "@fungi.computer/computer/operations";
35
+ } from "@fungi.computer/computer-contract";
34
36
  const empty = z.strictObject({});
35
37
  const command = runCommandInputSchema;
36
38
  const refusal = shedComputerRefusalSchema;
@@ -41,19 +43,19 @@ const commandReply = runCommandResultSchema;
41
43
  export type ShedMethodContext = Readonly<{
42
44
  runs?: ShedRuns;
43
45
  env: Readonly<{
44
- COMPUTER?: Partial<ShedComputer> &
46
+ COMPUTER: ShedComputer &
45
47
  Readonly<{
46
- ports?(input: Record<string, never>): Promise<WhistleJsonValue>;
47
- preview?(input: { port: number }): Promise<z.core.util.JSONType>;
48
- processKill?(
48
+ ports(input: Record<string, never>): Promise<WhistleJsonValue>;
49
+ preview(input: { port: number }): Promise<z.core.util.JSONType>;
50
+ processKill(
49
51
  input: z.output<typeof processIdInputSchema>,
50
52
  ): Promise<z.core.util.JSONType>;
51
- read?(input: {
53
+ read(input: {
52
54
  path: string;
53
55
  byteOffset: number;
54
56
  byteLength: number;
55
57
  }): Promise<z.core.util.JSONType>;
56
- exec?(input: z.output<typeof command>): Promise<WhistleJsonValue>;
58
+ exec(input: z.output<typeof command>): Promise<WhistleJsonValue>;
57
59
  }>;
58
60
  }>;
59
61
  }>;
@@ -68,6 +70,23 @@ const bytes = z.strictObject({
68
70
  encoding: z.literal("base64"),
69
71
  truncated: z.boolean(),
70
72
  });
73
+ /** The script's command lines, without blanks and comments. */
74
+ const commandLines = (command: string) =>
75
+ command
76
+ .split("\n")
77
+ .map((line) => line.trim())
78
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
79
+ /**
80
+ * A script reads by its last command: a script usually opens with setup
81
+ * (`set -eu`, `cd`) and ends with what it is for, such as a push.
82
+ */
83
+ const lastCommand = (command: string) =>
84
+ commandLines(command).at(-1)?.slice(0, 80) ?? "a command";
85
+ const scriptLength = (command: string) => {
86
+ const lines = commandLines(command).length;
87
+ return lines > 1 ? [`${String(lines)} commands`] : [];
88
+ };
89
+
71
90
  export const appBackend = defineAppBackend<ShedMethodContext>({
72
91
  gills: [processesGill],
73
92
  methods: {
@@ -79,7 +98,7 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
79
98
  input: computerPortInputSchema,
80
99
  output: preview,
81
100
  handler: async (input, context: ShedMethodContext, caller) => {
82
- if (!caller.computerId || !context.env.COMPUTER?.preview)
101
+ if (!caller.computerId)
83
102
  throw new Error("Computer preview access is not granted");
84
103
  // Project the issued URL; preview-row presentation policy stays with the host.
85
104
  return z
@@ -102,7 +121,7 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
102
121
  z.strictObject({ error: z.enum(["not_found", "ambiguous"]) }),
103
122
  ]),
104
123
  handler: async (input, context: ShedMethodContext, caller) => {
105
- if (!caller.computerId || !context.env.COMPUTER?.read || !context.runs)
124
+ if (!caller.computerId || !context.runs)
106
125
  throw new Error("Computer file access is not granted");
107
126
  const log = context.runs.logPath(
108
127
  input.run,
@@ -133,7 +152,7 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
133
152
  description:
134
153
  "Start one process on the pinned Computer and return its process and log path without waiting for exit.",
135
154
  effect: "write",
136
- requires: ["computer.execute", "computer.writeFile"],
155
+ requires: ["computer.execute"],
137
156
  pending: true,
138
157
  input: shedStartInputSchema,
139
158
  output: z.union([
@@ -146,29 +165,19 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
146
165
  ]),
147
166
  handler: (input, context: ShedMethodContext, caller) => {
148
167
  const computer = context.env.COMPUTER;
149
- if (
150
- !caller.computerId ||
151
- !computer?.processStart ||
152
- !computer.processes ||
153
- !context.runs
154
- )
168
+ if (!caller.computerId || !context.runs)
155
169
  throw new Error("Computer access is not granted");
156
170
  if (!caller.idempotencyKey)
157
171
  throw new Error(
158
172
  "A host idempotency key is required to start a process",
159
173
  );
160
- const { processStart, processes, workflowGitPass, write } = computer;
174
+ const { processStart, processes } = computer;
161
175
  return context.runs.start(
162
176
  input,
163
177
  caller.computerId,
164
178
  caller.idempotencyKey,
165
179
  caller.actor,
166
- {
167
- processStart,
168
- processes,
169
- workflowGitPass,
170
- write,
171
- },
180
+ { processStart, processes },
172
181
  );
173
182
  },
174
183
  }),
@@ -179,20 +188,22 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
179
188
  input: processIdInputSchema,
180
189
  output: z.union([processKillOutputSchema, refusal]),
181
190
  handler: async (input, context: ShedMethodContext, caller) => {
182
- if (!caller.computerId || !context.env.COMPUTER?.processKill)
191
+ if (!caller.computerId)
183
192
  throw new Error("Computer access is not granted");
184
193
  if (!context.runs?.owns(input.id, caller.computerId, caller.actor))
185
194
  return refusal.parse({ error: "not_found" });
186
195
  return z
187
196
  .union([processKillOutputSchema, refusal])
188
- .parse(await context.env.COMPUTER.processKill(input));
197
+ .parse(
198
+ await computerCall(() => context.env.COMPUTER.processKill(input)),
199
+ );
189
200
  },
190
201
  }),
191
202
  run: defineAppMethod({
192
203
  description:
193
204
  "Run one command on the pinned Computer, retaining a log and polling until it exits.",
194
205
  effect: "write",
195
- requires: ["computer.execute", "computer.writeFile"],
206
+ requires: ["computer.execute"],
196
207
  pending: true,
197
208
  input: shedRunInputSchema,
198
209
  output: z.union([
@@ -202,16 +213,34 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
202
213
  }),
203
214
  refusal,
204
215
  ]),
216
+ present: {
217
+ rows: resultRows,
218
+ verb: "ran",
219
+ row: ({ input, output }) =>
220
+ "error" in output
221
+ ? {
222
+ id: "refused",
223
+ title: "Shed could not run the command",
224
+ detail: output.error,
225
+ tone: "error",
226
+ }
227
+ : {
228
+ id: output.logPath,
229
+ title: `Shed ran ${lastCommand(input.command)}`,
230
+ detail: [
231
+ output.exitCode === 0
232
+ ? "Finished"
233
+ : `Exited with code ${String(output.exitCode)}`,
234
+ ...scriptLength(input.command),
235
+ ].join(" · "),
236
+ tone: output.exitCode === 0 ? "settled" : "error",
237
+ },
238
+ },
205
239
  handler: (input, context: ShedMethodContext, caller) => {
206
240
  const computer = context.env.COMPUTER;
207
- if (
208
- !caller.computerId ||
209
- !computer?.processStart ||
210
- !computer.processes ||
211
- !context.runs
212
- )
241
+ if (!caller.computerId || !context.runs)
213
242
  throw new Error("Computer access is not granted");
214
- const { processStart, processes, workflowGitPass, write } = computer;
243
+ const { processStart, processes } = computer;
215
244
  if (!caller.idempotencyKey)
216
245
  throw new Error("A host idempotency key is required to start a run");
217
246
  return context.runs.run(
@@ -219,7 +248,7 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
219
248
  caller.computerId,
220
249
  caller.idempotencyKey,
221
250
  caller.actor,
222
- { processStart, processes, workflowGitPass, write },
251
+ { processStart, processes },
223
252
  );
224
253
  },
225
254
  }),
@@ -232,9 +261,11 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
232
261
  input: empty,
233
262
  output: portsReply,
234
263
  handler: async (input, context, caller) =>
235
- caller.computerId === null || !context.env.COMPUTER?.ports
264
+ caller.computerId === null
236
265
  ? refusal.parse({ error: "not_granted" })
237
- : portsReply.parse(await context.env.COMPUTER.ports(input)),
266
+ : portsReply.parse(
267
+ await computerCall(() => context.env.COMPUTER.ports(input)),
268
+ ),
238
269
  }),
239
270
  listProcesses: defineAppMethod({
240
271
  description:
@@ -244,7 +275,7 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
244
275
  input: processPageInputSchema,
245
276
  output: processesReply,
246
277
  handler: async (input, context, caller) => {
247
- if (caller.computerId === null || !context.env.COMPUTER?.processes)
278
+ if (caller.computerId === null)
248
279
  return refusal.parse({ error: "not_granted" });
249
280
  if (
250
281
  input.after !== undefined &&
@@ -252,7 +283,7 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
252
283
  )
253
284
  return refusal.parse({ error: "invalid" });
254
285
  const inventory = processesReply.parse(
255
- await context.env.COMPUTER.processes({}),
286
+ await computerCall(() => context.env.COMPUTER.processes({})),
256
287
  );
257
288
  return "error" in inventory
258
289
  ? inventory
@@ -261,17 +292,17 @@ export const appBackend = defineAppBackend<ShedMethodContext>({
261
292
  }),
262
293
  runCommand: defineAppMethod({
263
294
  description:
264
- "Execute a shell command on the attached Computer, with an optional absolute cwd and a timeout up to 45 seconds. A Workflow step's admitted gitAccess reaches the command as $FUNGI_GIT_REMOTE with command-local Git credentials. The host mints request identity, owns the per-attachment execution budget and attribution. Each output stream is bounded to the Computer’s 64 KiB byte slice, with up to two extra UTF-8 bytes from a replacement character, and may be truncated.",
295
+ "Run a shell command on the attached Computer. Use cwd to choose an absolute working directory. The timeout is at most 45 seconds. In a Workflow Git step, use $FUNGI_GIT_REMOTE for the repository. Returns stdout, stderr, exitCode and a truncation flag for each stream, or an error code if the command cannot return output. A nonzero exitCode indicates failure or cancellation. Output may be truncated.",
265
296
  effect: "write",
266
- requires: ["computer.execute", "computer.writeFile"],
297
+ requires: ["computer.execute"],
267
298
  input: shedRunCommandInputSchema,
268
299
  output: commandReply,
269
300
  handler: async (input, context, caller) => {
270
301
  const computer = context.env.COMPUTER;
271
- if (caller.computerId === null || !computer?.exec)
302
+ if (caller.computerId === null)
272
303
  return refusal.parse({ error: "not_granted" });
273
- const { exec, workflowGitPass, write } = computer;
274
- return runShedCommand(input, { exec, workflowGitPass, write });
304
+ const { exec } = computer;
305
+ return runShedCommand(input, { exec });
275
306
  },
276
307
  }),
277
308
  },
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { processListOutputSchema } from "@fungi.computer/computer/operations";
2
+ import { processListOutputSchema } from "@fungi.computer/computer-contract";
3
3
 
4
4
  const process = processListOutputSchema.shape.processes.element;
5
5
  const processCursorSchema = z.strictObject({