@dynamicagents/plugins 0.12.0 → 0.13.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 (130) hide show
  1. package/README.md +58 -77
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +53 -0
  5. package/dist/claude-code/cards.js +272 -0
  6. package/dist/claude-code/config.d.ts +28 -63
  7. package/dist/claude-code/config.js +0 -11
  8. package/dist/claude-code/egress.d.ts +8 -1
  9. package/dist/claude-code/egress.js +42 -4
  10. package/dist/claude-code/events.d.ts +106 -9
  11. package/dist/claude-code/events.js +201 -56
  12. package/dist/claude-code/github.d.ts +39 -0
  13. package/dist/claude-code/github.js +82 -0
  14. package/dist/claude-code/index.d.ts +16 -90
  15. package/dist/claude-code/index.js +14 -276
  16. package/dist/claude-code/model.d.ts +152 -0
  17. package/dist/claude-code/model.js +342 -0
  18. package/dist/claude-code/run.d.ts +209 -112
  19. package/dist/claude-code/run.js +339 -138
  20. package/dist/claude-code/session.d.ts +131 -0
  21. package/dist/claude-code/session.js +285 -0
  22. package/dist/claude-code/spec.d.ts +46 -0
  23. package/dist/claude-code/spec.js +83 -0
  24. package/dist/claude-code/verified.d.ts +15 -0
  25. package/dist/claude-code/verified.js +15 -0
  26. package/dist/computer/context.d.ts +1 -5
  27. package/dist/computer/context.js +7 -10
  28. package/dist/computer/file-lock.js +5 -4
  29. package/dist/computer/gate.d.ts +4 -4
  30. package/dist/computer/gate.js +5 -5
  31. package/dist/computer/index.d.ts +40 -258
  32. package/dist/computer/index.js +71 -324
  33. package/dist/computer/paths.d.ts +36 -16
  34. package/dist/computer/paths.js +42 -29
  35. package/dist/computer/proxy.d.ts +45 -0
  36. package/dist/computer/proxy.js +234 -0
  37. package/dist/computer/render.d.ts +1 -40
  38. package/dist/computer/render.js +2 -68
  39. package/dist/computer/tools-exec.js +12 -13
  40. package/dist/computer/tools-file.d.ts +9 -1
  41. package/dist/computer/tools-file.js +22 -82
  42. package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
  43. package/dist/computer/tools-grep.js +129 -0
  44. package/dist/repo/checkout.d.ts +4 -5
  45. package/dist/repo/checkout.js +31 -8
  46. package/dist/repo/context.d.ts +35 -9
  47. package/dist/repo/context.js +101 -49
  48. package/dist/repo/index.d.ts +73 -28
  49. package/dist/repo/index.js +112 -96
  50. package/dist/repo/tools-checks.d.ts +3 -0
  51. package/dist/repo/tools-checks.js +112 -0
  52. package/dist/repo/tools-clone.js +1 -1
  53. package/dist/repo/tools-pr.d.ts +14 -0
  54. package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
  55. package/dist/repo/tools-review.d.ts +9 -0
  56. package/dist/repo/tools-review.js +67 -47
  57. package/dist/repo/tools-worktree.js +144 -60
  58. package/dist/repo/tools-worktrees.d.ts +12 -0
  59. package/dist/repo/tools-worktrees.js +48 -0
  60. package/dist/repo/url.d.ts +2 -2
  61. package/dist/repo/url.js +7 -7
  62. package/dist/scratch/index.d.ts +4 -4
  63. package/dist/scratch/index.js +12 -11
  64. package/dist/{computer → workspace}/advisory.d.ts +4 -4
  65. package/dist/{computer → workspace}/advisory.js +2 -2
  66. package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
  67. package/dist/{computer/host → workspace}/ca-trust.js +12 -3
  68. package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
  69. package/dist/{computer/host → workspace}/container-fault.js +23 -8
  70. package/dist/workspace/exec.d.ts +94 -0
  71. package/dist/workspace/exec.js +153 -0
  72. package/dist/workspace/format.d.ts +36 -0
  73. package/dist/workspace/format.js +57 -0
  74. package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
  75. package/dist/{computer/host → workspace}/git-host.js +72 -2
  76. package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
  77. package/dist/{computer/host → workspace}/git-identity.js +1 -1
  78. package/dist/workspace/index.d.ts +43 -47
  79. package/dist/workspace/index.js +52 -112
  80. package/dist/{computer/host → workspace}/install-job.d.ts +4 -4
  81. package/dist/{computer/host → workspace}/install-job.js +276 -176
  82. package/dist/{computer → workspace}/install.d.ts +8 -8
  83. package/dist/{computer → workspace}/install.js +3 -3
  84. package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +105 -27
  85. package/dist/{computer/host/workspace.js → workspace/object.js} +217 -48
  86. package/dist/workspace/open.d.ts +95 -0
  87. package/dist/workspace/open.js +63 -0
  88. package/dist/workspace/read.d.ts +16 -0
  89. package/dist/workspace/read.js +22 -0
  90. package/dist/{computer → workspace}/shell.d.ts +5 -5
  91. package/dist/{computer → workspace}/shell.js +6 -6
  92. package/dist/{computer/host → workspace}/wake.d.ts +1 -1
  93. package/package.json +16 -36
  94. package/dist/arc-agi/analysis.d.ts +0 -322
  95. package/dist/arc-agi/analysis.js +0 -802
  96. package/dist/arc-agi/client.d.ts +0 -82
  97. package/dist/arc-agi/client.js +0 -113
  98. package/dist/arc-agi/game-tools.d.ts +0 -24
  99. package/dist/arc-agi/game-tools.js +0 -26
  100. package/dist/arc-agi/index.d.ts +0 -40
  101. package/dist/arc-agi/index.js +0 -175
  102. package/dist/arc-agi/main-agent.d.ts +0 -24
  103. package/dist/arc-agi/main-agent.js +0 -62
  104. package/dist/arc-agi/recipe.d.ts +0 -48
  105. package/dist/arc-agi/recipe.js +0 -97
  106. package/dist/arc-agi/scorecard.d.ts +0 -118
  107. package/dist/arc-agi/scorecard.js +0 -163
  108. package/dist/arc-agi/soul.d.ts +0 -29
  109. package/dist/arc-agi/soul.js +0 -79
  110. package/dist/arc-agi/store.d.ts +0 -153
  111. package/dist/arc-agi/store.js +0 -215
  112. package/dist/arc-agi/tools.d.ts +0 -36
  113. package/dist/arc-agi/tools.js +0 -574
  114. package/dist/arc-agi/types.d.ts +0 -211
  115. package/dist/arc-agi/types.js +0 -19
  116. package/dist/claude-code/recipe.d.ts +0 -53
  117. package/dist/claude-code/recipe.js +0 -100
  118. package/dist/computer/read.d.ts +0 -133
  119. package/dist/computer/read.js +0 -200
  120. package/dist/computer/tools-find.js +0 -216
  121. package/dist/recall/index.d.ts +0 -105
  122. package/dist/recall/index.js +0 -181
  123. package/dist/repo/tools-forge.d.ts +0 -4
  124. package/dist/triage/index.d.ts +0 -360
  125. package/dist/triage/index.js +0 -311
  126. /package/dist/{computer/host → workspace}/container-deps.d.ts +0 -0
  127. /package/dist/{computer/host → workspace}/container-deps.js +0 -0
  128. /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
  129. /package/dist/{computer/host → workspace}/sync.js +0 -0
  130. /package/dist/{computer/host → workspace}/wake.js +0 -0
