@intentius/chant-lexicon-fountain 0.89.0 → 0.90.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Box — a persistent machine that serves an app repo and its tools (#2705).
3
+ *
4
+ * A box is an `Environment` whose repositories hold the app repo and whose
5
+ * `setup_script` provisions the machine (for an arugula studio box, the
6
+ * studio's `box/provision-template.sh`), an `Agent` on a persistent sandbox
7
+ * provisioned from it, and optionally a `Vault` for the secrets every box of
8
+ * this kind shares.
9
+ *
10
+ * ```ts
11
+ * export const { environment, agent, vault } = Box({
12
+ * name: "studio-box",
13
+ * repo: { url: "https://github.com/arugula-salad/studio", ref: "main" },
14
+ * setupScript: readFileSync("box/provision-template.sh", "utf8"),
15
+ * permissionPolicy: { default: "auto_allow" },
16
+ * allowedHosts: ["registry.npmjs.org", "github.com"],
17
+ * vault: { secrets: [{ key: "STUDIO_SECRET", value: process.env.STUDIO_SECRET! }] },
18
+ * });
19
+ * ```
20
+ *
21
+ * Beside `Steward`: a steward is an environment's one writer, an `acp` agent
22
+ * whose every turn is a chant command line, bound to a teammate with a
23
+ * standing thread and schedules. A box is where people and their agents work
24
+ * on an app: a conversational runtime (`claude` by default), the app repo
25
+ * cloned in, the provisioning script run as setup, and one served port. It
26
+ * declares no teammate and no schedule, and nothing here starts a
27
+ * conversation. Starting and reaping a box stays a runtime act, as it is in
28
+ * hud's box runtime.
29
+ *
30
+ * The defaults are the closed ones, as `ConciergeStack`'s are, and loosening
31
+ * one is a visible parameter: networking is `limited` with an empty allowlist
32
+ * until `allowedHosts` names hosts or `unrestrictedNetworking: true` opens it
33
+ * (which FTN011 then warns about); `allowed_vault_ids` holds the box's own
34
+ * vault, or nothing, until `allowedVaults` widens it. `permission_policy` has
35
+ * no default at all, because fountain's unset policy is `auto_allow` and that
36
+ * should be a choice someone wrote down.
37
+ *
38
+ * ## The port
39
+ *
40
+ * fountain's `Environment` and `Agent` have no field for a served port (the
41
+ * pinned spec, v0.21.0). The port is recorded in the metadata of both, under
42
+ * `box-port`, and returned as `port`, so whatever builds the box's URL reads
43
+ * it from the declaration or from fountain's own record without a guess.
44
+ */
45
+ import { Agent, Environment, Vault } from "../generated/index.js";
46
+ /** The metadata key a box's served port is recorded under, on its Environment and Agent. */
47
+ export declare const BOX_PORT_METADATA_KEY = "box-port";
48
+ /** The port a box serves when none is given: the door's. */
49
+ export declare const BOX_DEFAULT_PORT = 8080;
50
+ /** Where the app repo is cloned when no `mountPath` is given. */
51
+ export declare const BOX_DEFAULT_MOUNT_PATH = "/workspace/app";
52
+ /** A permission verdict, or a number of seconds under the `ask_timeout` key. */
53
+ export type BoxPermissionPolicy = Record<string, "ask" | "auto_allow" | "auto_deny" | number>;
54
+ /** The app repo the box serves. Shape of fountain's `Repository`. */
55
+ export interface BoxRepositoryOpts {
56
+ /** https clone url. */
57
+ url: string;
58
+ /** Absolute path the repo is cloned to. Default `/workspace/app`. */
59
+ mountPath?: string;
60
+ /** Branch or tag. The default branch when omitted. */
61
+ ref?: string;
62
+ /** Name of the secret holding a clone token. Required for a private repo. */
63
+ secretKey?: string;
64
+ }
65
+ /** The secrets every box of this kind shares, held in a Vault of the box's own. */
66
+ export interface BoxVaultOpts {
67
+ /** Vault name. Default `<name>-secrets`. */
68
+ name?: string;
69
+ description?: string;
70
+ /** Written at apply. A reference that resolves at build, never a literal (FTN001). */
71
+ secrets?: {
72
+ key: string;
73
+ value: string;
74
+ }[];
75
+ }
76
+ export interface BoxOpts {
77
+ /** The Agent's name. The Environment is `<name>-env`. */
78
+ name: string;
79
+ /** The app repo the box serves, cloned into the sandbox before setup runs. */
80
+ repo: BoxRepositoryOpts;
81
+ /** The provisioning script's text, run as the Environment's `setup_script`. */
82
+ setupScript: string;
83
+ /** Setup exec timeout, 1 to 900 seconds (FTN024). fountain's default is 120. */
84
+ setupTimeoutSeconds?: number;
85
+ /** Agent runtime. Default `claude`. */
86
+ runtime?: "claude" | "codex" | "gemini" | "opencode";
87
+ /** Canonical provider/model_id. Omitted, fountain's default for the runtime. */
88
+ model?: string;
89
+ /** Per-tool permission policy. Required: fountain's unset policy is `auto_allow`. */
90
+ permissionPolicy: BoxPermissionPolicy;
91
+ /** Environment packages, passed through. */
92
+ packages?: Record<string, unknown>;
93
+ /** Environment variables, passed through. Not for secrets: use `vault`. */
94
+ envVars?: Record<string, string>;
95
+ /** Egress allowlist under `limited` networking. Default [], deny-all. */
96
+ allowedHosts?: string[];
97
+ /** Open the sandbox's network. FTN011 warns on it. Refused with `allowedHosts`. */
98
+ unrestrictedNetworking?: boolean;
99
+ /** Declare a Vault for the box's shared secrets. */
100
+ vault?: BoxVaultOpts;
101
+ /**
102
+ * Vaults a conversation may attach. Default: the box's own vault, or none.
103
+ * `"any"` leaves the list unset, which fountain reads as any vault the
104
+ * tenant owns: what a runtime that makes a vault per box (hud's) needs.
105
+ */
106
+ allowedVaults?: "any" | Array<InstanceType<typeof Vault> | string>;
107
+ /** The port the box serves. Default 8080, the door's. Recorded as `box-port` metadata. */
108
+ port?: number;
109
+ system?: string;
110
+ skills?: Array<Record<string, unknown>>;
111
+ mcpServers?: Record<string, unknown>;
112
+ /** Extra metadata, merged over the ownership marker on every resource the box declares. */
113
+ metadata?: Record<string, unknown>;
114
+ }
115
+ export interface BoxResources {
116
+ environment: InstanceType<typeof Environment>;
117
+ agent: InstanceType<typeof Agent>;
118
+ vault?: InstanceType<typeof Vault>;
119
+ /** The served port, as recorded under `box-port`. */
120
+ port: number;
121
+ }
122
+ export declare function Box(opts: BoxOpts): BoxResources;
123
+ //# sourceMappingURL=box.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"box.d.ts","sourceRoot":"","sources":["../../src/composites/box.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE/D,4FAA4F;AAC5F,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC,iEAAiE;AACjE,eAAO,MAAM,sBAAsB,mBAAmB,CAAC;AAEvD,gFAAgF;AAChF,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,GAAG,YAAY,GAAG,WAAW,GAAG,MAAM,CAAC,CAAC;AAE9F,qEAAqE;AACrE,MAAM,WAAW,iBAAiB;IAChC,uBAAuB;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,mFAAmF;AACnF,MAAM,WAAW,YAAY;IAC3B,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sFAAsF;IACtF,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5C;AAED,MAAM,WAAW,OAAO;IACtB,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,IAAI,EAAE,iBAAiB,CAAC;IACxB,+EAA+E;IAC/E,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,uCAAuC;IACvC,OAAO,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAC;IACrD,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,gBAAgB,EAAE,mBAAmB,CAAC;IACtC,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,mFAAmF;IACnF,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,oDAAoD;IACpD,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,YAAY,CAAC,OAAO,KAAK,CAAC,GAAG,MAAM,CAAC,CAAC;IACnE,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,2FAA2F;IAC3F,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,YAAY,CAAC,OAAO,WAAW,CAAC,CAAC;IAC9C,KAAK,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IAClC,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IACnC,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,GAAG,CAAC,IAAI,EAAE,OAAO,GAAG,YAAY,CAoE/C"}
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export { ConciergeStack } from "./composites/concierge-stack.js";
14
14
  export type { ConciergeStackOpts, ConciergeStackResources } from "./composites/concierge-stack.js";
15
15
  export { Steward, stewardForOp, STEWARD_RUNTIME_COMMAND } from "./composites/steward.js";
16
16
  export type { StewardOp, StewardOpts, StewardResources, StewardWebhookOpts } from "./composites/steward.js";
17
+ export { Box, BOX_PORT_METADATA_KEY, BOX_DEFAULT_PORT, BOX_DEFAULT_MOUNT_PATH } from "./composites/box.js";
18
+ export type { BoxOpts, BoxResources, BoxRepositoryOpts, BoxVaultOpts, BoxPermissionPolicy } from "./composites/box.js";
17
19
  export { acpCommandGroup } from "./acp/index.js";
18
20
  export { AcpServer } from "./acp/server.js";
19
21
  export type { AcpServerOptions } from "./acp/server.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIlD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAC;AAC9D,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EACL,8BAA8B,EAC9B,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAG9B,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAInH,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAChE,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhE,OAAO,EACL,uBAAuB,EACvB,kBAAkB,EAClB,8BAA8B,GAC/B,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,wBAAwB,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAChG,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACtF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAIzG,OAAO,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC7C,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIlD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAC;AAC9D,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EACL,8BAA8B,EAC9B,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAG9B,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAInH,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAChE,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhE,OAAO,EACL,uBAAuB,EACvB,kBAAkB,EAClB,8BAA8B,GAC/B,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,wBAAwB,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAChG,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACtF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AACzG,OAAO,EAAE,GAAG,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AACxG,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAIpH,OAAO,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC7C,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "33090e28ff6ca41325918fd9fc2c7ceb354679308b1634834a6098b0fc03e364",
4
+ "manifest.json": "574b2bc5f0f9b2c0125e34c21d47fbdc1f154e558f9ff61e585a41b0c2c913da",
5
5
  "meta.json": "3301437a2a56eaf1a5cc8f6eabb18a40767f916e09701b2d9bf20aba4066738c",
6
6
  "types/index.d.ts": "cf6b634fe0a8e9e96f1d9d07e3df53838def2683dc93dbe3c7fd971c6d3908ae",
7
7
  "rules/ftn001-no-secret-literals.ts": "a417b194cd7039911cca9a0f14c49b042dfa6b1ee48517f7c2e22b16590a8599",
@@ -21,7 +21,7 @@
21
21
  "skills/chant-fountain.md": "906a312756a02c88de634915f63ab4dfe1b1078ff4d8cfb4b6dc97eec14bc237",
22
22
  "skills/chant-fountain-secrets.md": "40aa847d62547581bebdde630b54796c4e1cbf498869392e0d396d96a75c0ab6",
23
23
  "skills/chant-fountain-ops.md": "4e72f0a0c7477a9e98f85ea45e49ba69e8a699f9c13ba0f0cf1cb20a88151373",
24
- "skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
24
+ "skills/chant-fountain-locked-sandboxes.md": "14def4c68b13c2bd561f75819b188968cec85073a5dd8b59e91708e1af26daae"
25
25
  },
