@hachej/boring-agent 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,25 @@ Limits: a run is admitted against its hold, not stopped mid-run (no per-call fen
58
58
 
59
59
  `createPresentTool({ workspace, resolveAccess?, maxBytes? })` from `@hachej/boring-agent/artifacts` returns the native `present(path)` tool (its workspace resolved per call like the guard's): the agent writes a workspace file with its ordinary file tools and presents it, and the chat shows a card that points at that file (`target`, the revision shown, no `id` or `ordinal`). The file's versions are the history of the workspace provider (`@hachej/boring-files/workspace`, whose `keep` retains the presented revision); a card of an older revision pins it and a card of the newest follows the file as it changes. The tool writes nothing and refuses files that are missing, not UTF-8 text or larger than 256 KiB. An opt-in `ack: { timeoutMs?, viewers? }` makes `present` wait until a page has mounted the presented revision: the page answers `{ kind: 'opened', instanceId, path, revision }` or `{ kind: 'refused', reason }` through the chat's `answer` (the transport's `?op=answer`), and the result carries `opened: true` with `viewer`, `opened: false` with `refused` (for example `closed by user`), `opened: 'unconfirmed'` after `timeoutMs` (default 15 s) or `opened: false, viewer: 'none'` at once when `viewers()` says no page watches the conversation. The wait is the durable wait of `ask_user` (`packages/agent/src/view-open.ts`, a conversation document keyed by the call); the deadline is stored, so a restart cannot extend it, and the first valid answer wins. Without `ack` the result is unchanged. `answerViewOpen` settles an answer; the host chains it with `answerUserQuestion` and `answerBrowserTask` in its `answer` handler. How to use it is in the tool's own description, so a host does not repeat it in its prompt: substantial output goes to a file that is then presented, short answers (and anything asked for in the chat) stay in the chat, and a revision re-reads the file, changes it at the same path and presents it again. A host adds only what is its own, such as what its HTML preview allows.
60
60
 
61
+ ### A view that is not a document (a dashboard)
62
+
63
+ A view the agent opens, such as a dashboard, is presented the same way: it is a small workspace file that names what to show (for example `sales.dashboard.json`), and the app registers an artifact kind that draws it. There is no second channel: the same `ack` answers for it. Mark the kind `waitsForReady: true` when its renderer loads asynchronously: the panel then reports the mount (and the page confirms the `present` call) only after the renderer calls `onReady` for the revision shown. `onFailed(reason)`, or a renderer that throws, refuses the call with that reason, and a renderer that never becomes ready leaves it `unconfirmed` at the deadline. A kind without `waitsForReady` is confirmed as soon as its file is read.
64
+
65
+ ```tsx
66
+ // Server: the agent writes sales.dashboard.json, then calls present('sales.dashboard.json').
67
+ createPresentTool({ workspace, ack: { timeoutMs: 15_000 } });
68
+ // -> { kind: 'presented', artifact, opened: true, viewer } | { opened: false, refused: 'the sales query failed' } | { opened: 'unconfirmed', timeoutMs }
69
+
70
+ // Page (pi-app block): the kind confirms the view only once its data has loaded.
71
+ const dashboard: ArtifactKind = {
72
+ match: target => target.resource.path.endsWith('.dashboard.json'),
73
+ label: 'Dashboard',
74
+ waitsForReady: true,
75
+ renderer: ({ text, onReady, onFailed }) => <SalesDashboard spec={JSON.parse(text)} onLoaded={onReady} onError={error => onFailed?.(error.message)} />,
76
+ };
77
+ <AgentWorkspace {...workspace} artifactKinds={[dashboard]} />;
78
+ ```
79
+
61
80
  `workspaceMentionReader(files, access, { maxReadBytes? })` is the reader for a host with a workspace provider: it reads the person's own published view through the provider (the access check the file viewers use) and has no containment check of its own. A host that has no provider has no reader here; it writes a `MentionReader` over whatever it does have.
62
81
 
63
82
  The descriptor's `id` and `ordinal` are optional and `present` sets neither; `parseArtifact` validates a descriptor and has no Pi runtime dependency (the copied `pi-chat` registry item carries the same validator for the browser).
@@ -66,6 +85,20 @@ The descriptor's `id` and `ordinal` are optional and `present` sets neither; `pa
66
85
 
67
86
  `refreshAgentState({ conversations, agent, read, write, context })` from `@hachej/boring-agent/agents` brings existing conversations up to a changed deployment. A conversation keeps the agent it was created with, so when the agent's `instructions` or default `thinkingLevel` change, the helper calls `configure` on each conversation. The host stores a hash of `[instructions, thinkingLevel]` (`read()` returns the stored hash, `write(hash)` stores the new one); it configures nothing when the hash is unchanged and writes the new hash last, so a failed run is retried. It returns how many conversations were configured.
68
87
 
88
+ ### Add an extension to an existing conversation
89
+
90
+ `addExtension(conversation, extension, context)` from `@hachej/boring-agent/agents` adds one Pi extension (for example `createApprovalExtension(...)` or one that registers a tool) to a conversation that already exists; its next turn runs with it. It edits the conversation's stored `pi.agent` selection in one native `Conversation.commit` (Pi's public `AgentDoc`): the name is appended to the conversation's own list, so it keeps its other extensions and picks up no other agent's, and the extension's tools are enabled and offered even when the conversation lists its tools. Adding it twice changes nothing; remove it with native `conversation.configure({ extensions: { remove: [extension] } }, context)`. Native `configure({ extensions: { add } })` is not the same: on a `defineAgent` conversation it replaces the stored list with an edit of the host default selection.
91
+
92
+ ```ts
93
+ const registry = createRegistry();
94
+ agent.install(registry);
95
+ registry.install(approval); // at every Harness open: the conversation stores the name only
96
+ const conversation = await harness.conversation(id, context);
97
+ await addExtension(conversation, approval, context);
98
+ ```
99
+
100
+ The selection is durable (it is in the conversation's Pi state and survives a reopen), but an extension is host code and is not stored: install it in the registry every time the Harness opens, or its name selects nothing. Installing it does not add it to other `defineAgent` conversations, whose lists are explicit; a conversation with no stored list (such as `harness.root()` without `agent`) follows the host default selection, which is every installed extension unless `HarnessSettings.extensions` names them.
101
+
69
102
  `createConvertingRead({ workspace?, resolveAccess?, convert?, cache?, maxBytes?, name? })` from `@hachej/boring-agent/file-convert` returns an extension that wraps Pi's native `read` (`wrapTool`); there is one `read`, and no second tool. Text goes to Pi's read untouched. A PDF, an office document or an image goes to the host's `convert({ name, mediaType, bytes })`, once per saved revision (cached by path and revision), and the converted text is paged the way Pi pages a text file: `offset` is the 1-based line to start at, `limit` a number of lines, a page ends at 2000 lines or 50 KB, and the note names the `offset` to continue from. What is text and what is not comes from one table, `fileKind` in `@hachej/boring-agent/file-types`, which looks at the extension and the leading bytes (Pi's read returns garbled text for a PDF, so a PDF is recognised by `%PDF-` even under another name); the mention resolver uses the same table. An archive or other binary file answers `unsupported` and is never converted; without `convert`, an image uses Pi's native behaviour and a PDF or office file answers `unsupported`. A line of converted text above 50 KB is split into 50 KB segments, each counted as one line, with a warning, so nothing is cut. The type is sniffed on the bytes the provider returns after its access check, and the converter is told the media type the file's signature names (a PDF named `.txt` arrives as `application/pdf`). The file is read through the workspace provider, so its access policy applies; a path the wrapper cannot place inside the workspace is left to the native read. Install it before `createFileGuard`, so the guard stays outermost and refuses anything outside the workspace first. `workersAiMarkdown(env.AI)` adapts a Workers AI `toMarkdown` binding to `convert` without a Cloudflare types dependency.