@@ -4,20 +4,19 @@ import { DurableObject, tracing } from "cloudflare:workers";
4
4
  // object owes on waking, which is the callbacks registered on it below.
5
5
  import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
6
6
  import { Workspace } from "@cloudflare/computer";
7
- import { CloudflareContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container";
7
+ import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container";
8
8
  import { createGitClient } from "@cloudflare/computer/git";
9
9
  import { createCloudflareObserver } from "@cloudflare/computer/observe/cloudflare";
10
- import { MAX_TOOL_CALL_MS } from "@dynamicagents/core";
11
10
  import { ContainerTrust } from "./ca-trust.js";
12
11
  import { ContainerDeps } from "./container-deps.js";
13
12
  import { ContainerGitIdentity } from "./git-identity.js";
14
13
  import { InstallJob } from "./install-job.js";
15
14
  import { WorkspaceGitHost } from "./git-host.js";
16
15
  import { SYNC_DRAIN_BUDGET_MS, SYNC_DRAIN_RESUME_MS, WorkspaceSync, syncRetryDelayMs } from "./sync.js";
17
- // Leaf modules rather than `../index.js` — see the same import in
16
+ // Leaf modules rather than `./index.ts` — see the same import in
18
17
  // {@link file://./install-job.ts} for why the barrel is not reachable from here.
19
- import { deriveAdvisories } from "../advisory.js";
20
- import { pathExists } from "../read.js";
18
+ import { deriveAdvisories } from "./advisory.js";
19
+ import { pathExists } from "./read.js";
21
20
  /**
22
21
  * A workspace: one Durable Object, one container, one repository.
23
22
  *
@@ -30,7 +29,7 @@ import { pathExists } from "../read.js";
30
29
  * A directory inside the plugin rather than a subpath beside it, so that one
31
30
  * capability stays one import path and a consumer cannot take the tools without
32
31
  * the object they address. The bundle cost of that is nothing: `sideEffects` is
33
- * false, so an agent that only calls `sb_exec` carries no container backend and
32
+ * false, so an agent that only calls `bash` carries no container backend and
34
33
  * no isomorphic-git.
35
34
  *
36
35
  * What this file must not do is import the plugin barrel. The barrel re-exports
@@ -43,7 +42,7 @@ import { pathExists } from "../read.js";
43
42
  * FUSE at `/workspace`. Commands run against the same tree the Worker reads over
44
43
  * RPC, and the tree outlives the container.
45
44
  *
46
- * **One repository per object**, because `computer` is strictly 1 DO ↔ 1
45
+ * **One repository per object**, because `@cloudflare/computer` is strictly 1 DO ↔ 1
47
46
  * container: the id derives from caller *and* repository (see `workspaceName`),
48
47
  * so two repositories for one caller are two objects and two containers.
49
48
  *
@@ -66,7 +65,7 @@ import { pathExists } from "../read.js";
66
65
  * - `./sync.ts` — the host's half of syncing, which nothing else will do.
67
66
  * - `./ca-trust.ts` — making a container able to speak TLS.
68
67
  * - `./git-identity.ts` — who a container's commits belong to.
69
- * - `./git-host.ts` — the operations that hold the forge credential.
68
+ * - `./git-host.ts` — the operations that hold the GitHub credential.
70
69
  *
71
70
  * Each takes a small explicit seam, and that is the test: a module that needed
72
71
  * the whole object back would be a module that did not want extracting.
@@ -77,7 +76,7 @@ export const WORKSPACE_DIR = "/workspace";
77
76
  * The Durable Object name for one caller's checkout of one repository.
78
77
  *
79
78
  * Exported because several places must agree on it and none can see the others:
80
- * the plugin that resolves the stub, the agent that hands it down to subagents,
79
+ * the plugin that resolves the stub, the agent that hands it down to sub-agents,
81
80
  * and the cancellation path. A pipe rather than a slash, so the caller half
82
81
  * cannot forge a repository boundary by containing one.
83
82
  *
@@ -90,7 +89,7 @@ export const WORKSPACE_DIR = "/workspace";
90
89
  *
91
90
  * `repo` is not always a repository. A host may pass a sentinel through here as
92
91
  * one — a scratchpad is modelled that way, which is what keys it to its own
93
- * object and its own container. Anything that is not a forge name works, as long
92
+ * object and its own container. Anything that is not a GitHub name works, as long
94
93
  * as no caller could clone something that collides with it.
95
94
  */
96
95
  export function workspaceName(callerKey, repo) {
@@ -123,31 +122,38 @@ const IDLE_RECLAIM_ID = "idle-reclaim-id";
123
122
  * Source-only workspaces are small (6.3 MB for slack-gatekeeper), which makes
124
123
  * this hygiene rather than cost control — but unbounded hygiene is still
125
124
  * unbounded.
125
+ *
126
+ * Exported because a host keeping work in a workspace has to say how long that
127
+ * work survives untouched.
126
128
  */
127
- const IDLE_RECLAIM_MS = 7 * 24 * 60 * 60 * 1000;
129
+ export const IDLE_RECLAIM_MS = 7 * 24 * 60 * 60 * 1000;
128
130
  /** Where the container-idle deadline keeps its current schedule id. */
129
131
  const CONTAINER_IDLE_ID = "container-idle-id";
132
+ /** The holds on the container — see `hold` — as key → the time each lapses. */
133
+ const HOLDS_KEY = "container-holds";
134
+ /** When a release was asked for while a hold stood, for the last `unhold`. */
135
+ const RELEASE_ASKED_KEY = "container-release-asked";
130
136
  /** Where the container-warm wake keeps its current schedule id. */
131
137
  const CONTAINER_WARM_ID = "container-warm-id";
132
138
  /**
133
139
  * How long a container stays up after the last command **started**.
134
140
  *
135
- * Ours to schedule: `withWorkspaceContainer` wraps the runtime's raw
136
- * `ctx.container`, not `@cloudflare/containers`' `Container`, so there is no
137
- * `sleepAfter` to lean on.
141
+ * Ours to schedule. The backend replaces a container but never stops one, and
142
+ * there is no `@cloudflare/containers` `Container` here with a `sleepAfter` to
143
+ * lean on: a container nobody is using runs until this deadline stops it.
138
144
  *
139
145
  * **This must exceed the longest command the agent allows**, and breaking that
140
146
  * kills work in flight. Measured from when a command *starts*: `#touch()` arms
141
147
  * the clock on the way into this object, and a running command touches nothing
142
148
  * again until it finishes — `handle.result()` is one long await and the FUSE
143
- * traffic under it never surfaces as an RPC. Set equal to the computer plugin's
144
- * `DEFAULT_TIMEOUT_MS`, the two timers race and whichever fires first destroys
149
+ * traffic under it never surfaces as an RPC. Set equal to `DEFAULT_TIMEOUT_MS`
150
+ * in `./exec.ts`, the two timers race and whichever fires first destroys
145
151
  * the container the other depends on. Twenty minutes is double that ceiling;
146
- * raise it, never lower it, if `sb_exec` is given a longer timeout.
152
+ * raise it, never lower it, if `bash` is given a longer timeout.
147
153
  *
148
154
  * **The default, not the policy.** "Longest command" is a fact about the agent,
149
155
  * so one whose commands run longer must say so via
150
- * {@link WorkspaceObjectConfig.containerIdleMs} — `claude-coder` holds a
156
+ * {@link WorkspaceObjectConfig.containerIdleMs} — `claude-coordinator` holds a
151
157
  * `claude -p` session open for its whole 40-minute timeout.
152
158
  */
153
159
  const CONTAINER_IDLE_MS = 20 * 60_000;
@@ -174,13 +180,13 @@ const SYNC_DRAIN_GRACE_MS = 30 * 60_000;
174
180
  /**
175
181
  * How long a container start may take before the calls waiting on it are refused.
176
182
  *
177
- * A minute under core's per-call limit, and tied to it: past that limit core
178
- * abandons the call anyway, the model reads no reason, and the next call joins
179
- * the same stuck start. As late as that allows, because refusing a start that
180
- * was only slow clears `#readying` while it runs, and the next caller starts a
181
- * second one beside it — and a start pushing a large tree has taken nine minutes.
183
+ * Late, because refusing a start that was only slow clears `#readying` while it
184
+ * runs, and the next caller starts a second one beside it — and a start pushing
185
+ * a large tree has taken nine minutes. Not later, because a turn is cut at
186
+ * fifteen: past that the model reads no reason, and its next turn joins the
187
+ * same stuck start.
182
188
  */
183
- const READY_DEADLINE_MS = MAX_TOOL_CALL_MS - 60_000;
189
+ const READY_DEADLINE_MS = 9 * 60_000;
184
190
  /**
185
191
  * `work`, refused once `ms` pass without it settling. The work itself goes on;
186
192
  * only the wait for it ends — see {@link READY_DEADLINE_MS}.
@@ -199,6 +205,22 @@ export function readyWithin(work, ms) {
199
205
  * see {@link WorkspaceObjectBase.#purgeSyncedTrees}.
200
206
  */
201
207
  const SYNCED_TREES_PURGED_KEY = "deps:purged";
208
+ /**
209
+ * The `images` key a container starts from: `containers[].images.app` in the
210
+ * consumer's wrangler config. Named to the backend rather than left as its
211
+ * default, because {@link IMAGE_ENV} has to be read under the same key.
212
+ */
213
+ const IMAGE_NAME = "app";
214
+ /**
215
+ * The env name the prepared image's reference rides under — see `backend` for
216
+ * why it rides at all.
217
+ *
218
+ * Nothing reads it, and the spelling keeps it that way. `computerd` hands a
219
+ * command its allowlist plus every `COMPUTER_VAR_` name, and reads its own
220
+ * configuration from `COMPUTERD_`, where a later release could claim this
221
+ * name. Outside both, it stays in the daemon's environment, unread.
222
+ */
223
+ const IMAGE_ENV = "DYNAMICAGENTS_IMAGE";
202
224
  // --- the object -------------------------------------------------------------
203
225
  /**
204
226
  * Named rather than the anonymous class expression an app would write here.
@@ -212,10 +234,9 @@ class WorkspaceContainerHost extends DurableObject {
212
234
  }
213
235
  /**
214
236
  * `withWorkspaceContainer` adds one method, `getWorkspaceContainer()`, over
215
- * `this.ctx.container` — the runtime's own container handle. There is no
216
- * `@cloudflare/containers` `Container` subclass here and so no `sleepAfter`:
217
- * idle shutdown is this object's job, and it lands on the wake map with
218
- * everything else.
237
+ * `this.ctx.container` — the runtime's own container handle, started with the
238
+ * image and instance size this object asks for. Stopping it is this object's
239
+ * job — see {@link CONTAINER_IDLE_MS}.
219
240
  */
220
241
  const WorkspaceContainerBase = withWorkspaceContainer(WorkspaceContainerHost);
221
242
  /**
@@ -223,14 +244,13 @@ const WorkspaceContainerBase = withWorkspaceContainer(WorkspaceContainerHost);
223
244
  *
224
245
  * Abstract because a Durable Object class takes no constructor arguments, so
225
246
  * per-agent configuration cannot arrive that way. {@link workspaceConfig} is the
226
- * seam, and it is the shape core's own `RecipeSubagentBase.subagentRuntime`
227
- * uses for exactly the same reason.
247
+ * seam, for the same reason Think's own hooks are methods rather than options.
228
248
  *
229
249
  * ## Why `backend` and `#workspace` are lazy
230
250
  *
231
251
  * **Base class fields run before subclass fields**, so as plain fields they
232
252
  * would read `undefined` from any `workspaceConfig()` that touches a subclass
233
- * field — which `ClaudeCoderWorkspaceDO`'s does, for its credential store.
253
+ * field — which `ClaudeCoordinatorWorkspace`'s does, for its credential store.
234
254
  * Memoised getters remove the hazard rather than documenting it, which is what
235
255
  * lets a subclass implement the seam however it likes.
236
256
  */
@@ -353,7 +373,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
353
373
  id: () => this.ctx.id.toString()
354
374
  });