26
- "composite": "916a499797c8130a9fdcba5f55f63449f67fee870a5b93466b7b00f7515fb5a9"
26
+ "composite": "21bc34705dabf0dfe5a50d7bb81bf76d54cf4647b5f5790cd66e54de1d4f9a5f"
27
27
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fountain",
6
6
  "specVersion": "v0.21.0"
@@ -1,15 +1,62 @@
1
1
  /**
2
2
  * fountainRun — start a conversation from a declared Agent and follow it
3
- * to a terminal status.
3
+ * to the end of its turn.
4
4
  *
5
5
  * Conversations are runs, not declarables (the one fountain kind the
6
6
  * lexicon deliberately does not model as a resource). This op is the
7
- * imperative half: resolve the agent by name, POST the conversation,
8
- * poll until `completed | failed | timed_out | terminated`, and terminate
9
- * on deadline so a hung run never outlives the op that started it.
7
+ * imperative half: resolve the agent by name, POST the conversation, and
8
+ * poll until the turn is done.
9
+ *
10
+ * What "done" means depends on the agent's `sandbox_mode` (#2718). An
11
+ * `ephemeral` conversation's machine is its own and this polls the
12
+ * conversation's own status to `completed | failed | timed_out |
13
+ * terminated` — none of which a running server's conversation status
14
+ * actually reaches on its own, so in practice this waits out `timeoutMs`
15
+ * and terminates, as it always has. A `persistent` agent's conversation
16
+ * (`Steward`, and `Box` from #2705) shares one machine across every
17
+ * conversation on it, and a turn ending there does not tear the sandbox
18
+ * down: the conversation settles on `idle`, not on any of the four
19
+ * statuses above. Waiting for one of those on a persistent agent is the
20
+ * bug this op used to have — it waited out the whole deadline after the
21
+ * turn had already finished, then terminated a machine that was meant to
22
+ * persist. For `idle`, this instead reads the finished turn back
23
+ * (`GET .../turns`) and returns its own outcome (`completed | failed |
24
+ * interrupted` — fountain's `end_turn` block, or an error) without
25
+ * terminating anything.
26
+ *
27
+ * Terminating the conversation's machine on deadline (a hung run should
28
+ * not outlive the op that started it) still happens whenever the turn has
29
+ * not ended — for either sandbox mode. `terminate` makes the choice
30
+ * explicit rather than leaving it implicit in `sandbox_mode`.
10
31
  */
11
32
  import { type FountainHttp, type FountainConnectionDeps } from "./fountain-apply.js";
33
+ /**
34
+ * Conversation statuses that end an *ephemeral* agent's run. `completed`
35
+ * and `timed_out` are not values fountain's `Conversation.status` takes
36
+ * today (its enum is `pending | running | idle | failed | terminated`) —
37
+ * they are kept so a server that starts reporting them is handled without
38
+ * a further change here.
39
+ */
12
40
  export declare const TERMINAL_STATUSES: Set<string>;