70
103
 
71
104
  ## Shared document readers
@@ -312,7 +345,18 @@ With self-evolution, workspace skills may be flat `.agent/skills/<name>.md` file
312
345
 
313
346
  These follow [Pi Durable on Cloudflare](https://earendil.com/posts/pi-durable-on-cloudflare/) and stay host-agnostic: a Node process and a Durable Object mount the same tools.
314
347
 
315
- **Delegate.** `createDelegateTool({ agent, name?, description? })` from `@hachej/boring-agent/agents` is Pi's documented subagent pattern (pi-durable README, "Abort and Subagents"; pi-durable ships it as an example, not an export) as a tool factory. `delegate({ task })` creates a child conversation owned by the calling tool task (`ownership: { kind: 'task', taskId }`), configured as the child agent alone (its extensions, model and instructions; the inherited tool filters reset), submits the task with request id `<name>:<taskId>` and returns only the child's final text, with `details.conversationId` naming the child. It is replay-safe: a rerun after a crash or eviction finds its child through Pi's ownership index (`scanConversations({ ownerTaskId })`) and its submission through the request id, so it never starts a second child or turn. Aborting the call aborts the child. With `defineAgent`, declare `delegates: [{ agent: researcher }]`: each becomes a tool of the agent and `install(registry)` installs the child's extensions as well. A raw-Pi host installs the child agent's extensions itself.
348
+ **Delegate.** `createDelegateTool({ agent, name?, description? })` from `@hachej/boring-agent/agents` is Pi's documented subagent pattern (pi-durable README, "Abort and Subagents"; pi-durable ships it as an example, not an export) as a tool factory. `delegate({ task })` creates a child conversation owned by the calling tool task (`ownership: { kind: 'task', taskId }`), configured as the child agent alone (its extensions, model and instructions; the inherited tool filters reset), submits the task with request id `<name>:<taskId>` and returns only the child's final text, with `details` (`DelegateDetails`, below) naming the child. It is replay-safe: a rerun after a crash or eviction finds its child through Pi's ownership index (`scanConversations({ ownerTaskId })`) and its submission through the request id, so it never starts a second child or turn. Aborting the call aborts the child. With `defineAgent`, declare `delegates: [{ agent: researcher }]`: each becomes a tool of the agent and `install(registry)` installs the child's extensions as well. A raw-Pi host installs the child agent's extensions itself.
349
+
350
+ **Child identity.** The kit copies no identity onto a child; the host decides what a child carries. Pass `onChild(parent, child, tx)` in `DelegateOptions` (or a `delegates` entry): it runs inside the same native commit (`ToolExecutionApi.commit`, Pi's `Tx`) that creates and configures the child, so the child never exists without what it writes, and a throw aborts the commit and leaves no child (the delegate call returns an error). It runs once per child, not on a replay that finds the existing child. Read and write only through `tx`:
351
+
352
+ ```ts
353
+ const delegates = [{ agent: researcher, onChild: async (parent, child, tx) => {
354
+ const from = await tx.doc(HostIdentity, parent), to = await tx.doc(HostIdentity, child); // HostIdentity: the host's conversation doc
355
+ to.user = from.user; to.workspace = from.workspace; to.permissions = [...from.permissions];
356
+ } }];
357
+ ```
358
+
359
+ **Sub-agent card data.** Every delegate call's `details` is a stable `DelegateDetails` (exported from `@hachej/boring-agent/agents`, with `DELEGATE_DETAILS_KIND` and `DelegateStatus`): `{ kind: 'boring.delegate', version: 1, agent, conversationId, status, startedAt, durationMs, toolCalls, tokens: { input, output }, error? }`. `status` is `running`, `done`, `failed` (with `error`, the same text as the result) or `cancelled`; `conversationId` is the child conversation, `toolCalls` counts the tool calls in the child's assistant messages and `tokens` sums the child's `UsageDoc` (input includes cache reads and writes). While the child runs, the tool publishes `running` values through Pi's `api.details`, so they appear in the call's `LiveDoc` tool slot: it watches the child's `LiveDoc` (`api.watchDoc`) and, when a step of the child's run changes, recounts (`tx.scanEntries` from the last counted entry, `api.snapshot(UsageDoc)`) and publishes only when the counts change; Pi paces the commits. The final result carries `done`, `failed` or `cancelled`. When the delegate call itself is stopped, Pi writes its aborted result from the slot, so its details keep the last `running` value: read `running` on a settled error result as `cancelled`. The `pi-chat` block's `SubagentCard` and `parseDelegateDetails(details, result?)` do this and render any call carrying these details.
316
360
 
317
361
  **Schedule.** `@hachej/boring-agent/schedule` exports `createScheduleExtension({ now?, onDeadline?, maxDelaySeconds? })`, one native extension with `schedule({ instruction, delaySeconds | at, operationId? })`, `list_schedules()` and `cancel_schedule({ id })`, plus the delivery task `ScheduledDelivery`. A schedule is a native Pi task owned by the conversation as background work: it sleeps on the Harness clock (`runtime.sleep`) and then submits the instruction to its conversation as a follow-up input (entry kind `boring.schedule.delivery`, request id = schedule id, so a replayed delivery never submits twice). Pi's scheduler runs it and a restart resumes it; there is no second scheduler. Pending schedules live in the conversation document `ConversationSchedules`, so a conversation lists and cancels only its own; a cancelled schedule's task wakes and ends without delivering. `schedule` is idempotent on `operationId` (default: the tool task, so a replayed call returns the schedule it made). Hosts schedule outside a tool with `addSchedule(tx, conversationId, { operationId, instruction, runAt })`, `cancelSchedule(tx, conversationId, id)` and `listSchedules(harness, conversationId, context)`. Pass the Harness's own clock as `now`. A host with one wake-up timer (a Durable Object alarm) computes the earliest deadline from `harness.inspect()` with `scheduledDeadline(task)` and moves the timer earlier from `onDeadline`; its alarm handler only reopens or resumes the Harness, and Pi delivers what is due.
318
362
 
package/dist/agents.d.ts CHANGED
@@ -9,8 +9,8 @@ export { createSkillsExtension, parseSkill } from './skills.js';
9
9
  export type { Skill } from './skills.js';
10
10
  export { jsonSchemaTool } from './json-schema-tool.js';
11
11
  export type { JsonObjectSchema, JsonSchemaToolOptions } from './json-schema-tool.js';
12
- export { createDelegateTool, DELEGATE_TOOL } from './delegate.js';
13
- export type { DelegateOptions } from './delegate.js';
12
+ export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
13
+ export type { DelegateDetails, DelegateOptions, DelegateStatus } from './delegate.js';
14
14
  /** One agent as plain data over native Pi pieces. It is a convenience, not another registry or lifecycle. */
15
15
  export interface AgentSpec {
16
16
  /** Stable name; it becomes the native extension name `agent.<id>`. */
@@ -82,4 +82,13 @@ export declare function refreshAgentState(options: {
82
82
  readonly write: (hash: string) => void | Promise<void>;
83
83
  readonly context: Context;
84
84
  }): Promise<number>;
85
+ /**
86
+ * Add one extension to an existing conversation, so its next turn runs with it (native `Conversation.commit` over the public
87
+ * `pi.agent` document, `AgentDoc`). The conversation stores extension names only: the host installs `extension` in the Harness
88
+ * registry, now and every time it opens the Harness, or the name selects nothing. Unlike `configure({ extensions: { add } })`,
89
+ * which replaces a stored list (a `defineAgent` conversation's) with an edit of the host default selection, this appends to the
90
+ * conversation's own list, so it keeps its other extensions and gains no one else's. Its tools are enabled and offered even when
91
+ * the conversation lists its tools explicitly. Adding an extension twice changes nothing. Remove it with native `configure`.
92
+ */
93
+ export declare function addExtension(conversation: Conversation, extension: Extension, context: Context): Promise<void>;
85
94
  //# sourceMappingURL=agents.d.ts.map
package/dist/agents.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { defineExtension } from '@earendil-works/pi-durable';
2
+ import { AgentDoc, defineExtension } from '@earendil-works/pi-durable';
3
3
  import { createSelfEvolution } from './self-evolving.js';
4
4
  import { NAME, createSkillsExtension, parseSkill, validSkill } from './skills.js';
5
5
  import { createDelegateTool } from './delegate.js';
6
6
  export { createSkillsExtension, parseSkill } from './skills.js';
7
7
  export { jsonSchemaTool } from './json-schema-tool.js';
8
- export { createDelegateTool, DELEGATE_TOOL } from './delegate.js';
8
+ export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
9
9
  /** Turn an agent described as data into native extensions and a native agent configuration. */
10
10
  export function defineAgent(spec) {
11
11
  if (!NAME.test(spec.id))
@@ -96,3 +96,35 @@ export async function refreshAgentState(options) {
96
96
  await options.write(hash);
97
97
  return configured;
98
98
  }
99
+ /**
100
+ * Add one extension to an existing conversation, so its next turn runs with it (native `Conversation.commit` over the public
101
+ * `pi.agent` document, `AgentDoc`). The conversation stores extension names only: the host installs `extension` in the Harness
102
+ * registry, now and every time it opens the Harness, or the name selects nothing. Unlike `configure({ extensions: { add } })`,
103
+ * which replaces a stored list (a `defineAgent` conversation's) with an edit of the host default selection, this appends to the
104
+ * conversation's own list, so it keeps its other extensions and gains no one else's. Its tools are enabled and offered even when
105
+ * the conversation lists its tools explicitly. Adding an extension twice changes nothing. Remove it with native `configure`.
106
+ */
107
+ export async function addExtension(conversation, extension, context) {
108
+ const tools = (extension.tools ?? []).map(tool => tool.name);
109
+ await conversation.commit(async (tx) => {
110
+ const state = await tx.doc(AgentDoc, conversation.id);
111
+ const append = (list, names) => { for (const name of names)
112
+ if (!list.includes(name))
113
+ list.push(name); };
114
+ const selected = state.extensions;
115
+ if (Array.isArray(selected))
116
+ append(selected, [extension.name]);
117
+ else {
118
+ const add = [...selected?.add ?? []];
119
+ append(add, [extension.name]);
120
+ const remove = (selected?.remove ?? []).filter(name => name !== extension.name);
121
+ state.extensions = { add, ...(remove.length ? { remove } : {}) };
122
+ }
123
+ if (Array.isArray(state.tools))
124
+ append(state.tools, tools);
125
+ else if (state.tools)
126
+ state.tools = { remove: state.tools.remove.filter(name => !tools.includes(name)) };
127
+ if (Array.isArray(state.modelTools))
128
+ append(state.modelTools, tools);
129
+ }, context);
130
+ }
@@ -56,6 +56,14 @@ export interface ApprovalExtensionOptions {
56
56
  readonly name?: string;
57
57
  /** Tool names to gate, with their rule. Any native tool works, including Pi's stock `bash`, `write` and `edit`. */
58
58
  readonly tools: Readonly<Record<string, ApprovalRule>>;
59
+ /**
60
+ * What happens to a tool call whose tool is NOT named in `tools`. `'allow'` (default) runs it ungated. `'deny'` blocks it before it runs
61
+ * (the model is told it was not run), except the tools listed in `allow`. Approval is not asked for unlisted tools: a native hook can
62
+ * block a call but cannot ask the person, so name a tool in `tools` to have it asked.
63
+ */
64
+ readonly unlisted?: 'allow' | 'deny';
65
+ /** With `unlisted: 'deny'`: tool names that still run without approval (for example the read tools). Ignored otherwise. */
66
+ readonly allow?: readonly string[];
59
67
  }
60
68
  /**
61
69
  * The approval gate as an opt-in native extension. It brings no tools: it wraps the named tools (native `wraps`, Pi's own
@@ -64,6 +72,13 @@ export interface ApprovalExtensionOptions {
64
72
  * `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
65
73
  * Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
66
74
  *
75
+ * Gating only `bash` is bypassable when self-evolving tools or write+reload are enabled: the agent writes a tool with the ungated
76
+ * `write` tool, reloads it, and the new tool runs through the same `env.exec`. Gate `write`/`edit` and the reload path too, or set
77
+ * `unlisted: 'deny'` (with `allow` for the tools that may run freely) so a tool nobody listed cannot run. The default `'allow'` gates only
78
+ * what `tools` names.
79
+ *
80
+ * A delegated child (`createDelegateTool`) inherits the parent's approval extensions, so a delegate cannot escape the gate.
81
+ *
67
82
  * It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
68
83
  * not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
69
84
  */
package/dist/approval.js CHANGED
@@ -1,4 +1,4 @@
1
- import { defineExtension } from '@earendil-works/pi-durable';
1
+ import { ToolTask, defineExtension, hook } from '@earendil-works/pi-durable';
2
2
  import { askPerson } from './ask-user.js';
3
3
  /** The two answers an approval question accepts. A chat answers with exactly these strings through `answerUserQuestion`. */
4
4
  export const APPROVE = 'Approve';
@@ -13,6 +13,8 @@ export const CANCELLED_PREFIX = 'Not run: the approval question was cancelled.';
13
13
  * without reading the question document. The card in `registry/pi-chat` repeats this key (a contract test keeps them equal).
14
14
  */
15
15
  export const APPROVAL_DETAILS = 'boring.approval';
16
+ const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
17
+ const markGuard = (extension) => { GUARDS.add(extension); return extension; };
16
18
  const MEMO = 'boring.approval.started';
17
19
  const GATED = new WeakSet();
18
20
  const text = (value, isError) => ({ isError, output: [{ type: 'text', text: value }] });
@@ -112,6 +114,13 @@ const defaultSummary = (name) => (args) => `${name} ${JSON.stringify(args)}`.sli
112
114
  * `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
113
115
  * Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
114
116
  *
117
+ * Gating only `bash` is bypassable when self-evolving tools or write+reload are enabled: the agent writes a tool with the ungated
118
+ * `write` tool, reloads it, and the new tool runs through the same `env.exec`. Gate `write`/`edit` and the reload path too, or set
119
+ * `unlisted: 'deny'` (with `allow` for the tools that may run freely) so a tool nobody listed cannot run. The default `'allow'` gates only
120
+ * what `tools` names.
121
+ *
122
+ * A delegated child (`createDelegateTool`) inherits the parent's approval extensions, so a delegate cannot escape the gate.
123
+ *
115
124
  * It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
116
125
  * not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
117
126
  */
@@ -123,5 +132,9 @@ export function createApprovalExtension(options) {
123
132
  ...(rule !== true && rule.when ? { when: rule.when } : {}),
124
133
  }),
125
134
  }));
126
- return defineExtension({ name: options.name ?? 'boring.approval', wraps });
135
+ const allowed = new Set([...Object.keys(options.tools), ...options.allow ?? []]);
136
+ const hooks = options.unlisted === 'deny'
137
+ ? [hook(ToolTask, { beforeTool: call => allowed.has(call.name) ? undefined : { block: `Not run: ${call.name} is not allowed in this conversation.` } })]
138
+ : [];
139
+ return markGuard(defineExtension({ name: options.name ?? 'boring.approval', wraps, hooks }));
127
140
  }
@@ -55,7 +55,10 @@ export interface ChatTransportAccess {
55
55
  * it in `createPromptContext({ context })`; this handler keeps nothing. Without it the `context` field is ignored.
56
56
  */
57
57
  readonly requestContext?: (value: Readonly<Record<string, unknown>>) => void | Promise<void>;
58
- /** Refuse an effect call. Reading stays governed by `authenticate` and `project`. */
58
+ /**
59
+ * Refuse an effect call. Reading stays governed by `authenticate` and `project`. Without it EVERY effect (submit, abort, answer,
60
+ * withdraw, configure) is allowed to whoever `authenticate` accepted: a host with read-only viewers must pass it.
61
+ */
59
62
  readonly allow?: (operation: 'submit' | 'abort' | 'answer' | 'withdraw' | 'configure') => boolean | Promise<boolean>;
60
63
  }
61
64
  export interface ChatTransportOptions {
@@ -123,6 +126,12 @@ export interface ConversationSocketOptions {
123
126
  * hibernation (the original request is gone by then). The same access the HTTP handler gets from `authenticate`. Null closes the socket.
124
127
  */
125
128
  readonly access: (attachment: ConversationSocketAttachment) => Promise<ChatTransportAccess | null>;
129
+ /**
130
+ * Origins (`https://app.example`) besides the request's own host that may open the socket. A browser upgrade whose `Origin` is
131
+ * neither the request's host nor listed here is refused (a cross-site page would otherwise ride the visitor's cookies); a request with
132
+ * no `Origin` (not a browser) is not subject to this check, so `authenticate` still decides who it is.
133
+ */
134
+ readonly allowedOrigins?: readonly string[];
126
135
  }
127
136
  /** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
128
137
  export declare const CHAT_SOCKET_PROTOCOL = "boring.chat";
@@ -241,6 +241,8 @@ export function createChatTransportHandler(options) {
241
241
  || (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string')
242
242
  || (access.requestContext && !contextValue(input['context'])))
243
243
  return failure(400, 'invalid-request');
244
+ if (!await allowed('submit'))
245
+ return failure(403, 'not-authorized'); // before `prepareInput`: a denied submitter reads nothing
244
246
  let prepared = input['content'];
245
247
  if (access.prepareInput) {
246
248
  try {
@@ -248,7 +250,7 @@ export function createChatTransportHandler(options) {
248
250
  }
249
251
  catch { /* submit what the person typed */ }
250
252
  }