355
375
  /**
356
- * The forge credential, read at the moment it would be handed over.
376
+ * The GitHub credential, read at the moment it would be handed over.
357
377
  *
358
378
  * `#`-private, which is the only spelling actually unreachable over RPC — see
359
379
  * {@link WorkspaceGitConfig.tokenBinding} for why that matters here and not
@@ -392,19 +412,38 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
392
412
  * Nothing sets `egressHost`; the default `computer.internal` is the host the
393
413
  * container's outbound HTTP is intercepted on, internal to that loopback.
394
414
  *
395
- * The binding name and the egress policy are the subclass's — see
396
- * {@link WorkspaceObjectConfig}, which carries the warnings on both.
415
+ * **The image rides in the env so that a deploy replaces the container.** The
416
+ * backend reuses a running container whose launch — env, network, size, image
417
+ * key — matches the one it asks for, and the image a key resolves to is not
418
+ * part of that: a container started before a deploy would go on running the
419
+ * old image under the new Worker. The library here and the `computerd` in the
420
+ * image are one release, and a daemon from across a version line fails to
421
+ * authenticate (`[stage=auth]`). With the prepared image's reference in the
422
+ * env as {@link IMAGE_ENV}, a new image is a new launch, and the next connect
423
+ * replaces the container. With no image prepared under {@link IMAGE_NAME}
424
+ * there is nothing to add, and the backend's start says so.
425
+ *
426
+ * The binding name, the egress policy and the instance size are the
427
+ * subclass's — see {@link WorkspaceObjectConfig}, which carries the warnings
428
+ * on each.
397
429
  */