41
+ /**
42
+ * Conversation statuses that end a *persistent* agent's run: everything
43
+ * {@link TERMINAL_STATUSES} does, plus `idle` — the status a persistent
44
+ * conversation settles on once its turn ends, machine still up (#2718).
45
+ */
46
+ export declare const PERSISTENT_DONE_STATUSES: Set<string>;
47
+ /**
48
+ * How `fountainRun` treats the conversation's machine once its wait ends
49
+ * (#2718).
50
+ *
51
+ * - `on-deadline` — terminate only when `timeoutMs` is hit with the turn
52
+ * still unfinished, never on a turn that ended cleanly. This was
53
+ * `fountainRun`'s only behavior before this option existed.
54
+ * - `never` — never terminate, even past the deadline; the caller, or
55
+ * fountain's own idle/max-lifetime bounds, is responsible for the machine.
56
+ * - `always` — terminate whenever the wait ends, whether the turn ended
57
+ * cleanly or the deadline fired.
58
+ */
59
+ export type TerminatePolicy = "never" | "on-deadline" | "always";
13
60
  export interface FountainRunArgs {
14
61
  /** Agent name (resolved against /api/agents) or a raw agent id. */
15
62
  agent: string;
@@ -26,19 +73,43 @@ export interface FountainRunArgs {
26
73
  profile?: string;
27
74
  /** Project root `chant.config.ts` is read from. Default: process.cwd(). */
28
75
  cwd?: string;
29
- /** Give up (and terminate the conversation) after this long. Default 10 min. */
76
+ /** Give up after this long. Default 10 min. */
30
77
  timeoutMs?: number;
31
78
  /** Poll interval. Default 5s. */
32
79
  pollMs?: number;
80
+ /**
81
+ * When to terminate the conversation's machine (#2718). Default follows
82
+ * the agent's `sandbox_mode`: `on-deadline` for `ephemeral` (unchanged),
83
+ * `never` for `persistent` — its machine is a home meant to outlive the
84
+ * conversation, so a run that hits its deadline leaves it be unless this
85
+ * says otherwise.
86
+ */
87
+ terminate?: TerminatePolicy;
33
88
  /** Injectable clock/sleep for tests. */
34
89
  sleep?: (ms: number) => Promise<void>;
35
90
  }
36
91
  export interface FountainRunResult {
37
92
  conversationId: string;
93
+ /**
94
+ * The run's outcome. For a persistent agent whose turn ended, this is the
95
+ * turn's own status (`completed | failed | interrupted`); otherwise it is
96
+ * the conversation's status when the wait ended (`terminated` on a
97
+ * deadline).
98
+ */
38
99
  status: string;
39
- /** True when the op hit its deadline and terminated the conversation. */
100
+ /** True when the agent's `sandbox_mode` resolved to `persistent`. */
101
+ persistent: boolean;
102
+ /** True when the op hit its deadline with the turn still unfinished. */
40
103
  terminatedByDeadline: boolean;
41
104
  }
105
+ export interface ResolvedAgent {
106
+ id: string;
107
+ /** `ephemeral` or `persistent`; fountain's own default is `ephemeral`. */
108
+ sandboxMode: string;
109
+ }
110
+ /** Resolve an agent by name or id, along with its `sandbox_mode`. */
111
+ export declare function resolveAgent(http: FountainHttp, agent: string): Promise<ResolvedAgent>;
112
+ /** Agent id only — the common case, and the one call sites outside this file use. */
42
113
  export declare function resolveAgentId(http: FountainHttp, agent: string): Promise<string>;
43
114
  export declare function fountainRun(args: FountainRunArgs, http?: FountainHttp, deps?: FountainConnectionDeps): Promise<FountainRunResult>;
44
115
  //# sourceMappingURL=fountain-run.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fountain-run.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAE1B,eAAO,MAAM,iBAAiB,aAA8D,CAAC;AAE7F,MAAM,WAAW,eAAe;IAC9B,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,iBAAiB;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAID,wBAAsB,cAAc,CAAC,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQvF;AAED,wBAAsB,WAAW,CAC/B,IAAI,EAAE,eAAe,EACrB,IAAI,CAAC,EAAE,YAAY,EACnB,IAAI,CAAC,EAAE,sBAAsB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CAuC5B"}
1
+ {"version":3,"file":"fountain-run.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAE1B;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,aAA8D,CAAC;AAE7F;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,aAA0C,CAAC;AAKhF;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,wCAAwC;IACxC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,iBAAiB;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,UAAU,EAAE,OAAO,CAAC;IACpB,wEAAwE;IACxE,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAID,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,qEAAqE;AACrE,wBAAsB,YAAY,CAAC,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAe5F;AAED,qFAAqF;AACrF,wBAAsB,cAAc,CAAC,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGvF;AAeD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,eAAe,EACrB,IAAI,CAAC,EAAE,YAAY,EACnB,IAAI,CAAC,EAAE,sBAAsB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CAwD5B"}
@@ -6,6 +6,6 @@
6
6
  */
7
7
  export { fountainApply, resolveEndpoint, resolveToken, resolveConnection, parseManifest, toApplyPayload, isChantOwned, defaultFountainHttp, DEFAULT_FOUNTAIN_BASE_URL, OWNERSHIP_KEY, OWNERSHIP_VALUE, } from "./fountain-apply.js";
8
8
  export type { FountainApplyArgs, FountainApplySummary, ManifestResource, FountainHttp, FountainConnectionDeps, } from "./fountain-apply.js";
9
- export { fountainRun, resolveAgentId, TERMINAL_STATUSES } from "./fountain-run.js";
10
- export type { FountainRunArgs, FountainRunResult } from "./fountain-run.js";
9
+ export { fountainRun, resolveAgent, resolveAgentId, TERMINAL_STATUSES, PERSISTENT_DONE_STATUSES, } from "./fountain-run.js";
10
+ export type { FountainRunArgs, FountainRunResult, ResolvedAgent, TerminatePolicy } from "./fountain-run.js";
11
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,mBAAmB,EACnB,yBAAyB,EACzB,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAChF,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,mBAAmB,EACnB,yBAAyB,EACzB,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,WAAW,EACX,YAAY,EACZ,cAAc,EACd,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}
@@ -31,4 +31,4 @@ The environment's config is the enforcement boundary, so watch it: `chant lifecy
31
31
 
32
32
  ## Running conversations
33
33
 
34
- Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault), polls to `completed | failed | timed_out`, and terminates at its deadline so a hung sandbox never outlives the op. Multi-turn interaction (follow-up prompts, interrupt) is fountain's own conversations API — keep chant to the lifecycle edges.
34
+ Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault), and waits for the turn to end. What "end" means, and what happens to the machine, follows the agent's `sandbox_mode`: an `ephemeral` agent's conversation is polled for a terminal status and terminated on deadline, same as before; a `persistent` agent's (a `Steward` or `Box`) conversation settles on `idle` once its turn ends — machine still up for the next turn — so `fountainRun` reads the turn back and returns its outcome (`completed | failed | interrupted`) without terminating anything, by default. Pass `terminate: "on-deadline" | "always"` to opt a persistent agent's run back into ending the machine. Multi-turn interaction (follow-up prompts, interrupt) is fountain's own conversations API — keep chant to the lifecycle edges.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "type": "module",
5
5
  "description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@
50
50
  "bundle": "tsx src/package-cli.ts"
51
51
  },