251
- if (!await allowed('submit') || access.revoked?.aborted)
253
+ if (access.revoked?.aborted)
252
254
  return failure(403, 'not-authorized');
253
255
  if (access.requestContext && input['context'] !== undefined)
254
256
  await access.requestContext(input['context']);
@@ -311,7 +313,7 @@ export function createChatTransportHandler(options) {
311
313
  if (error instanceof Error && error.code === 'submission-refused') {
312
314
  return Response.json({ schema: CHAT_TRANSPORT.schema, version: CHAT_TRANSPORT.version, reason: 'submission-refused', message: error.message.slice(0, 500) }, { status: 402, headers });
313
315
  }
314
- return failure(503, error instanceof Error && error.message ? `source-unavailable: ${error.message.slice(0, 200)}` : 'source-unavailable');
316
+ return failure(503, 'source-unavailable'); // the detail stays on the host: a message can carry paths, hosts or credentials
315
317
  }
316
318
  return failure(404, 'unknown-operation');
317
319
  };
@@ -342,6 +344,20 @@ export function routeSubmissions(conversation, admit) {
342
344
  }
343
345
  /** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
344
346
  export const CHAT_SOCKET_PROTOCOL = 'boring.chat';
347
+ /** Whether a WebSocket upgrade comes from the page's own host or a listed origin. No `Origin` header: not a browser, so not subject. */
348
+ function sameOrigin(request, allowed = []) {
349
+ const origin = request.headers.get('origin');
350
+ if (origin === null)
351
+ return true;
352
+ if (allowed.includes(origin))
353
+ return true;
354
+ try {
355
+ return new URL(origin).host === (request.headers.get('host') ?? new URL(request.url).host);
356
+ }
357
+ catch {
358
+ return false;
359
+ }
360
+ }
345
361
  const ownerTag = (owner) => `owner:${owner}`;