398
430
  #backendMemo;
399
431
  get backend() {
400
- return (this.#backendMemo ??= new CloudflareContainerBackend({
432
+ return (this.#backendMemo ??= this.#newBackend());
433
+ }
434
+ #newBackend() {
435
+ const image = this.ctx.container?.images?.[IMAGE_NAME];
436
+ return new ContainerBackend({
401
437
  container: () => this,
402
438
  workspace: {
403
439
  binding: this.#cfg.binding,
404
440
  id: this.ctx.id.toString()
405
441
  },
406
- egress: this.#cfg.egress
407
- }));
442
+ egress: this.#cfg.egress,
443
+ name: IMAGE_NAME,
444
+ instance: this.#cfg.instance,
445
+ containerEnv: image === undefined ? {} : { [IMAGE_ENV]: image }
446
+ });
408
447
  }
409
448
  #workspaceMemo;
410
449
  get #workspace() {
@@ -486,7 +525,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
486
525
  * method is reachable over RPC — see {@link WorkspaceGitConfig.tokenBinding}.
487
526
  *
488
527
  * **One start at a time**, because {@link #warmIfCold} makes overlap ordinary:
489
- * a warm and the first `sb_exec` want the container at the same moment, as do
528
+ * a warm and the first `bash` want the container at the same moment, as do
490
529
  * a warm and the install alarm. Two starts means two trust commands through a
491
530
  * half-established connection, the second pushing the tree again behind the
492
531
  * first. Cleared on settle, so the next caller asks about the container that
@@ -581,7 +620,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
581
620
  observer: createCloudflareObserver({ tracing }),
582
621
  // Git, running **here** rather than in the container.
583
622
  //
584
- // This is what lets the forge token stay on this side of the boundary.
623
+ // This is what lets the GitHub token stay on this side of the boundary.
585
624
  // `createGitClient` binds isomorphic-git to `provider()` — the local
586
625
  // SQLite store, not the wire — so a clone, fetch or push executes next to
587
626
  // the data it writes, and the container never holds a credential at all.
@@ -657,7 +696,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
657
696
  *
658
697
  * A stub cannot enforce what the caller does with it: `runtime.exec` on this
659
698
  * one would run against a container with no CA. The exec paths take the other
660
- * method — see `openWorkspaceFs` in `@dynamicagents/plugins/computer`.
699
+ * method — see `openWorkspaceFs` in `./open.ts`.
661
700
  */
662
701
  async __getWorkspaceFsStub() {
663
702
  await this.#touch();
@@ -669,7 +708,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
669
708
  return this.#workspace.stub();
670
709
  }
671
710
  /**
672
- * Ask the alarm to start the container, so the first `sb_exec` finds one that
711
+ * Ask the alarm to start the container, so the first `bash` finds one that
673
712
  * is up and already holding the tree.
674
713
  *
675
714
  * What this avoids is the push, not the boot — see {@link __getWorkspaceFsStub}.
@@ -679,7 +718,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
679
718
  * nothing to push and nothing to run, and a container it never uses still
680
719
  * bills.
681
720
  *
682
- * Only the arming is awaited. A warm that fails costs the next `sb_exec` the
721
+ * Only the arming is awaited. A warm that fails costs the next `bash` the
683
722
  * start it would have paid for anyway.
684
723
  */
685
724
  async #warmIfCold() {
@@ -697,7 +736,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
697
736
  await this.#containerWarm.set(new Date());
698
737
  }