52
52
  "peerDependencies": {
53
- "@intentius/chant": "^0.89.0",
53
+ "@intentius/chant": "^0.90.0",
54
54
  "typescript": "^5.9.3",
55
55
  "zod": "^4.3.6"
56
56
  },
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Box — a persistent machine that serves an app repo and its tools (#2705).
3
+ *
4
+ * A box is an `Environment` whose repositories hold the app repo and whose
5
+ * `setup_script` provisions the machine (for an arugula studio box, the
6
+ * studio's `box/provision-template.sh`), an `Agent` on a persistent sandbox
7
+ * provisioned from it, and optionally a `Vault` for the secrets every box of
8
+ * this kind shares.
9
+ *
10
+ * ```ts
11
+ * export const { environment, agent, vault } = Box({
12
+ * name: "studio-box",
13
+ * repo: { url: "https://github.com/arugula-salad/studio", ref: "main" },
14
+ * setupScript: readFileSync("box/provision-template.sh", "utf8"),
15
+ * permissionPolicy: { default: "auto_allow" },
16
+ * allowedHosts: ["registry.npmjs.org", "github.com"],
17
+ * vault: { secrets: [{ key: "STUDIO_SECRET", value: process.env.STUDIO_SECRET! }] },
18
+ * });
19
+ * ```
20
+ *
21
+ * Beside `Steward`: a steward is an environment's one writer, an `acp` agent
22
+ * whose every turn is a chant command line, bound to a teammate with a
23
+ * standing thread and schedules. A box is where people and their agents work
24
+ * on an app: a conversational runtime (`claude` by default), the app repo
25
+ * cloned in, the provisioning script run as setup, and one served port. It
26
+ * declares no teammate and no schedule, and nothing here starts a
27
+ * conversation. Starting and reaping a box stays a runtime act, as it is in
28
+ * hud's box runtime.
29
+ *
30
+ * The defaults are the closed ones, as `ConciergeStack`'s are, and loosening
31
+ * one is a visible parameter: networking is `limited` with an empty allowlist
32
+ * until `allowedHosts` names hosts or `unrestrictedNetworking: true` opens it
33
+ * (which FTN011 then warns about); `allowed_vault_ids` holds the box's own
34
+ * vault, or nothing, until `allowedVaults` widens it. `permission_policy` has
35
+ * no default at all, because fountain's unset policy is `auto_allow` and that
36
+ * should be a choice someone wrote down.
37
+ *
38
+ * ## The port
39
+ *
40
+ * fountain's `Environment` and `Agent` have no field for a served port (the
41
+ * pinned spec, v0.21.0). The port is recorded in the metadata of both, under
42
+ * `box-port`, and returned as `port`, so whatever builds the box's URL reads
43
+ * it from the declaration or from fountain's own record without a guess.
44
+ */
45
+
46
+ import { Agent, Environment, Vault } from "../generated/index";
47
+
48
+ /** The metadata key a box's served port is recorded under, on its Environment and Agent. */
49
+ export const BOX_PORT_METADATA_KEY = "box-port";
50
+
51
+ /** The port a box serves when none is given: the door's. */
52
+ export const BOX_DEFAULT_PORT = 8080;
53
+
54
+ /** Where the app repo is cloned when no `mountPath` is given. */
55
+ export const BOX_DEFAULT_MOUNT_PATH = "/workspace/app";
56
+
57
+ /** A permission verdict, or a number of seconds under the `ask_timeout` key. */
58
+ export type BoxPermissionPolicy = Record<string, "ask" | "auto_allow" | "auto_deny" | number>;
59
+
60
+ /** The app repo the box serves. Shape of fountain's `Repository`. */
61
+ export interface BoxRepositoryOpts {
62
+ /** https clone url. */
63
+ url: string;
64
+ /** Absolute path the repo is cloned to. Default `/workspace/app`. */
65
+ mountPath?: string;
66
+ /** Branch or tag. The default branch when omitted. */
67
+ ref?: string;
68
+ /** Name of the secret holding a clone token. Required for a private repo. */
69
+ secretKey?: string;
70
+ }
71
+
72
+ /** The secrets every box of this kind shares, held in a Vault of the box's own. */
73
+ export interface BoxVaultOpts {
74
+ /** Vault name. Default `<name>-secrets`. */
75
+ name?: string;
76
+ description?: string;
77
+ /** Written at apply. A reference that resolves at build, never a literal (FTN001). */
78
+ secrets?: { key: string; value: string }[];
79
+ }
80
+
81
+ export interface BoxOpts {
82
+ /** The Agent's name. The Environment is `<name>-env`. */
83
+ name: string;
84
+ /** The app repo the box serves, cloned into the sandbox before setup runs. */
85
+ repo: BoxRepositoryOpts;
86
+ /** The provisioning script's text, run as the Environment's `setup_script`. */
87
+ setupScript: string;
88
+ /** Setup exec timeout, 1 to 900 seconds (FTN024). fountain's default is 120. */
89
+ setupTimeoutSeconds?: number;
90
+ /** Agent runtime. Default `claude`. */
91
+ runtime?: "claude" | "codex" | "gemini" | "opencode";
92
+ /** Canonical provider/model_id. Omitted, fountain's default for the runtime. */
93
+ model?: string;
94
+ /** Per-tool permission policy. Required: fountain's unset policy is `auto_allow`. */
95
+ permissionPolicy: BoxPermissionPolicy;
96
+ /** Environment packages, passed through. */
97
+ packages?: Record<string, unknown>;
98
+ /** Environment variables, passed through. Not for secrets: use `vault`. */
99
+ envVars?: Record<string, string>;
100
+ /** Egress allowlist under `limited` networking. Default [], deny-all. */
101
+ allowedHosts?: string[];
102
+ /** Open the sandbox's network. FTN011 warns on it. Refused with `allowedHosts`. */
103
+ unrestrictedNetworking?: boolean;
104
+ /** Declare a Vault for the box's shared secrets. */
105
+ vault?: BoxVaultOpts;
106
+ /**
107
+ * Vaults a conversation may attach. Default: the box's own vault, or none.
108
+ * `"any"` leaves the list unset, which fountain reads as any vault the
109
+ * tenant owns: what a runtime that makes a vault per box (hud's) needs.
110
+ */
111
+ allowedVaults?: "any" | Array<InstanceType<typeof Vault> | string>;
112
+ /** The port the box serves. Default 8080, the door's. Recorded as `box-port` metadata. */
113
+ port?: number;
114
+ system?: string;
115
+ skills?: Array<Record<string, unknown>>;
116
+ mcpServers?: Record<string, unknown>;
117
+ /** Extra metadata, merged over the ownership marker on every resource the box declares. */
118
+ metadata?: Record<string, unknown>;
119
+ }
120
+
121
+ export interface BoxResources {
122
+ environment: InstanceType<typeof Environment>;
123
+ agent: InstanceType<typeof Agent>;
124
+ vault?: InstanceType<typeof Vault>;
125
+ /** The served port, as recorded under `box-port`. */
126
+ port: number;
127
+ }
128
+
129
+ export function Box(opts: BoxOpts): BoxResources {
130
+ const port = opts.port ?? BOX_DEFAULT_PORT;
131
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
132
+ throw new Error(`Box "${opts.name}": port ${port} is not a TCP port (1 to 65535)`);
133
+ }
134
+ if (opts.unrestrictedNetworking && opts.allowedHosts !== undefined) {
135
+ throw new Error(
136
+ `Box "${opts.name}": allowedHosts and unrestrictedNetworking together — ` +
137
+ `an allowlist means nothing on an open network. Pass one.`,
138
+ );
139
+ }
140
+ if (!opts.setupScript.trim()) {
141
+ throw new Error(`Box "${opts.name}": setupScript is empty — a box is provisioned by its setup script`);
142
+ }
143
+
144
+ const owned = { "managed-by": "chant", ...(opts.metadata ?? {}) };
145
+ const metadata = { ...owned, [BOX_PORT_METADATA_KEY]: port };
146
+
147
+ const repository = {
148
+ url: opts.repo.url,
149
+ mount_path: opts.repo.mountPath ?? BOX_DEFAULT_MOUNT_PATH,
150
+ ...(opts.repo.ref !== undefined ? { ref: opts.repo.ref } : {}),
151
+ ...(opts.repo.secretKey !== undefined ? { secret_key: opts.repo.secretKey } : {}),
152
+ };
153
+
154
+ const environment = new Environment({
155
+ name: `${opts.name}-env`,
156
+ repositories: [repository],
157
+ setup_script: opts.setupScript,
158
+ ...(opts.setupTimeoutSeconds !== undefined ? { setup_timeout_seconds: opts.setupTimeoutSeconds } : {}),
159
+ ...(opts.packages ? { packages: opts.packages } : {}),
160
+ ...(opts.envVars ? { env_vars: opts.envVars } : {}),
161
+ ...(opts.unrestrictedNetworking
162
+ ? { networking_type: "unrestricted" as const }
163
+ : { networking_type: "limited" as const, networking_config: { allowed_hosts: opts.allowedHosts ?? [] } }),
164
+ metadata,
165
+ });
166
+
167
+ const vault = opts.vault
168
+ ? new Vault({
169
+ name: opts.vault.name ?? `${opts.name}-secrets`,
170
+ ...(opts.vault.description !== undefined ? { description: opts.vault.description } : {}),
171
+ ...(opts.vault.secrets ? { secrets: opts.vault.secrets } : {}),
172
+ metadata: owned,
173
+ })
174
+ : undefined;
175
+
176
+ // A Vault declaration rather than its `.id`, as in Steward: the manifest's
177
+ // reference form is the resource's name (FTN021), and an AttrRef would
178
+ // serialize to the chant export name instead.
179
+ const allowedVaults =
180
+ opts.allowedVaults === "any" ? undefined : (opts.allowedVaults ?? (vault ? [vault] : []));
181
+
182
+ const agent = new Agent({
183
+ name: opts.name,
184
+ runtime: opts.runtime ?? "claude",
185
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
186
+ sandbox_mode: "persistent",
187
+ environment,
188
+ permission_policy: opts.permissionPolicy,
189
+ ...(allowedVaults !== undefined ? { allowed_vault_ids: allowedVaults } : {}),
190
+ ...(opts.skills ? { skills: opts.skills } : {}),
191
+ ...(opts.mcpServers ? { mcp_servers: opts.mcpServers } : {}),
192
+ ...(opts.system !== undefined ? { system: opts.system } : {}),
193
+ metadata,
194
+ });
195
+
196
+ return { environment, agent, ...(vault ? { vault } : {}), port };
197
+ }
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it } from "vitest";
2
2
  import { WatchOp, type OpConfig } from "@intentius/chant/op";
3
3
  import { ConciergeStack } from "./concierge-stack";
4
4
  import { Steward, stewardForOp, __resetStewardsForTests } from "./steward";
5
+ import { Box, BOX_PORT_METADATA_KEY } from "./box";
6
+ import { postSynthChecks } from "../lint/post-synth/index";
7
+ import spec from "../spec/fountain-openapi.snapshot.json";
5
8
  import { Environment, Vault } from "../generated/index";
6
9
  import { fountainSerializer } from "../serializer";
7
10
  import type { Declarable } from "@intentius/chant";
@@ -234,3 +237,133 @@ describe("Steward", () => {
234
237
  expect(yaml).toMatch(/allowed_vault_ids:\n\s+- prod-creds/);
235
238
  });
236
239
  });