346
362
  const conversationTag = (id) => `conversation:${id}`;
347
363
  const tagFor = (to) => to?.conversationId !== undefined ? conversationTag(to.conversationId) : to?.owner !== undefined ? ownerTag(to.owner) : undefined;
@@ -426,6 +442,8 @@ export function createConversationSocketHandler(options) {
426
442
  }
427
443
  async function resume() { await Promise.all(state.getWebSockets().map(socket => attach(socket))); }
428
444
  async function accept(socket, request) {
445
+ if (!sameOrigin(request, options.allowedOrigins))
446
+ return false;
429
447
  const found = await options.authenticate(request);
430
448
  if (!found)
431
449
  return false;
@@ -475,7 +493,7 @@ export function createConversationSocketHandler(options) {
475
493
  return failure(503, 'authentication-unavailable');
476
494
  }
477
495
  if (!accepted)
478
- return failure(401, 'authentication-required');
496
+ return failure(sameOrigin(request, options.allowedOrigins) ? 401 : 403, sameOrigin(request, options.allowedOrigins) ? 'authentication-required' : 'origin-not-allowed');
479
497
  const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map(value => value.trim());
480
498
  return new Response(null, { status: 101, webSocket: pair[0], ...(offered.includes(CHAT_SOCKET_PROTOCOL) ? { headers: { 'sec-websocket-protocol': CHAT_SOCKET_PROTOCOL } } : {}) });
481
499
  },