699
738
  /**
700
- * Clone, fetch and push — the operations that need the forge token.
739
+ * Clone, fetch and push — the operations that need the GitHub token.
701
740
  *
702
741
  * The work is `./git-host.ts`, which owns why the credential stays on this
703
742
  * side of the boundary. What stays here is what only this object can do: the
@@ -716,6 +755,14 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
716
755
  const behind = await this.#beforeGit();
717
756
  return behind ?? (await this.#gitHost.push(req));
718
757
  }
758
+ /**
759
+ * A checkout's `origin`, from this object's storage. Starts no container and
760
+ * moves no deadline, like {@link checkoutDir}: a pull request polled for its
761
+ * review is not the workspace in use.
762
+ */
763
+ async gitOrigin(dir) {
764
+ return await this.#gitHost.origin(dir);
765
+ }
719
766
  /**
720
767
  * Bring the workspace up to date, or say why git must not run.
721
768
  *
@@ -725,7 +772,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
725
772
  * did not finish, the workspace still holds the tree as it was before the
726
773
  * commit — and isomorphic-git, running here, would push a branch that does not
727
774
  * include it. That is the one failure worth stopping for: the push reports
728
- * success, the forge shows an older tree, and nothing in either account says
775
+ * success, GitHub shows an older tree, and nothing in either account says
729
776
  * why.
730
777
  *
731
778
  * Refused rather than attempted, because every alternative is worse. Pushing
@@ -793,7 +840,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
793
840
  *
794
841
  * Every entry point calls this, which is what makes the idle clock measure
795
842
  * *use* rather than "when the agent last said this name". The agent hands a
796
- * workspace name to a subagent once and then never sees the traffic; the
843
+ * workspace name to a sub-agent once and then never sees the traffic; the
797
844
  * workspace sees all of it.
798
845
  */
799
846
  async #touch() {
@@ -816,6 +863,117 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
816
863
  await this.#idleReclaim.set(new Date(now + IDLE_RECLAIM_MS));
817
864
  await this.#containerIdle.set(new Date(now + this.#containerIdleMs));
818
865
  }