240
+
241
+ // ── Box ───────────────────────────────────────────────────────────────────
242
+
243
+ describe("Box", () => {
244
+ const base = {
245
+ name: "studio-box",
246
+ repo: { url: "https://github.com/arugula-salad/studio" },
247
+ setupScript: "#!/bin/bash\nexec ~/box/provision-template.sh\n",
248
+ permissionPolicy: { default: "auto_allow" as const },
249
+ };
250
+
251
+ it("declares a closed Environment and a persistent claude Agent, with the port in metadata", () => {
252
+ const { environment, agent, vault, port } = Box(base);
253
+
254
+ const env = props(environment);
255
+ expect(env.name).toBe("studio-box-env");
256
+ expect(env.repositories).toEqual([{ url: "https://github.com/arugula-salad/studio", mount_path: "/workspace/app" }]);
257
+ expect(env.setup_script).toBe(base.setupScript);
258
+ expect(env.networking_type).toBe("limited");
259
+ expect(env.networking_config).toEqual({ allowed_hosts: [] });
260
+ expect(env.metadata).toEqual({ "managed-by": "chant", [BOX_PORT_METADATA_KEY]: 8080 });
261
+
262
+ const a = props(agent);
263
+ expect(a.name).toBe("studio-box");
264
+ expect(a.runtime).toBe("claude");
265
+ expect(a.model).toBeUndefined();
266
+ expect(a.sandbox_mode).toBe("persistent");
267
+ expect(a.environment).toBe(environment);
268
+ expect(a.permission_policy).toEqual({ default: "auto_allow" });
269
+ expect(a.allowed_vault_ids).toEqual([]);
270
+ expect(a.metadata).toEqual({ "managed-by": "chant", "box-port": 8080 });
271
+
272
+ expect(vault).toBeUndefined();
273
+ expect(port).toBe(8080);
274
+ });
275
+
276
+ it("declares the shared vault and scopes the agent to it", () => {
277
+ const { agent, vault } = Box({
278
+ ...base,
279
+ vault: { secrets: [{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }] },
280
+ });
281
+ expect(props(vault).name).toBe("studio-box-secrets");
282
+ expect(props(vault).secrets).toEqual([{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }]);
283
+ expect(props(vault).metadata).toEqual({ "managed-by": "chant" });
284
+ expect(props(agent).allowed_vault_ids).toEqual([vault]);
285
+ });
286
+
287
+ it("loosening is a visible parameter", () => {
288
+ const open = Box({
289
+ ...base,
290
+ runtime: "codex",
291
+ model: "openai/gpt-5",
292
+ port: 3000,
293
+ unrestrictedNetworking: true,
294
+ allowedVaults: "any",
295
+ repo: { url: "https://github.com/o/app", mountPath: "/srv/app", ref: "v1", secretKey: "GH_TOKEN" },
296
+ envVars: { NODE_ENV: "production" },
297
+ packages: { apt: ["podman"] },
298
+ setupTimeoutSeconds: 900,
299
+ metadata: { team: "studio" },
300
+ });
301
+ const env = props(open.environment);
302
+ expect(env.networking_type).toBe("unrestricted");
303
+ expect(env.networking_config).toBeUndefined();
304
+ expect(env.repositories).toEqual([{ url: "https://github.com/o/app", mount_path: "/srv/app", ref: "v1", secret_key: "GH_TOKEN" }]);
305
+ expect(env.env_vars).toEqual({ NODE_ENV: "production" });
306
+ expect(env.packages).toEqual({ apt: ["podman"] });
307
+ expect(env.setup_timeout_seconds).toBe(900);
308
+ expect(env.metadata).toEqual({ "managed-by": "chant", team: "studio", "box-port": 3000 });
309
+ const a = props(open.agent);
310
+ expect(a.runtime).toBe("codex");
311
+ expect(a.model).toBe("openai/gpt-5");
312
+ // "any" leaves the list unset: fountain reads null as any vault the tenant owns.
313
+ expect("allowed_vault_ids" in a).toBe(false);
314
+ expect(open.port).toBe(3000);
315
+
316
+ const listed = Box({ ...base, allowedHosts: ["registry.npmjs.org"] });
317
+ expect(props(listed.environment).networking_config).toEqual({ allowed_hosts: ["registry.npmjs.org"] });
318
+ });
319
+
320
+ it("refuses what it cannot mean", () => {
321
+ expect(() => Box({ ...base, port: 0 })).toThrow(/not a TCP port/);
322
+ expect(() => Box({ ...base, port: 8080.5 })).toThrow(/not a TCP port/);
323
+ expect(() => Box({ ...base, unrestrictedNetworking: true, allowedHosts: ["github.com"] })).toThrow(
324
+ /allowedHosts and unrestrictedNetworking/,
325
+ );
326
+ expect(() => Box({ ...base, setupScript: " " })).toThrow(/setupScript is empty/);
327
+ });
328
+
329
+ it("serializes to a manifest whose specs the pinned API accepts, clean under every post-synth check", () => {
330
+ const { environment, agent, vault } = Box({
331
+ ...base,
332
+ allowedHosts: ["registry.npmjs.org", "github.com"],
333
+ vault: { secrets: [{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }] },
334
+ });
335
+ const entities = new Map<string, Declarable>([
336
+ ["boxAgent", agent as unknown as Declarable],
337
+ ["boxVault", vault as unknown as Declarable],
338
+ ["boxEnv", environment as unknown as Declarable],
339
+ ]);
340
+
341
+ const yaml = fountainSerializer.serialize(entities) as string;
342
+ expect([...yaml.matchAll(/^kind: (\w+)$/gm)].map((m) => m[1])).toEqual(["Environment", "Vault", "Agent"]);
343
+ expect(yaml).toContain("environment: studio-box-env");
344
+ expect(yaml).toMatch(/allowed_vault_ids:\n\s+- studio-box-secrets/);
345
+ expect(yaml).toContain("box-port: 8080");
346
+
347
+ // Every spec key is a field of the pinned create request, or one of the
348
+ // two manifest-level forms fountainApply and `fountain apply -f` resolve:
349
+ // a name reference to the agent's environment, and inline secrets.
350
+ const schemas = (spec as unknown as { components: { schemas: Record<string, { properties: Record<string, unknown> }> } })
351
+ .components.schemas;
352
+ const manifestOnly = new Set(["environment", "secrets"]);
353
+ for (const [kind, entity] of [
354
+ ["EnvironmentRequest", environment],
355
+ ["VaultRequest", vault],
356
+ ["AgentRequest", agent],
357
+ ] as const) {
358
+ const fields = Object.keys(schemas[kind].properties);
359
+ for (const key of Object.keys(props(entity))) {
360
+ if (key === "name" || manifestOnly.has(key)) continue;
361
+ expect(fields, `${kind} has no field ${key}`).toContain(key);
362
+ }
363
+ }
364
+
365
+ const ctx = { outputs: new Map(), entities, buildResult: { warnings: [], errors: [] } } as unknown as PostSynthContext;
366
+ const diagnostics = postSynthChecks.flatMap((c) => c.check(ctx));
367
+ expect(diagnostics).toEqual([]);
368
+ });
369
+ });
package/src/index.ts CHANGED
@@ -41,6 +41,8 @@ export { ConciergeStack } from "./composites/concierge-stack";
41
41
  export type { ConciergeStackOpts, ConciergeStackResources } from "./composites/concierge-stack";
42
42
  export { Steward, stewardForOp, STEWARD_RUNTIME_COMMAND } from "./composites/steward";
43
43
  export type { StewardOp, StewardOpts, StewardResources, StewardWebhookOpts } from "./composites/steward";
44
+ export { Box, BOX_PORT_METADATA_KEY, BOX_DEFAULT_PORT, BOX_DEFAULT_MOUNT_PATH } from "./composites/box";
45
+ export type { BoxOpts, BoxResources, BoxRepositoryOpts, BoxVaultOpts, BoxPermissionPolicy } from "./composites/box";
44
46
 
45
47
  // `chant acp` (#2125) — the ACP server, mounted through the plugin's command
46
48
  // group. Exported so an embedder can serve it over its own transport.
@@ -9,7 +9,6 @@ import {
9
9
  vaultNameRefs,
10
10
  type FountainHttp,
11
11
  } from "./fountain-apply";
12
- import { fountainRun } from "./fountain-run";
13
12
  import type { ChantConfig } from "@intentius/chant/config";
14
13
 
15
14
  interface Call {
@@ -904,56 +903,3 @@ describe("fountainApply — Teammate, Schedule and Webhook", () => {
904
903
  ]);
905
904
  });
906
905
  });