@@ -359,7 +359,7 @@ export function createConversationsHandler(options) {
359
359
  return failure(error.status, error.status === 413 ? 'request-too-large' : 'invalid-request');
360
360
  if (error instanceof TypeError)
361
361
  return failure(400, 'invalid-request');
362
- return failure(503, error instanceof Error && error.message ? `source-unavailable: ${error.message.slice(0, 200)}` : 'source-unavailable');
362
+ return failure(503, 'source-unavailable');
363
363
  }
364
364
  return failure(404, 'unknown-operation');
365
365
  };
@@ -1,5 +1,36 @@
1
- import type { AgentChange, ToolRegistration } from '@earendil-works/pi-durable';
1
+ import type { AgentChange, ConversationId, ToolRegistration, Tx } from '@earendil-works/pi-durable';
2
2
  export declare const DELEGATE_TOOL = "delegate";
3
+ /** `DelegateDetails.kind`: how a UI recognises a sub-agent call's details, whatever the tool is named. */
4
+ export declare const DELEGATE_DETAILS_KIND = "boring.delegate";
5
+ export type DelegateStatus = 'running' | 'done' | 'failed' | 'cancelled';
6
+ /**
7
+ * What a delegate call publishes for a UI (a sub-agent card), version 1. While the child runs, the call's running details
8
+ * (Pi's `ToolExecutionApi.details`, `pi.live` tool slot) carry it with `status: 'running'`, replaced each time the child's tool
9
+ * calls or tokens change; the tool result's details carry the finished value (`done`, `failed` with `error`, or `cancelled`).
10
+ * Fields are only added under version 1; a change of meaning bumps `version`.
11
+ */
12
+ export type DelegateDetails = {
13
+ readonly kind: typeof DELEGATE_DETAILS_KIND;
14
+ readonly version: 1;
15
+ /** The child agent's id (`DelegateOptions.agent.id`). */
16
+ readonly agent: string;
17
+ /** The child conversation, owned by the delegate call's tool task. */
18
+ readonly conversationId: ConversationId;
19
+ readonly status: DelegateStatus;
20
+ /** Epoch milliseconds the call first started (kept across a replay). */
21
+ readonly startedAt: number;
22
+ /** Milliseconds from `startedAt` to this update (to the end, once finished). */
23
+ readonly durationMs: number;
24
+ /** Tool calls the child's model has made so far (from its committed assistant entries). */
25
+ readonly toolCalls: number;
26
+ /** The child's spend so far, from its `pi.usage`: prompt tokens (cache reads and writes included) and output tokens. */
27
+ readonly tokens: {
28
+ readonly input: number;
29
+ readonly output: number;
30
+ };
31
+ /** Why the child failed (`failed` only). */
32
+ readonly error?: string;
33
+ };
3
34
  export interface DelegateOptions {
4
35
  /**
5
36
  * The child agent, usually a `defineAgent` result (its `id` and native `agent` change). The child conversation selects exactly its
@@ -12,13 +43,21 @@ export interface DelegateOptions {
12
43
  /** Tool name; default `delegate`. Give each delegate of one agent its own name. */
13
44
  readonly name?: string;
14
45
  readonly description?: string;
46
+ /**
47
+ * Runs inside the native commit (`ToolExecutionApi.commit`, Pi's `Tx`) that creates the child, after the child agent is
48
+ * configured: the host copies what the child must carry from the parent (user, workspace binding, permissions) with `tx`.
49
+ * It runs once per child (never on a replay that finds the existing child); a throw aborts the commit, so no child exists.
50
+ */
51
+ readonly onChild?: (parent: ConversationId, child: ConversationId, tx: Tx) => void | Promise<void>;
15
52
  }