866
+ /**
867
+ * Stop the container, and **keep everything on disk**.
868
+ *
869
+ * The counterpart to {@link reclaimIfIdle}, and the difference is the whole
870
+ * reason both exist. A reclaim is for a workspace nobody wants again: it empties
871
+ * storage, so the checkout and the dependency tree go with the container. This is
872
+ * for a workspace that will be worked in again — the next task on the same
873
+ * repository has to skip the clone and the install, which is the cost the whole
874
+ * design is arranged around.
875
+ *
876
+ * **Why a caller needs this at all.** The idle deadline would eventually do it,
877
+ * but it cannot be tuned down to meet a cost target: `containerIdleMs` must
878
+ * exceed the longest command the agent allows, and a container that outlives its
879
+ * work by that much bills for the difference. So a host that knows the work is
880
+ * finished says so, and the deadline goes back to being a backstop for the case
881
+ * where nothing got to say anything.
882
+ *
883
+ * Deliberately does **not** touch: `#touch()` would push the very deadline this
884
+ * is standing in for.
885
+ *
886
+ * `released: false` means the container is still up and the standing deadline
887
+ * still owns it — an install is running, or a pull is still moving blocks. Both
888
+ * resolve themselves: the drain re-arms the deadline to come back and finish,
889
+ * which is the same path `#onContainerIdle` takes. Under a {@link hold} the
890
+ * release is deferred instead, to the last `unhold`.
891
+ */
892
+ async releaseContainer() {
893
+ // Work this object cannot see. Deferred rather than refused: the last
894
+ // `unhold` releases, unless the workspace is used again first.
895
+ const holds = await this.#liveHolds();
896
+ if (holds.length > 0) {
897
+ await this.ctx.storage.put(RELEASE_ASKED_KEY, Date.now());
898
+ console.info(`[${this.#tag}] release deferred while held`, {
899
+ id: this.ctx.id.toString(),
900
+ holds
901
+ });
902
+ return { released: false };
903
+ }
904
+ await this.ctx.storage.delete(RELEASE_ASKED_KEY);
905
+ // An install in flight is "in use" even though nothing has called in.
906
+ // Same rule, and same reason, as `#onContainerIdle`.
907
+ const install = await this.#install.read();
908
+ if (install.state === "running")
909
+ return { released: false };
910
+ // Never stop a container with a pull still moving: that is what turns an
911
+ // outstanding write from late into lost. `#drainBeforeStop` carries the
912
+ // reasoning and re-arms the deadline when it defers.
913
+ const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
914
+ if (await this.#drainBeforeStop(lastUsedAt, 0))
915
+ return { released: false };
916
+ // Dropped rather than left standing: the container is about to be gone, and an
917
+ // alarm that fires afterwards logs a stop against a container that is not
918
+ // running. `#touch` re-arms it the next time this workspace is used.
919
+ await this.#containerIdle.clear();
920
+ await this.#stopContainer("idle");
921
+ return { released: true };
922
+ }
923
+ /**
924
+ * Keep the container up for work this object cannot see, until `untilMs` or
925
+ * {@link unhold}.
926
+ *
927
+ * A Claude Code session a host starts here runs detached: nothing calls in
928
+ * while it works, and the runtime cannot list
929
+ * what is running. So a host done with the workspace cannot tell that the
930
+ * container is not, and {@link releaseContainer} would stop the session with
931
+ * it. A hold is the host saying so. It defers a release; it is not use, so it
932
+ * does not touch, and the idle deadline already outlasts a session.
933
+ *
934
+ * `untilMs` bounds a hold whose `unhold` never comes — a run cut between its
935
+ * start and its end. Past it the hold is ignored.
936
+ */
937
+ async hold(key, untilMs) {
938
+ const holds = await this.#holdMap();
939
+ holds[key] = untilMs;
940
+ await this.ctx.storage.put(HOLDS_KEY, holds);
941
+ }
942
+ /**
943
+ * The work a {@link hold} stood for is over. When it was the last hold, a
944
+ * release asked for while it stood happens now — unless the workspace was
945
+ * used since, in which case the next release is asked for by whoever used it.
946
+ */
947
+ async unhold(key) {
948
+ // Read and written with no other I/O between, so the object's input gate
949
+ // keeps another call from interleaving: the map written is the one decided on.
950
+ const holds = await this.#holdMap();
951
+ delete holds[key];
952
+ await this.ctx.storage.put(HOLDS_KEY, holds);
953
+ if (Object.keys(holds).length > 0)
954
+ return { released: false };
955
+ const asked = await this.ctx.storage.get(RELEASE_ASKED_KEY);
956
+ if (asked === undefined)
957
+ return { released: false };
958
+ // A use in the same millisecond as the ask counts as after it: wrongly
959
+ // keeping a container costs its idle window, wrongly stopping one a session.
960
+ const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
961
+ if (lastUsedAt >= asked) {
962
+ await this.ctx.storage.delete(RELEASE_ASKED_KEY);
963
+ return { released: false };
964
+ }
965
+ return await this.releaseContainer();
966
+ }
967
+ /** Every hold, lapsed ones pruned. */
968
+ async #holdMap() {
969
+ const now = Date.now();
970
+ const stored = (await this.ctx.storage.get(HOLDS_KEY)) ?? {};
971
+ return Object.fromEntries(Object.entries(stored).filter(([, until]) => until > now));
972
+ }
973
+ /** The keys of the holds still standing. */
974
+ async #liveHolds() {
975
+ return Object.keys(await this.#holdMap());
976
+ }
819
977
  /**
820
978
  * Throw this workspace away if nothing has touched it for `maxIdleMs`.
821
979
  *
@@ -883,6 +1041,10 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
883
1041
  reason,
884
1042
  running: this.ctx.container?.running ?? false
885
1043
  });
1044
+ // Hang up the session to computerd first: a destroy that cuts it live
1045
+ // surfaces as an uncaught `Network connection lost`. The workspace
1046
+ // reconnects on its next use, and `close()` swallows its own failures.
1047
+ await this.#workspaceMemo?.close();
886
1048
  try {
887
1049
  await this.ctx.container?.destroy();
888
1050
  }
@@ -1013,7 +1175,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
1013
1175
  }
1014
1176
  catch (err) {
1015
1177
  // A read of local SQLite that threw says nothing about the tree. Treat it
1016
- // as absent: refusing a delegation costs a round, starting a session in a
1178
+ // as absent: refusing a delegation costs a tool call, starting a session in a
1017
1179
  // directory that may not exist costs the run.
1018
1180
  console.warn(`[${this.#tag}] could not probe a checkout`, {
1019
1181
  id: this.ctx.id.toString(),
@@ -1025,7 +1187,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
1025
1187
  }
1026
1188
  /**
1027
1189
  * Everything currently true about this workspace that a caller must not assume
1028
- * away — the array `sb_exec`, `sb_write` and `sb_edit` all consult.
1190
+ * away — the array `bash`, `edit` and the workspace's writes all consult.
1029
1191
  *
1030
1192
  * The policy is not here. `deriveAdvisories` decides which facts matter and
1031
1193
  * how they are worded; this method gathers what only the object can see and
@@ -1161,7 +1323,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
1161
1323
  *
1162
1324
  * **Swallows its own failure.** Every other wake here is work somebody waits
1163
1325
  * for; this one is early, so a container that will not start must cost the
1164
- * next `sb_exec` a start rather than put the scheduler's retry ladder behind a
1326
+ * next `bash` a start rather than put the scheduler's retry ladder behind a
1165
1327
  * deployment fault that will refuse for as long as it stands.
1166
1328
  */