907
-
908
- describe("fountainRun", () => {
909
- it("resolves the agent by name, starts, and polls to a terminal status", async () => {
910
- let polls = 0;
911
- const http: FountainHttp = async (method, path, body) => {
912
- if (path.startsWith("/api/agents?search=")) {
913
- return { status: 200, json: { data: [{ id: "agent-1", name: "researcher" }] } };
914
- }
915
- if (method === "POST" && path === "/api/conversations") {
916
- expect((body as Record<string, unknown>).agent_id).toBe("agent-1");
917
- return { status: 201, json: { data: { id: "conv-1" } } };
918
- }
919
- if (method === "GET" && path === "/api/conversations/conv-1") {
920
- polls += 1;
921
- return {
922
- status: 200,
923
- json: { data: { status: polls < 3 ? "running" : "completed" } },
924
- };
925
- }
926
- throw new Error(`unrouted: ${method} ${path}`);
927
- };
928
-
929
- const result = await fountainRun(
930
- { agent: "researcher", prompt: "hi", pollMs: 1, sleep: async () => {} },
931
- http,
932
- );
933
- expect(result).toEqual({ conversationId: "conv-1", status: "completed", terminatedByDeadline: false });
934
- });
935
-
936
- it("terminates the conversation when the deadline passes", async () => {
937
- const calls: string[] = [];
938
- const http: FountainHttp = async (method, path) => {
939
- calls.push(`${method} ${path}`);
940
- if (path === "/api/conversations" && method === "POST") {
941
- return { status: 201, json: { data: { id: "conv-2" } } };
942
- }
943
- if (method === "GET") return { status: 200, json: { data: { status: "running" } } };
944
- return { status: 200, json: null };
945
- };
946
-
947
- const result = await fountainRun(
948
- {
949
- agent: "123e4567-e89b-42d3-a456-426614174000",
950
- timeoutMs: 1,
951
- pollMs: 1,
952
- sleep: async () => {},
953
- },
954
- http,
955
- );
956
- expect(result.terminatedByDeadline).toBe(true);
957
- expect(calls).toContain("POST /api/conversations/conv-2/terminate");
958
- });
959
- });
@@ -0,0 +1,219 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fountainRun, resolveAgent, resolveAgentId } from "./fountain-run";
3
+ import type { FountainHttp } from "./fountain-apply";
4
+
5
+ describe("resolveAgent", () => {
6
+ it("resolves by name and reports sandbox_mode from the search result", async () => {
7
+ const http: FountainHttp = async (method, path) => {
8
+ if (method === "GET" && path.startsWith("/api/agents?search=")) {
9
+ return {
10
+ status: 200,
11
+ json: { data: [{ id: "agent-1", name: "researcher", sandbox_mode: "persistent" }] },
12
+ };
13
+ }
14
+ throw new Error(`unrouted: ${method} ${path}`);
15
+ };
16
+ expect(await resolveAgent(http, "researcher")).toEqual({
17
+ id: "agent-1",
18
+ sandboxMode: "persistent",
19
+ });
20
+ });
21
+
22
+ it("defaults sandbox_mode to ephemeral when the agent omits it", async () => {
23
+ const http: FountainHttp = async () => ({
24
+ status: 200,
25
+ json: { data: [{ id: "agent-1", name: "researcher" }] },
26
+ });
27
+ expect(await resolveAgent(http, "researcher")).toEqual({
28
+ id: "agent-1",
29
+ sandboxMode: "ephemeral",
30
+ });
31
+ });
32
+
33
+ it("looks a raw agent id up by GET /api/agents/{id} for its sandbox_mode", async () => {
34
+ const calls: string[] = [];
35
+ const http: FountainHttp = async (method, path) => {
36
+ calls.push(`${method} ${path}`);
37
+ return { status: 200, json: { data: { id: "123e4567-e89b-42d3-a456-426614174000", sandbox_mode: "persistent" } } };
38
+ };
39
+ const resolved = await resolveAgent(http, "123e4567-e89b-42d3-a456-426614174000");
40
+ expect(resolved).toEqual({ id: "123e4567-e89b-42d3-a456-426614174000", sandboxMode: "persistent" });
41
+ expect(calls).toEqual(["GET /api/agents/123e4567-e89b-42d3-a456-426614174000"]);
42
+ });
43
+ });
44
+
45
+ describe("resolveAgentId", () => {
46
+ it("returns a raw agent id with no HTTP call", async () => {
47
+ const http: FountainHttp = async () => {
48
+ throw new Error("should not be called");
49
+ };
50
+ expect(await resolveAgentId(http, "123e4567-e89b-42d3-a456-426614174000")).toBe(
51
+ "123e4567-e89b-42d3-a456-426614174000",
52
+ );
53
+ });
54
+ });
55
+
56
+ /** A scripted conversation: agent lookup, create, then a fixed sequence of GET replies. */
57
+ function scriptedConversation(opts: {
58
+ sandboxMode?: string;
59
+ conversationId: string;
60
+ statuses: string[];
61
+ turns?: Array<{ turn_number: number; status: string }>;
62
+ }) {
63
+ const calls: string[] = [];
64
+ let statusIndex = 0;
65
+ const http: FountainHttp = async (method, path, body) => {
66
+ calls.push(`${method} ${path}`);
67
+ if (method === "GET" && path.startsWith("/api/agents?search=")) {
68
+ return {
69
+ status: 200,
70
+ json: {
71
+ data: [{ id: "agent-1", name: "researcher", sandbox_mode: opts.sandboxMode }],
72
+ },
73
+ };
74
+ }
75
+ if (method === "POST" && path === "/api/conversations") {
76
+ expect((body as Record<string, unknown>).agent_id).toBe("agent-1");
77
+ return { status: 201, json: { data: { id: opts.conversationId } } };
78
+ }
79
+ if (method === "GET" && path === `/api/conversations/${opts.conversationId}`) {
80
+ const status = opts.statuses[Math.min(statusIndex, opts.statuses.length - 1)];
81
+ statusIndex += 1;
82
+ return { status: 200, json: { data: { status } } };
83
+ }
84
+ if (method === "GET" && path === `/api/conversations/${opts.conversationId}/turns`) {
85
+ return { status: 200, json: { data: opts.turns ?? [] } };
86
+ }
87
+ if (method === "POST" && path === `/api/conversations/${opts.conversationId}/terminate`) {
88
+ return { status: 200, json: null };
89
+ }
90
+ throw new Error(`unrouted: ${method} ${path}`);
91
+ };
92
+ return { http, calls };
93
+ }
94
+
95
+ describe("fountainRun — ephemeral (behaves as today, #2718)", () => {
96
+ it("resolves the agent by name, starts, and polls to a terminal status", async () => {
97
+ const { http } = scriptedConversation({
98
+ sandboxMode: "ephemeral",
99
+ conversationId: "conv-1",
100
+ statuses: ["running", "running", "completed"],
101
+ });
102
+
103
+ const result = await fountainRun(
104
+ { agent: "researcher", prompt: "hi", pollMs: 1, sleep: async () => {} },
105
+ http,
106
+ );
107
+ expect(result).toEqual({
108
+ conversationId: "conv-1",
109
+ status: "completed",
110
+ persistent: false,
111
+ terminatedByDeadline: false,
112
+ });
113
+ });
114
+
115
+ it("terminates the conversation when the deadline passes", async () => {
116
+ const { http, calls } = scriptedConversation({
117
+ sandboxMode: "ephemeral",
118
+ conversationId: "conv-2",
119
+ statuses: ["running"],
120
+ });
121
+
122
+ const result = await fountainRun(
123
+ { agent: "researcher", timeoutMs: 1, pollMs: 1, sleep: async () => {} },
124
+ http,
125
+ );
126
+ expect(result).toEqual({
127
+ conversationId: "conv-2",
128
+ status: "terminated",
129
+ persistent: false,
130
+ terminatedByDeadline: true,
131
+ });
132
+ expect(calls).toContain("POST /api/conversations/conv-2/terminate");
133
+ });
134
+ });
135
+
136
+ describe("fountainRun — persistent (#2718)", () => {
137
+ it("returns the finished turn's outcome once the conversation goes idle, without terminating", async () => {
138
+ const { http, calls } = scriptedConversation({
139
+ sandboxMode: "persistent",
140
+ conversationId: "conv-3",
141
+ statuses: ["pending", "running", "idle"],
142
+ turns: [{ turn_number: 1, status: "completed" }],
143
+ });
144
+
145
+ const result = await fountainRun(
146
+ { agent: "researcher", prompt: "hi", pollMs: 1, sleep: async () => {} },
147
+ http,
148
+ );
149
+ expect(result).toEqual({
150
+ conversationId: "conv-3",
151
+ status: "completed",
152
+ persistent: true,
153
+ terminatedByDeadline: false,
154
+ });
155
+ expect(calls).not.toContain("POST /api/conversations/conv-3/terminate");
156
+ });
157
+
158
+ it("reports a failed turn's outcome", async () => {
159
+ const { http } = scriptedConversation({
160
+ sandboxMode: "persistent",
161
+ conversationId: "conv-4",
162
+ statuses: ["running", "idle"],
163
+ turns: [
164
+ { turn_number: 1, status: "completed" },
165
+ { turn_number: 2, status: "failed" },
166
+ ],
167
+ });
168
+
169
+ const result = await fountainRun({ agent: "researcher", pollMs: 1, sleep: async () => {} }, http);
170
+ expect(result.status).toBe("failed");
171
+ expect(result.persistent).toBe(true);
172
+ });
173
+
174
+ it("does not terminate a hung run by default — the machine is a home (terminate: never)", async () => {
175
+ const { http, calls } = scriptedConversation({
176
+ sandboxMode: "persistent",
177
+ conversationId: "conv-5",
178
+ statuses: ["running"],
179
+ });
180
+
181
+ const result = await fountainRun(
182
+ { agent: "researcher", timeoutMs: 1, pollMs: 1, sleep: async () => {} },
183
+ http,
184
+ );
185
+ expect(result.terminatedByDeadline).toBe(true);
186
+ expect(result.persistent).toBe(true);
187
+ expect(calls).not.toContain("POST /api/conversations/conv-5/terminate");
188
+ });
189
+
190
+ it("terminate: on-deadline still ends a persistent agent's hung run", async () => {
191
+ const { http, calls } = scriptedConversation({
192
+ sandboxMode: "persistent",
193
+ conversationId: "conv-6",
194
+ statuses: ["running"],
195
+ });
196
+
197
+ await fountainRun(
198
+ { agent: "researcher", terminate: "on-deadline", timeoutMs: 1, pollMs: 1, sleep: async () => {} },
199
+ http,
200
+ );
201
+ expect(calls).toContain("POST /api/conversations/conv-6/terminate");
202
+ });
203
+
204
+ it("terminate: always ends the machine even after a clean turn", async () => {
205
+ const { http, calls } = scriptedConversation({
206
+ sandboxMode: "persistent",
207
+ conversationId: "conv-7",
208
+ statuses: ["running", "idle"],
209
+ turns: [{ turn_number: 1, status: "completed" }],
210
+ });
211
+
212
+ const result = await fountainRun(
213
+ { agent: "researcher", terminate: "always", pollMs: 1, sleep: async () => {} },
214
+ http,
215
+ );
216
+ expect(result.terminatedByDeadline).toBe(false);
217
+ expect(calls).toContain("POST /api/conversations/conv-7/terminate");
218
+ });
219
+ });
@@ -1,12 +1,33 @@
1
1
  /**
2
2
  * fountainRun — start a conversation from a declared Agent and follow it
3
- * to a terminal status.
3
+ * to the end of its turn.
4
4
  *
5
5
  * Conversations are runs, not declarables (the one fountain kind the
6
6
  * lexicon deliberately does not model as a resource). This op is the
7
- * imperative half: resolve the agent by name, POST the conversation,
8
- * poll until `completed | failed | timed_out | terminated`, and terminate
9
- * on deadline so a hung run never outlives the op that started it.
7
+ * imperative half: resolve the agent by name, POST the conversation, and
8
+ * poll until the turn is done.
9
+ *
10
+ * What "done" means depends on the agent's `sandbox_mode` (#2718). An
11
+ * `ephemeral` conversation's machine is its own and this polls the
12
+ * conversation's own status to `completed | failed | timed_out |
13
+ * terminated` — none of which a running server's conversation status
14
+ * actually reaches on its own, so in practice this waits out `timeoutMs`
15
+ * and terminates, as it always has. A `persistent` agent's conversation
16
+ * (`Steward`, and `Box` from #2705) shares one machine across every
17
+ * conversation on it, and a turn ending there does not tear the sandbox
18
+ * down: the conversation settles on `idle`, not on any of the four
19
+ * statuses above. Waiting for one of those on a persistent agent is the
20
+ * bug this op used to have — it waited out the whole deadline after the
21
+ * turn had already finished, then terminated a machine that was meant to
22
+ * persist. For `idle`, this instead reads the finished turn back
23
+ * (`GET .../turns`) and returns its own outcome (`completed | failed |
24
+ * interrupted` — fountain's `end_turn` block, or an error) without
25
+ * terminating anything.
26
+ *
27
+ * Terminating the conversation's machine on deadline (a hung run should
28
+ * not outlive the op that started it) still happens whenever the turn has
29
+ * not ended — for either sandbox mode. `terminate` makes the choice
30
+ * explicit rather than leaving it implicit in `sandbox_mode`.
10
31
  */