16
53
  /**
17
54
  * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
18
55
  * calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
19
56
  * once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
20
57
  * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
21
- * child's final text; `details.conversationId` names the child for a UI that shows it.
58
+ * child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
59
+ * sandbox stays last), so a delegate cannot escape the parent's gate; `details` is a `DelegateDetails` (live while the child runs) for a UI that shows it.
60
+ * `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
22
61
  */
23
62
  export declare function createDelegateTool(options: DelegateOptions): ToolRegistration;
24
63
  //# sourceMappingURL=delegate.d.ts.map
package/dist/delegate.js CHANGED
@@ -1,12 +1,18 @@
1
- import { AssistantEntry, configure, defineTool } from '@earendil-works/pi-durable';
1
+ import { AssistantEntry, LiveDoc, UsageDoc, configure, defineTool } from '@earendil-works/pi-durable';
2
2
  import { Type } from '@earendil-works/pi-ai';
3
+ const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
4
+ const isGuard = (extension) => GUARDS.has(extension);
3
5
  export const DELEGATE_TOOL = 'delegate';
6
+ /** `DelegateDetails.kind`: how a UI recognises a sub-agent call's details, whatever the tool is named. */
7
+ export const DELEGATE_DETAILS_KIND = 'boring.delegate';
4
8
  /**
5
9
  * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
6
10
  * calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
7
11
  * once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
8
12
  * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
9
- * child's final text; `details.conversationId` names the child for a UI that shows it.
13
+ * child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
14
+ * sandbox stays last), so a delegate cannot escape the parent's gate; `details` is a `DelegateDetails` (live while the child runs) for a UI that shows it.
15
+ * `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
10
16
  */