1167
1329
  async #onContainerWarm() {
@@ -1191,6 +1353,13 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
1191
1353
  await this.#containerIdle.set(new Date(Date.now() + this.#containerIdleMs));
1192
1354
  return;
1193
1355
  }
1356
+ // Work nothing calls in for, which the deadline cannot measure: come back
1357
+ // when the first hold lapses, without counting the hold as use.
1358
+ const lapses = Object.values(await this.#holdMap());
1359
+ if (lapses.length > 0) {
1360
+ await this.#containerIdle.set(new Date(Math.min(...lapses)));
1361
+ return;
1362
+ }
1194
1363
  // Re-read the clock rather than trusting the alarm, the same way
1195
1364
  // `reclaimIfIdle` does. `#touch` moves the deadline forward, but an alarm
1196
1365
  // already in flight cannot be recalled — so without this a workspace that
@@ -0,0 +1,95 @@
1
+ import type { WorkspaceClient, WorkspaceStub } from "@cloudflare/computer";
2
+ import type { WorkspaceAdvisory } from "./advisory.js";
3
+ /**
4
+ * Reaching a workspace: which one a call names, and the two ways to open it.
5
+ *
6
+ * A leaf, so the object, `/computer`'s tools and its proxy in
7
+ * `../computer/proxy.ts` can import it without
8
+ * going through the barrel that re-exports them.
9
+ */
10
+ /**
11
+ * Where a parent puts the workspace name so its sub-agents reach the same one.
12
+ *
13
+ * A sub-agent cannot compute this itself. The name is derived from the caller
14
+ * and the repository, and the checkout it should work in is one its parent
15
+ * chose — so a sub-agent that named its own would land beside the parent's
16
+ * checkout, in a workspace with nothing in it.
17
+ *
18
+ * A spec's `prepare` runs on the parent, where the name resolves, and its
19
+ * return value reaches the sub-agent's plugins as `runtime()`. That is the
20
+ * channel core built for exactly this, and it is emphatically *not* the
21
+ * sub-agent's input, which the parent's model writes — a workspace name there
22
+ * would be model-authored, and a model naming another caller's workspace would
23
+ * get that caller's files.
24
+ */
25
+ export declare const WORKSPACE_RUNTIME_KEY = "workspaceName";
26
+ /**
27
+ * Read a parent-resolved workspace name off a sub-agent's `runtime()`.
28
+ *
29
+ * Returns `undefined` rather than throwing on anything unexpected: a parent has
30
+ * no runtime at all, and falling back to the configured thunk is right there.
31
+ */
32
+ export declare function workspaceNameFromRuntime(runtime: unknown): string | undefined;
33
+ /**
34
+ * The Durable Object the workspace lives in, as this plugin needs to see it.
35
+ *
36
+ * Structural rather than imported: the class is the host's — it owns the
37
+ * container binding, the alarm and the install job — and a plugin that imported
38
+ * it would stop a second host from bringing its own. `__getWorkspaceStub` is the
39
+ * one method `getWorkspace()` calls across the boundary, and it is what
40
+ * `withWorkspace` installs (or what a host that constructs `Workspace` itself
41
+ * reimplements, which is the same three lines).
42
+ */
43
+ export interface WorkspaceHost extends Rpc.DurableObjectBranded {
44
+ __getWorkspaceStub(): Promise<WorkspaceStub>;
45
+ /**
46
+ * The same workspace for filesystem-only work, which the host must serve
47
+ * **without starting a container**.
48
+ *
49
+ * The filesystem is the Durable Object's own SQLite, while the first command
50
+ * in a fresh container waits for the whole tree to be pushed into it — so a
51
+ * file tool served the other way waits minutes for a local `readdir`. See
52
+ * `WorkspaceObjectBase.__getWorkspaceFsStub` in `./object.ts`.
53
+ *
54
+ * Required rather than optional, for the reason {@link advisories} gives. A
55
+ * host with nothing to distinguish returns its own `__getWorkspaceStub()`.
56
+ */
57
+ __getWorkspaceFsStub(): Promise<WorkspaceStub>;
58
+ /**
59
+ * Everything currently true about the workspace that a caller must not assume
60
+ * away — see {@link file://./advisory.ts}. An empty array is the good case.
61
+ *
62
+ * Required rather than optional, and not only because Workers RPC types an
63
+ * optional method as a union nothing can call. A host with nothing to report
64
+ * returns `[]` in one line; a host that forgot to expose it gets a compile
65
+ * error instead of a `bash` that silently runs against a half-built
66
+ * `node_modules`, or against a workspace whose writes are being dropped.
67
+ *
68
+ * `deriveAdvisories` builds the array from what the host already knows, so
69
+ * implementing this is gathering what the host has rather than writing policy.
70
+ */
71
+ advisories(): Promise<readonly WorkspaceAdvisory[]>;
72
+ }
73
+ /**
74
+ * Open the workspace behind a Durable Object stub.
75
+ *
76
+ * The cast is unavoidable, and narrow enough to be worth isolating here rather
77
+ * than repeating. `getWorkspace` wants a handle whose `__getWorkspaceStub()`
78
+ * resolves to a `WorkspaceStub` — the concrete class, private fields and all.
79
+ * Across a Durable Object boundary Workers RPC hands back a structural
80
+ * `Stub<WorkspaceStub>`, which forwards every method faithfully but carries none
81
+ * of the class's private brand: runtime-compatible, type-incompatible. This is
82
+ * the only place in the plugin that gap is crossed.
83
+ */
84
+ export declare function openWorkspace(host: DurableObjectStub<WorkspaceHost>): Promise<WorkspaceClient>;
85
+ /**
86
+ * The same workspace, opened for filesystem work alone.
87
+ *
88
+ * `getWorkspace` calls exactly one method on what it is handed, so the host is
89
+ * wrapped in an object answering it from the other side of the seam — see
90
+ * {@link WorkspaceHost.__getWorkspaceFsStub}. Everything past that is identical.
91
+ *
92
+ * **Only the file tools take this.** `bash` and `workspaceExec` need the
93
+ * container started and its CA installed, so they open it the other way.
94
+ */
95
+ export declare function openWorkspaceFs(host: DurableObjectStub<WorkspaceHost>): Promise<WorkspaceClient>;
@@ -0,0 +1,63 @@
1
+ import { getWorkspace } from "@cloudflare/computer";
2
+ /**
3
+ * Reaching a workspace: which one a call names, and the two ways to open it.
4
+ *
5
+ * A leaf, so the object, `/computer`'s tools and its proxy in
6
+ * `../computer/proxy.ts` can import it without
7
+ * going through the barrel that re-exports them.
8
+ */
9
+ /**
10
+ * Where a parent puts the workspace name so its sub-agents reach the same one.
11
+ *
12
+ * A sub-agent cannot compute this itself. The name is derived from the caller
13
+ * and the repository, and the checkout it should work in is one its parent
14
+ * chose — so a sub-agent that named its own would land beside the parent's
15
+ * checkout, in a workspace with nothing in it.
16
+ *
17
+ * A spec's `prepare` runs on the parent, where the name resolves, and its
18
+ * return value reaches the sub-agent's plugins as `runtime()`. That is the
19
+ * channel core built for exactly this, and it is emphatically *not* the
20
+ * sub-agent's input, which the parent's model writes — a workspace name there
21
+ * would be model-authored, and a model naming another caller's workspace would
22
+ * get that caller's files.
23
+ */
24
+ export const WORKSPACE_RUNTIME_KEY = "workspaceName";
25
+ /**
26
+ * Read a parent-resolved workspace name off a sub-agent's `runtime()`.
27
+ *
28
+ * Returns `undefined` rather than throwing on anything unexpected: a parent has
29
+ * no runtime at all, and falling back to the configured thunk is right there.
30
+ */
31
+ export function workspaceNameFromRuntime(runtime) {
32
+ const value = runtime?.[WORKSPACE_RUNTIME_KEY];
33
+ return typeof value === "string" && value.length > 0 ? value : undefined;
34
+ }
35
+ /**
36
+ * Open the workspace behind a Durable Object stub.
37
+ *
38
+ * The cast is unavoidable, and narrow enough to be worth isolating here rather
39
+ * than repeating. `getWorkspace` wants a handle whose `__getWorkspaceStub()`
40
+ * resolves to a `WorkspaceStub` — the concrete class, private fields and all.
41
+ * Across a Durable Object boundary Workers RPC hands back a structural
42
+ * `Stub<WorkspaceStub>`, which forwards every method faithfully but carries none
43
+ * of the class's private brand: runtime-compatible, type-incompatible. This is
44
+ * the only place in the plugin that gap is crossed.
45
+ */
46
+ export function openWorkspace(host) {
47
+ return getWorkspace(host);
48
+ }
49
+ /**
50
+ * The same workspace, opened for filesystem work alone.
51
+ *
52
+ * `getWorkspace` calls exactly one method on what it is handed, so the host is
53
+ * wrapped in an object answering it from the other side of the seam — see
54
+ * {@link WorkspaceHost.__getWorkspaceFsStub}. Everything past that is identical.
55
+ *
56
+ * **Only the file tools take this.** `bash` and `workspaceExec` need the
57
+ * container started and its CA installed, so they open it the other way.
58
+ */
59
+ export function openWorkspaceFs(host) {
60
+ return getWorkspace({
61
+ __getWorkspaceStub: () => host.__getWorkspaceFsStub()
62
+ });
63
+ }