11
32
 
12
33
  import {
@@ -16,8 +37,39 @@ import {
16
37
  type FountainConnectionDeps,
17
38
  } from "./fountain-apply";
18
39
 
40
+ /**
41
+ * Conversation statuses that end an *ephemeral* agent's run. `completed`
42
+ * and `timed_out` are not values fountain's `Conversation.status` takes
43
+ * today (its enum is `pending | running | idle | failed | terminated`) —
44
+ * they are kept so a server that starts reporting them is handled without
45
+ * a further change here.
46
+ */
19
47
  export const TERMINAL_STATUSES = new Set(["completed", "failed", "timed_out", "terminated"]);
20
48
 
49
+ /**
50
+ * Conversation statuses that end a *persistent* agent's run: everything
51
+ * {@link TERMINAL_STATUSES} does, plus `idle` — the status a persistent
52
+ * conversation settles on once its turn ends, machine still up (#2718).
53
+ */
54
+ export const PERSISTENT_DONE_STATUSES = new Set([...TERMINAL_STATUSES, "idle"]);
55
+
56
+ /** Turn statuses that mean the turn has ended — fountain's `end_turn` block, or an error. */
57
+ const TERMINAL_TURN_STATUSES = new Set(["completed", "failed", "interrupted"]);
58
+
59
+ /**
60
+ * How `fountainRun` treats the conversation's machine once its wait ends
61
+ * (#2718).
62
+ *
63
+ * - `on-deadline` — terminate only when `timeoutMs` is hit with the turn
64
+ * still unfinished, never on a turn that ended cleanly. This was
65
+ * `fountainRun`'s only behavior before this option existed.
66
+ * - `never` — never terminate, even past the deadline; the caller, or
67
+ * fountain's own idle/max-lifetime bounds, is responsible for the machine.
68
+ * - `always` — terminate whenever the wait ends, whether the turn ended
69
+ * cleanly or the deadline fired.
70
+ */
71
+ export type TerminatePolicy = "never" | "on-deadline" | "always";
72
+
21
73
  export interface FountainRunArgs {
22
74
  /** Agent name (resolved against /api/agents) or a raw agent id. */
23
75
  agent: string;
@@ -34,31 +86,80 @@ export interface FountainRunArgs {
34
86
  profile?: string;
35
87
  /** Project root `chant.config.ts` is read from. Default: process.cwd(). */
36
88
  cwd?: string;
37
- /** Give up (and terminate the conversation) after this long. Default 10 min. */
89
+ /** Give up after this long. Default 10 min. */
38
90
  timeoutMs?: number;
39
91
  /** Poll interval. Default 5s. */
40
92
  pollMs?: number;
93
+ /**
94
+ * When to terminate the conversation's machine (#2718). Default follows
95
+ * the agent's `sandbox_mode`: `on-deadline` for `ephemeral` (unchanged),
96
+ * `never` for `persistent` — its machine is a home meant to outlive the
97
+ * conversation, so a run that hits its deadline leaves it be unless this
98
+ * says otherwise.
99
+ */
100
+ terminate?: TerminatePolicy;
41
101
  /** Injectable clock/sleep for tests. */
42
102
  sleep?: (ms: number) => Promise<void>;
43
103
  }
44
104
 
45
105
  export interface FountainRunResult {
46
106
  conversationId: string;
107
+ /**
108
+ * The run's outcome. For a persistent agent whose turn ended, this is the
109
+ * turn's own status (`completed | failed | interrupted`); otherwise it is
110
+ * the conversation's status when the wait ended (`terminated` on a
111
+ * deadline).
112
+ */
47
113
  status: string;
48
- /** True when the op hit its deadline and terminated the conversation. */
114
+ /** True when the agent's `sandbox_mode` resolved to `persistent`. */
115
+ persistent: boolean;
116
+ /** True when the op hit its deadline with the turn still unfinished. */
49
117
  terminatedByDeadline: boolean;
50
118
  }
51
119
 
52
120
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
53
121
 
54
- export async function resolveAgentId(http: FountainHttp, agent: string): Promise<string> {
55
- if (UUID_RE.test(agent)) return agent;
122
+ export interface ResolvedAgent {
123
+ id: string;
124
+ /** `ephemeral` or `persistent`; fountain's own default is `ephemeral`. */
125
+ sandboxMode: string;
126
+ }
127
+
128
+ /** Resolve an agent by name or id, along with its `sandbox_mode`. */
129
+ export async function resolveAgent(http: FountainHttp, agent: string): Promise<ResolvedAgent> {
130
+ if (UUID_RE.test(agent)) {
131
+ const { status, json } = await http("GET", `/api/agents/${agent}`);
132
+ if (status !== 200) throw new Error(`fountainRun: agent lookup failed (${status})`);
133
+ const data = (json as { data?: { id?: string; sandbox_mode?: string } })?.data;
134
+ if (!data?.id) throw new Error(`fountainRun: no agent "${agent}"`);
135
+ return { id: data.id, sandboxMode: data.sandbox_mode ?? "ephemeral" };
136
+ }
56
137
  const { status, json } = await http("GET", `/api/agents?search=${encodeURIComponent(agent)}`);
57
138
  if (status !== 200) throw new Error(`fountainRun: agent lookup failed (${status})`);
58
- const data = (json as { data?: Array<{ id: string; name: string }> })?.data ?? [];
139
+ const data =
140
+ (json as { data?: Array<{ id: string; name: string; sandbox_mode?: string }> })?.data ?? [];
59
141
  const exact = data.find((a) => a.name === agent);
60
142
  if (!exact) throw new Error(`fountainRun: no agent named "${agent}"`);
61
- return exact.id;
143
+ return { id: exact.id, sandboxMode: exact.sandbox_mode ?? "ephemeral" };
144
+ }
145
+
146
+ /** Agent id only — the common case, and the one call sites outside this file use. */
147
+ export async function resolveAgentId(http: FountainHttp, agent: string): Promise<string> {
148
+ if (UUID_RE.test(agent)) return agent;
149
+ return (await resolveAgent(http, agent)).id;
150
+ }
151
+
152
+ /** The latest turn's status, or undefined if it can't be read or none exists. */
153
+ async function latestTurnStatus(
154
+ http: FountainHttp,
155
+ conversationId: string,
156
+ ): Promise<string | undefined> {
157
+ const { status, json } = await http("GET", `/api/conversations/${conversationId}/turns`);
158
+ if (status !== 200) return undefined;
159
+ const turns = (json as { data?: Array<{ turn_number?: number; status?: string }> })?.data ?? [];
160
+ if (turns.length === 0) return undefined;
161
+ const latest = turns.reduce((a, b) => ((b.turn_number ?? 0) > (a.turn_number ?? 0) ? b : a));
162
+ return latest.status;
62
163
  }
63
164
 
64
165
  export async function fountainRun(
@@ -75,7 +176,10 @@ export async function fountainRun(
75
176
  const timeoutMs = args.timeoutMs ?? 600_000;
76
177
  const pollMs = args.pollMs ?? 5_000;
77
178
 
78
- const agentId = await resolveAgentId(client, args.agent);
179
+ const { id: agentId, sandboxMode } = await resolveAgent(client, args.agent);
180
+ const persistent = sandboxMode === "persistent";
181
+ const terminatePolicy: TerminatePolicy = args.terminate ?? (persistent ? "never" : "on-deadline");
182
+ const doneStatuses = persistent ? PERSISTENT_DONE_STATUSES : TERMINAL_STATUSES;
79
183
 
80
184
  const createBody: Record<string, unknown> = { agent_id: agentId };
81
185
  if (args.prompt !== undefined) createBody.prompt = args.prompt;
@@ -89,19 +193,33 @@ export async function fountainRun(
89
193
  if (!conversationId) throw new Error("fountainRun: conversation create returned no id");
90
194
 
91
195
  const deadline = Date.now() + timeoutMs;
92
- let status = "pending";
196
+ let conversationStatus = "pending";
93
197
  while (Date.now() < deadline) {
94
198
  const res = await client("GET", `/api/conversations/${conversationId}`);
95
199
  if (res.status === 200) {
96
- status = (res.json as { data?: { status?: string } })?.data?.status ?? status;
97
- if (TERMINAL_STATUSES.has(status)) {
98
- return { conversationId, status, terminatedByDeadline: false };
200
+ conversationStatus =
201
+ (res.json as { data?: { status?: string } })?.data?.status ?? conversationStatus;
202
+ if (doneStatuses.has(conversationStatus)) {
203
+ let status = conversationStatus;
204
+ if (persistent && conversationStatus === "idle") {
205
+ const turnStatus = await latestTurnStatus(client, conversationId);
206
+ if (turnStatus && TERMINAL_TURN_STATUSES.has(turnStatus)) status = turnStatus;
207
+ }
208
+ if (terminatePolicy === "always") {
209
+ await client("POST", `/api/conversations/${conversationId}/terminate`);
210
+ }
211
+ return { conversationId, status, persistent, terminatedByDeadline: false };
99
212
  }
100
213
  }
101
214
  await sleep(pollMs);
102
215
  }
103
216
 
104
- // Deadline: end the conversation so the sandbox does not outlive the op.
105
- await client("POST", `/api/conversations/${conversationId}/terminate`);
106
- return { conversationId, status: "terminated", terminatedByDeadline: true };
217
+ // Deadline: the turn never ended (or, for an ephemeral conversation, its
218
+ // done-status is never observed on the wire — see TERMINAL_STATUSES).
219
+ // `never` leaves a hung run's machine alone; every other policy ends the
220
+ // conversation so it does not outlive the op.
221
+ if (terminatePolicy !== "never") {
222
+ await client("POST", `/api/conversations/${conversationId}/terminate`);
223
+ }
224
+ return { conversationId, status: "terminated", persistent, terminatedByDeadline: true };
107
225
  }
@@ -25,5 +25,11 @@ export type {
25
25
  FountainConnectionDeps,
26
26
  } from "./fountain-apply";
27
27
 
28
- export { fountainRun, resolveAgentId, TERMINAL_STATUSES } from "./fountain-run";
29
- export type { FountainRunArgs, FountainRunResult } from "./fountain-run";
28
+ export {
29
+ fountainRun,
30
+ resolveAgent,
31
+ resolveAgentId,
32
+ TERMINAL_STATUSES,
33
+ PERSISTENT_DONE_STATUSES,
34
+ } from "./fountain-run";
35
+ export type { FountainRunArgs, FountainRunResult, ResolvedAgent, TerminatePolicy } from "./fountain-run";
@@ -31,4 +31,4 @@ The environment's config is the enforcement boundary, so watch it: `chant lifecy
31
31
 
32
32
  ## Running conversations
33
33
 
34
- Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault), polls to `completed | failed | timed_out`, and terminates at its deadline so a hung sandbox never outlives the op. Multi-turn interaction (follow-up prompts, interrupt) is fountain's own conversations API — keep chant to the lifecycle edges.
34
+ Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault), and waits for the turn to end. What "end" means, and what happens to the machine, follows the agent's `sandbox_mode`: an `ephemeral` agent's conversation is polled for a terminal status and terminated on deadline, same as before; a `persistent` agent's (a `Steward` or `Box`) conversation settles on `idle` once its turn ends — machine still up for the next turn — so `fountainRun` reads the turn back and returns its outcome (`completed | failed | interrupted`) without terminating anything, by default. Pass `terminate: "on-deadline" | "always"` to opt a persistent agent's run back into ending the machine. Multi-turn interaction (follow-up prompts, interrupt) is fountain's own conversations API — keep chant to the lifecycle edges.