11
17
  export function createDelegateTool(options) {
12
18
  const name = options.name ?? DELEGATE_TOOL;
@@ -19,26 +25,125 @@ export function createDelegateTool(options) {
19
25
  parameters: Type.Object({ task: Type.String({ minLength: 1, description: 'The complete task; the agent sees nothing else of this conversation.' }) }, { additionalProperties: false }),
20
26
  replay: 'safe',
21
27
  execute: async (args, api, context) => {
28
+ const inherited = (await api.agent(context)).extensions.filter(isGuard);
29
+ const agent = withGuards(child, inherited);
22
30
  const childId = await api.commit(async (tx) => {
23
31
  const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
24
32
  if (existing !== undefined)
25
33
  return existing.id;
26
34
  const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } });
27
- await configure(tx, created.id, child);
35
+ await configure(tx, created.id, agent);
36
+ await options.onChild?.(api.conversationId, created.id, tx);
28
37
  return created.id;
29
38
  }, context);
30
- await api.details({ conversationId: childId }, context);
39
+ const startedAt = await api.memo('delegate.startedAt', Date.now(), context);
40
+ const progress = childProgress(api, childId);
41
+ const details = (status, counts, error) => ({
42
+ kind: DELEGATE_DETAILS_KIND, version: 1, agent: options.agent.id, conversationId: childId, status, startedAt,
43
+ durationMs: Math.max(1, Date.now() - startedAt), ...counts, ...(error === undefined ? {} : { error })
44
+ });
45
+ const initial = await progress.read(context);
46
+ await api.details(details('running', initial), context);
31
47
  const conversation = await api.conversation(childId, context);
32
48
  if (conversation === undefined)
33
49
  throw new Error(`Delegate conversation ${childId} is missing`);
34
50
  const submission = await conversation.submit({ type: 'input', content: args.task, requestId: `${name}:${api.taskId}` }, context);
35
- const settled = await submission.wait(context);
36
- if (settled.status !== 'done' || settled.type !== 'input')
37
- throw new Error(`The ${options.agent.id} agent did not answer: ${settled.status}`);
51
+ const live = await publishLive(api, childId, progress, initial, counts => api.details(details('running', counts), context), context);
52
+ // A stopped call ends here: Pi writes its aborted result from the slot, so its details stay the last `running` value.
53
+ const settled = await submission.wait(context).finally(() => live.stop());
54
+ const counts = await progress.read(context);
55
+ if (settled.status !== 'done' || settled.type !== 'input') {
56
+ const reason = settled.status === 'unanswered' ? settled.reason : settled.status;
57
+ const detail = settled.status === 'unanswered' && typeof settled.detail === 'string' && settled.detail ? settled.detail : undefined;
58
+ const error = `The ${options.agent.id} agent did not answer: ${reason}${detail ? ` (${detail})` : ''}`;
59
+ return { output: [{ type: 'text', text: error }], isError: true, details: reason === 'aborted' ? details('cancelled', counts) : details('failed', counts, error) };
60
+ }
38
61
  const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), context);
39
62
  const message = answer?.model?.find(part => part.role === 'assistant');
40
63
  const text = message?.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : '';
41
- return { output: [{ type: 'text', text }], details: { conversationId: childId } };
64
+ return { output: [{ type: 'text', text }], details: details('done', counts) };
42
65
  },
43
66
  });
44
67
  }
68
+ /**
69
+ * The child's counts from its native records: tool calls from its committed assistant entries (scanned once, incrementally,
70
+ * through the invocation's commit), tokens from its `pi.usage` (Pi's `UsageDoc`, both buckets).
71
+ */
72
+ function childProgress(api, childId) {
73
+ let toolCalls = 0, after;
74
+ return {
75
+ async read(context) {
76
+ const scanned = await api.commit(async (tx) => {
77
+ let found = 0, last = after, cursor;
78
+ do {
79
+ const page = await tx.scanEntries({ conversationId: childId, order: 'ascending', ...(last === undefined ? {} : { minEntryId: (last + 1) }) }, 100, cursor);
80
+ for (const entry of page.items) {
81
+ last = entry.id;
82
+ for (const message of entry.model ?? [])
83
+ if (message.role === 'assistant')
84
+ found += message.content.filter(part => part.type === 'toolCall').length;
85
+ }
86
+ cursor = page.next;
87
+ } while (cursor !== undefined);
88
+ return { found, last };
89
+ }, context);
90
+ toolCalls += scanned.found;
91
+ after = scanned.last;
92
+ const usage = await api.snapshot(UsageDoc, childId, context);
93
+ const tokens = { input: 0, output: 0 };
94
+ for (const bucket of [usage?.models, usage?.tools])
95
+ for (const spend of Object.values(bucket ?? {})) {
96
+ tokens.input += (spend.input ?? 0) + (spend.cacheRead ?? 0) + (spend.cacheWrite ?? 0);
97
+ tokens.output += spend.output ?? 0;
98
+ }
99
+ return { toolCalls, tokens };
100
+ },
101
+ };
102
+ }
103
+ /**
104
+ * Publish the child's counts while it runs. Pi's `watchDoc(LiveDoc)` on the child is the trigger: a frame whose run shape changed
105
+ * (a response committed, a tool round started or ended; never a streamed partial) schedules one read of the counts, outside the
106
+ * listener so the watch never waits on it, coalescing the frames that arrive meanwhile. The details are replaced only when the
107
+ * counts changed. `stop()` ends the watch and waits for a publish in flight.
108
+ */
109
+ async function publishLive(api, childId, progress, initial, publish, context) {
110
+ const watch = await api.watchDoc(LiveDoc, childId, context);
111
+ if (watch === undefined)
112
+ return { stop: async () => undefined };
113
+ let shape = '', published = JSON.stringify(initial), stopped = false, dirty = false;
114
+ let reading, committing = Promise.resolve();
115
+ const drain = async () => {
116
+ while (dirty && !stopped) {
117
+ dirty = false;
118
+ const counts = await progress.read(context);
119
+ const key = JSON.stringify(counts);
120
+ if (stopped || key === published)
121
+ continue;
122
+ published = key;
123
+ // Not awaited: Pi paces running-details commits (`progress.outputIntervalMs`) and a newer value replaces a pending one.
124
+ committing = publish(counts).catch(() => undefined);
125
+ }
126
+ };
127
+ watch.start(async (value) => {
128
+ const next = runShape(value);
129
+ if (stopped || next === shape)
130
+ return;
131
+ shape = next;
132
+ dirty = true;
133
+ reading ??= drain().catch(() => undefined).finally(() => { reading = undefined; });
134
+ });
135
+ return { async stop() { stopped = true; await watch.stop(); await reading; await committing; } };
136
+ }
137
+ const runShape = (live) => JSON.stringify([live?.run?.taskId ?? null, live?.generation?.attempt ?? null, live?.generation?.message === undefined,
138
+ (live?.tools ?? []).map(slot => `${slot.callId}:${slot.status}`)]);
139
+ /** `change` with the parent's guard extensions appended to its extension selection (an array or `add`; an unset selection already inherits the parent's). */
140
+ function withGuards(change, guards) {
141
+ const { extensions } = change;
142
+ if (!guards.length || extensions === undefined || extensions === null)
143
+ return change;
144
+ if (Array.isArray(extensions))
145
+ return { ...change, extensions: [...extensions, ...guards.filter(guard => !extensions.some(own => own.name === guard.name))] };
146
+ const selection = extensions;
147
+ const present = selection.add ?? [];
148
+ return { ...change, extensions: { ...selection, add: [...present, ...guards.filter(guard => !present.some(own => own.name === guard.name))] } };
149
+ }
package/dist/mentions.js CHANGED
@@ -113,7 +113,7 @@ export function createMentionResolver(options) {
113
113
  }
114
114
  const cut = bytes.length > room;
115
115
  used += Math.min(bytes.length, room);
116
- added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${cut ? ` truncated="showing the first ${new TextEncoder().encode(text).length} of ${bytes.length} bytes"` : ''}>\n${text.replace(/<\/file>/g, '<\\/file>')}\n</file>` });
116
+ added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${cut ? ` truncated="showing the first ${new TextEncoder().encode(text).length} of ${bytes.length} bytes"` : ''}>\n${text.replace(/<(\s*\/?\s*file)\b/gi, '<\\$1')}\n</file>` });
117
117
  }
118
118
  return [...parts, ...added];
119
119
  }
@@ -14,7 +14,9 @@ const produced = new WeakSet();
14
14
  * assistant continuation) at the tail. Pure: the input array is not changed.
15
15
  */
16
16
  export function withRequestContext(messages, text, tag = 'request-context') {
17
- const message = { role: 'user', content: [{ type: 'text', text: `<${tag}>\n${text}\n</${tag}>` }], timestamp: Date.now() };
17
+ // The text may be browser-supplied: neutralise every opening or closing `tag` in it (any case, spaces allowed) so it cannot end the wrapper.
18
+ const inner = text.replace(new RegExp(`<(\\s*/?\\s*${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![\\w-])`, 'gi'), '<\\$1');
19
+ const message = { role: 'user', content: [{ type: 'text', text: `<${tag}>\n${inner}\n</${tag}>` }], timestamp: Date.now() };
18
20
  produced.add(message);
19
21
  let end = messages.length;
20
22
  while (end > 0 && produced.has(messages[end - 1]))
package/dist/sandbox.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { Extension } from '@earendil-works/pi-durable';
2
2
  import type { JsonValue } from '@earendil-works/chord';
3
- /** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
4
3
  export declare const SANDBOX_DETAILS = "boring.sandbox";
5
4
  /** What the host declares about one tool. An unset field is not declared: a call is recorded unless the host declares it safe. */
6
5
  export interface SandboxFlags {
package/dist/sandbox.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { defineExtension, hook, ToolTask } from '@earendil-works/pi-durable';
2
2
  /** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
3
+ const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
4
+ const markGuard = (extension) => { GUARDS.add(extension); return extension; };
3
5
  export const SANDBOX_DETAILS = 'boring.sandbox';
4
6
  /** The acknowledgement a recorded call returns when the host does not supply its own. */
5
7
  export const defaultAck = (toolName) => `Simulated: ${toolName} was recorded and not run. Nothing was changed.`;
@@ -45,13 +47,13 @@ export function createSandboxExtension(options) {
45
47
  const recordOnly = new Set(options.tools.filter(name => sandboxDecision(options.flags?.[name]) === 'record'));
46
48
  const simulate = options.simulate ?? ((toolName) => defaultAck(toolName));
47
49
  const wraps = [...recordOnly].map(name => ({ tool: name, wrap: (tool) => recorded(tool, simulate) }));
48
- return defineExtension({
50
+ return markGuard(defineExtension({
49
51
  name: options.name ?? 'boring.sandbox',
50
52
  wraps,
51
53
  hooks: [hook(ToolTask, {
52
54
  beforeTool: call => covered.has(call.name) ? undefined : { block: `Not run: ${call.name} is not covered by the sandbox.` },
53
55
  })],
54
- });
56
+ }));
55
57
  }
56
58
  /**
57
59
  * Setup-time check over the public `Extension.wraps` shape. Throws when an extension selected after `sandbox` wraps a tool the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -156,8 +156,8 @@
156
156
  "@earendil-works/pi-durable": "~1.2.0",
157
157
  "@earendil-works/pi-ai": "~1.2.0",
158
158
  "@earendil-works/chord": "~1.2.0",
159
- "@hachej/boring-files": "^0.4.8",
160
- "@hachej/boring-ui-kit": "^0.4.8",
159
+ "@hachej/boring-files": "^0.4.10",
160
+ "@hachej/boring-ui-kit": "^0.4.10",
161
161
  "xlsx": "^0.20.3"
162
162
  },
163
163
  "peerDependenciesMeta": {