@agentic-kit/pi-host 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
4
+ Copyright (c) 2025 Constructive <developers@constructive.io>
5
+ Copyright (c) 2020-present, Interweb, Inc.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @agentic-kit/pi-host
2
+
3
+ The host side of a headless agent run. `@agentic-kit` already owns the loop
4
+ (`@agentic-kit/agent`), the tools (`@agentic-kit/pi`) and the gating policy
5
+ (`@agentic-kit/harness`); what a workload has to supply is the three small
6
+ host-shaped things those packages ask for, and nothing here re-implements any of
7
+ them.
8
+
9
+ | Existing contract | What this package supplies |
10
+ | --- | --- |
11
+ | `GateHost` (`@agentic-kit/harness`) | `createThreadGateHost` — approval through the conversation thread |
12
+ | `ConfirmGate` (`createConfirmGate`) | `createGatedToolset` — wraps `AgentTool.execute` with the harness gate |
13
+ | `PiToolsHost` (`@agentic-kit/pi/host`) | `createPiToolsHost` — built from values, not from a desktop session |
14
+ | `AgentEvent` (`@agentic-kit/agent`) | `TranscriptWriter` — events → `agent_message` / `agent_task` |
15
+
16
+ ## Approval is a message, so `hasUI` is true
17
+
18
+ `GateHost.hasUI` does not mean "a window exists": it means a human decision is
19
+ obtainable. A Job can obtain one — it writes the pending call into the thread at
20
+ `approval-requested` and waits for the human to echo it back — so the runner
21
+ reports `true`. Reporting `false` would tell the harness no decision is possible
22
+ and every tool in `MUTATING_DB_TOOLS` would be refused before it ran.
23
+
24
+ The gate is the harness's. `createGatedToolset` calls `gate.onToolCall(...)` and,
25
+ when the gate blocks, returns the gate's own reason to the model as an ordinary
26
+ tool result — a declined call is information the agent can act on, not a crash.
27
+ pi's fs/edit/bash/git tools are not in `MUTATING_DB_TOOLS` and are never gated:
28
+ the whole point of the coding lane is that editing the clone is free.
29
+
30
+ ## Credentials do not go in the clone
31
+
32
+ `@agentic-kit/pi` resolves tenant context with `resolveProjectContext(cwd)`,
33
+ which reads `<cwd>/.env` for `ACCESS_TOKEN` and `DATABASE_ID`. In this lane `cwd`
34
+ is a cloned git repository that the agent is about to `git add -A` and push, so
35
+ writing platform credentials there is a credential-exfiltration bug waiting for
36
+ a careless commit. The coding lane therefore runs with the coding tool set and no
37
+ project context in the work tree. A lane that genuinely needs control-plane tools
38
+ calls `materializeProjectContext({ dir, workTree, ... })`, which writes a `0600`
39
+ `.env` **outside** the work tree and throws
40
+ `ProjectContextInsideWorkTreeError` if asked to do otherwise.
41
+
42
+ ## Persona
43
+
44
+ `loadPersona` / `loadPersonaSkills` / `selectPersona` turn an `agent_persona` row
45
+ and the `agent_resource` rows it names into the model, system prompt, temperature
46
+ and tool subset for the run. A persona that asks for a tool the lane does not
47
+ carry throws (`UnknownPersonaToolError`) rather than running with a quietly
48
+ smaller toolbox, and a named resource that is missing or inactive throws rather
49
+ than dropping a skill the operator expected to be in force.
50
+
51
+ ## Tests
52
+
53
+ `pnpm test` — the gate against a fake thread (approve / decline / cancel /
54
+ timeout), the writer against a fake agent run, persona selection, the gated
55
+ toolset, and the project-context guard.
56
+
57
+ ---
58
+
59
+ ## Education and Tutorials
60
+
61
+ 1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
62
+ Get started with modular databases in minutes. Install prerequisites and deploy your first module.
63
+
64
+ 2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
65
+ Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
66
+
67
+ 3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
68
+ Master the workflow for adding, organizing, and managing database changes with pgpm.
69
+
70
+ 4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
71
+ Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
72
+
73
+ 5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
74
+ Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
75
+
76
+ 6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
77
+ Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
78
+
79
+ 7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
80
+ Common issues and solutions for pgpm, PostgreSQL, and testing.
81
+
82
+ ## Related Constructive Tooling
83
+
84
+ ### 📦 Package Management
85
+
86
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
87
+
88
+ ### 🧪 Testing
89
+
90
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
91
+ * [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
92
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
93
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
94
+ * [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
95
+ * [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
96
+
97
+ ### 🧠 Parsing & AST
98
+
99
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
100
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
101
+ * [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
102
+ * [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
103
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
104
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
105
+
106
+ ### 📚 Documentation & Skills
107
+
108
+ * [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
109
+
110
+ Install skills for AI coding agents:
111
+
112
+ ```bash
113
+ # All platform skills (security, blueprints, codegen, billing, etc.)
114
+ npx skills add constructive-io/constructive-skills
115
+
116
+ # Individual repo skills (pgpm, testing, CLI, search, etc.)
117
+ npx skills add https://github.com/constructive-io/constructive --skill pgpm
118
+ npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
119
+ ```
120
+
121
+ ## Credits
122
+
123
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
124
+
125
+ ## Disclaimer
126
+
127
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
128
+
129
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
@@ -0,0 +1,47 @@
1
+ import type { AgentEvent } from '@agentic-kit/agent';
2
+ import type { TaskWriter, TodoItem, Transcript } from '@agentic-kit/agent-conversation';
3
+ /** Tool names whose arguments carry the agent's todo list. */
4
+ export declare const TODO_TOOL_NAMES: string[];
5
+ export interface TranscriptWriterOptions {
6
+ transcript: Transcript;
7
+ /** Absent when the tenant's agent module has no task surface for this run. */
8
+ tasks?: TaskWriter;
9
+ /** Tool names whose arguments carry a todo list. */
10
+ todoToolNames?: string[];
11
+ }
12
+ /**
13
+ * Translate a run's events into thread writes.
14
+ *
15
+ * Nothing here is best-effort: a failed write is kept and rethrown from
16
+ * `drain()`, because a transcript that silently lost the middle of a run is
17
+ * worse than a run that fails.
18
+ */
19
+ export declare class TranscriptWriter {
20
+ private readonly options;
21
+ private chain;
22
+ private readonly pending;
23
+ private readonly todoToolNames;
24
+ private failure;
25
+ constructor(options: TranscriptWriterOptions);
26
+ /** Subscribe this writer to an agent: `agent.subscribe(writer.handler)`. */
27
+ readonly handler: (event: AgentEvent) => void;
28
+ /** Wait for every write queued so far, and surface the first that failed. */
29
+ drain(): Promise<void>;
30
+ private enqueue;
31
+ private apply;
32
+ private syncTodos;
33
+ }
34
+ /** The prose of an assistant message; thinking and tool calls are not prose. */
35
+ export declare function assistantText(message: {
36
+ content: unknown;
37
+ }): string;
38
+ /** The text of a tool result, as the transcript records it. */
39
+ export declare function resultText(result: {
40
+ content: unknown;
41
+ }): string;
42
+ /**
43
+ * The todo list a todo tool was called with. Returns null when the arguments do
44
+ * not carry one; throws when they carry a malformed one, because a todo list the
45
+ * tasks table cannot represent is a bug rather than an absence.
46
+ */
47
+ export declare function parseTodos(args: Record<string, unknown>): TodoItem[] | null;
package/esm/events.js ADDED
@@ -0,0 +1,161 @@
1
+ // `AgentEvent` → the thread.
2
+ //
3
+ // `@agentic-kit/agent` already emits everything a transcript needs, so this is a
4
+ // translation and nothing else: prose to a message, a tool call to a ToolPart
5
+ // rewritten in place as it runs, the agent's todo list to `agent_task` rows.
6
+ // Writes are serialized through one promise chain so the thread's order matches
7
+ // the run's order; `drain()` is how a composition root waits for the tail.
8
+ import { completeToolPart, failToolPart, toolPart } from '@agentic-kit/agent-conversation';
9
+ import { asOneOf, asRecord, asString } from '@constructive-io/coerce';
10
+ /** The text of a content block, or '' when the block carries none. */
11
+ const blockText = (block) => asString(asRecord(block)?.text) ?? '';
12
+ /** Tool names whose arguments carry the agent's todo list. */
13
+ export const TODO_TOOL_NAMES = ['todo_write', 'update_plan', 'write_todos'];
14
+ /**
15
+ * Translate a run's events into thread writes.
16
+ *
17
+ * Nothing here is best-effort: a failed write is kept and rethrown from
18
+ * `drain()`, because a transcript that silently lost the middle of a run is
19
+ * worse than a run that fails.
20
+ */
21
+ export class TranscriptWriter {
22
+ options;
23
+ chain = Promise.resolve();
24
+ pending = new Map();
25
+ todoToolNames;
26
+ failure;
27
+ constructor(options) {
28
+ this.options = options;
29
+ this.todoToolNames = new Set(options.todoToolNames ?? TODO_TOOL_NAMES);
30
+ }
31
+ /** Subscribe this writer to an agent: `agent.subscribe(writer.handler)`. */
32
+ handler = (event) => {
33
+ this.enqueue(() => this.apply(event));
34
+ };
35
+ /** Wait for every write queued so far, and surface the first that failed. */
36
+ async drain() {
37
+ await this.chain;
38
+ if (this.failure !== undefined) {
39
+ const failure = this.failure;
40
+ this.failure = undefined;
41
+ throw failure;
42
+ }
43
+ }
44
+ enqueue(work) {
45
+ this.chain = this.chain.then(async () => {
46
+ if (this.failure !== undefined)
47
+ return;
48
+ try {
49
+ await work();
50
+ }
51
+ catch (error) {
52
+ this.failure = error;
53
+ }
54
+ });
55
+ }
56
+ async apply(event) {
57
+ switch (event.type) {
58
+ case 'message_end': {
59
+ if (event.message.role !== 'assistant')
60
+ return;
61
+ const text = assistantText(event.message);
62
+ if (text)
63
+ await this.options.transcript.appendText(text);
64
+ return;
65
+ }
66
+ case 'tool_execution_start': {
67
+ const part = toolPart({
68
+ toolName: event.toolName,
69
+ toolCallId: event.toolCallId,
70
+ input: event.args
71
+ });
72
+ const message = await this.options.transcript.appendToolPart(part);
73
+ this.pending.set(event.toolCallId, { messageId: message.id, part });
74
+ await this.syncTodos(event.toolName, event.args);
75
+ return;
76
+ }
77
+ case 'tool_execution_end': {
78
+ const call = this.pending.get(event.toolCallId);
79
+ if (!call) {
80
+ throw new Error(`tool call ${event.toolCallId} (${event.toolName}) ended without having started`);
81
+ }
82
+ this.pending.delete(event.toolCallId);
83
+ const output = resultText(event.result);
84
+ const next = event.isError
85
+ ? failToolPart(call.part, output || 'The tool failed without a message')
86
+ : completeToolPart(call.part, output);
87
+ await this.options.transcript.updateToolPart(call.messageId, next);
88
+ return;
89
+ }
90
+ case 'tool_decision_pending': {
91
+ // The lane's tools declare no `decision` schema — approval is the
92
+ // harness gate, answered through the thread. An in-band decision would
93
+ // stall the run forever, so it fails loudly instead.
94
+ throw new Error(`tool ${event.toolName} (${event.toolCallId}) asked for an in-band decision, which this runner does not serve`);
95
+ }
96
+ default:
97
+ return;
98
+ }
99
+ }
100
+ async syncTodos(toolName, args) {
101
+ if (!this.options.tasks || !this.todoToolNames.has(toolName))
102
+ return;
103
+ const todos = parseTodos(args);
104
+ if (todos)
105
+ await this.options.tasks.sync(todos);
106
+ }
107
+ }
108
+ /** The prose of an assistant message; thinking and tool calls are not prose. */
109
+ export function assistantText(message) {
110
+ const content = message.content;
111
+ const prose = asString(content);
112
+ if (prose)
113
+ return prose.trim();
114
+ if (!Array.isArray(content))
115
+ return '';
116
+ return content
117
+ .map((block) => (asRecord(block)?.type === 'text' ? blockText(block) : ''))
118
+ .join('')
119
+ .trim();
120
+ }
121
+ /** The text of a tool result, as the transcript records it. */
122
+ export function resultText(result) {
123
+ const content = result.content;
124
+ const text = asString(content);
125
+ if (text)
126
+ return text;
127
+ if (!Array.isArray(content))
128
+ return '';
129
+ return content.map(blockText).join('').trim();
130
+ }
131
+ const TODO_STATUSES = ['pending', 'in_progress', 'completed', 'failed'];
132
+ /**
133
+ * The todo list a todo tool was called with. Returns null when the arguments do
134
+ * not carry one; throws when they carry a malformed one, because a todo list the
135
+ * tasks table cannot represent is a bug rather than an absence.
136
+ */
137
+ export function parseTodos(args) {
138
+ const raw = args.todos ?? args.items ?? args.plan;
139
+ if (raw === undefined || raw === null)
140
+ return null;
141
+ if (!Array.isArray(raw))
142
+ throw new Error('todo tool called with a non-list todo argument');
143
+ return raw.map((entry, index) => {
144
+ const item = asRecord(entry);
145
+ if (!item)
146
+ throw new Error(`todo ${index} is not an object`);
147
+ const description = asString(item.description ?? item.content ?? item.title ?? item.step);
148
+ if (!description)
149
+ throw new Error(`todo ${index} carries no description`);
150
+ const status = asOneOf(item.status ?? 'pending', TODO_STATUSES);
151
+ if (!status) {
152
+ throw new Error(`todo ${index} has status "${String(item.status)}", which agent_task does not model`);
153
+ }
154
+ const error = asString(item.error);
155
+ return {
156
+ description: description.trim(),
157
+ status,
158
+ ...(error ? { error } : {})
159
+ };
160
+ });
161
+ }
package/esm/gate.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Inbox, ToolPart, Transcript } from '@agentic-kit/agent-conversation';
2
+ import type { GateHost } from '@agentic-kit/harness';
3
+ export interface ThreadGateHostOptions {
4
+ transcript: Transcript;
5
+ inbox: Inbox;
6
+ /** How long a pending approval waits before it is treated as declined. */
7
+ approvalTimeoutMs?: number;
8
+ /** Approval ids, as a value — a suite passes a counter. */
9
+ newApprovalId?: () => string;
10
+ /** Called when the human asked the run to stop while an approval was pending. */
11
+ onCancel?: (reason: string | undefined) => void;
12
+ /** Called with every tool part the gate writes, so a caller can trace the run. */
13
+ onToolPart?: (part: ToolPart) => void;
14
+ }
15
+ export declare const DEFAULT_APPROVAL_TIMEOUT_MS: number;
16
+ /**
17
+ * The harness's `GateHost` plus the drain its one synchronous method needs.
18
+ *
19
+ * `notifyToolSkipped` returns void, so its transcript write cannot be awaited
20
+ * where it is called. It is queued instead, and `drain()` — awaited by the
21
+ * composition root once the run ends — rethrows the first failure, so a write
22
+ * that fails still fails the Job rather than disappearing into a `catch`.
23
+ */
24
+ export type ThreadGateHost = GateHost & {
25
+ drain(): Promise<void>;
26
+ };
27
+ /**
28
+ * A `GateHost` whose UI is the thread.
29
+ *
30
+ * The pending call is one message whose single ToolPart is rewritten in place as
31
+ * the decision arrives, so the thread reads as a conversation and the state
32
+ * machine in `@agentic-kit/agent-conversation` is the only place the
33
+ * lifecycle is enforced.
34
+ */
35
+ export declare function createThreadGateHost(options: ThreadGateHostOptions): ThreadGateHost;
package/esm/gate.js ADDED
@@ -0,0 +1,88 @@
1
+ // The GateHost the harness already defines, answered by the conversation.
2
+ //
3
+ // `@agentic-kit/harness` owns *what* is gated (`MUTATING_DB_TOOLS`), the prompt
4
+ // wording (`buildConfirmPrompt`) and the decline guard; a host only supplies the
5
+ // three capabilities in `GateHost`. Desktop answers `confirmTool` with a dialog.
6
+ // A Job answers it with a message: it writes the tool call into the thread at
7
+ // `approval-requested` and waits for the human to echo the part back at
8
+ // `approval-responded`. That is the same contract, resolved asynchronously —
9
+ // which is exactly why the runner reports `hasUI: true`. Reporting false would
10
+ // tell the gate no decision can ever be obtained and block every mutating tool.
11
+ import { denyToolPart, failToolPart, isApprovalEvent, requestApproval, respondToApproval, toolPart } from '@agentic-kit/agent-conversation';
12
+ export const DEFAULT_APPROVAL_TIMEOUT_MS = 15 * 60 * 1000;
13
+ /**
14
+ * A `GateHost` whose UI is the thread.
15
+ *
16
+ * The pending call is one message whose single ToolPart is rewritten in place as
17
+ * the decision arrives, so the thread reads as a conversation and the state
18
+ * machine in `@agentic-kit/agent-conversation` is the only place the
19
+ * lifecycle is enforced.
20
+ */
21
+ export function createThreadGateHost(options) {
22
+ const { transcript, inbox, approvalTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS, newApprovalId = () => `approval-${Math.random().toString(36).slice(2, 10)}`, onCancel, onToolPart } = options;
23
+ const emit = (part) => {
24
+ onToolPart?.(part);
25
+ return part;
26
+ };
27
+ const confirmTool = async (toolCallId, title, message, preview) => {
28
+ const pending = toolPart({
29
+ toolName: 'confirm',
30
+ toolCallId,
31
+ input: { title, message, ...(preview ? { preview } : {}) }
32
+ });
33
+ const requested = emit(requestApproval(pending, newApprovalId()));
34
+ const written = await transcript.appendToolPart(requested);
35
+ const outcome = await inbox.waitFor(isApprovalEvent, { timeoutMs: approvalTimeoutMs });
36
+ if (outcome.event && outcome.event.toolCallId === toolCallId) {
37
+ // Two writes on a decline, on purpose: `approval-responded` is the human's
38
+ // answer and `output-denied` is the call's outcome, and the transcript is
39
+ // read as a history — collapsing them would lose who declined and why.
40
+ const responded = respondToApproval(requested, {
41
+ approved: outcome.event.approved,
42
+ reason: outcome.event.reason
43
+ });
44
+ await transcript.updateToolPart(written.id, emit(responded));
45
+ if (!outcome.event.approved) {
46
+ await transcript.updateToolPart(written.id, emit(denyToolPart(responded, outcome.event.reason ?? 'Declined by the user')));
47
+ }
48
+ return outcome.event.approved;
49
+ }
50
+ if (outcome.event) {
51
+ // A decision arrived for a call this gate is not waiting on. The gate is
52
+ // serial — one pending confirm at a time — so this is a malformed client,
53
+ // not a race, and silently dropping it would strand the run.
54
+ throw new Error(`approval for tool call ${outcome.event.toolCallId} arrived while ${toolCallId} was pending`);
55
+ }
56
+ if (outcome.cancelled) {
57
+ onCancel?.(outcome.cancelled.reason);
58
+ await transcript.updateToolPart(written.id, emit(denyToolPart(requested, outcome.cancelled.reason ?? 'Cancelled by the user')));
59
+ return false;
60
+ }
61
+ await transcript.updateToolPart(written.id, emit(failToolPart(requested, `No decision within ${Math.round(approvalTimeoutMs / 1000)}s`)));
62
+ return false;
63
+ };
64
+ // Queued notices, serialized so the transcript keeps the order the gate saw,
65
+ // and their first failure, kept for `drain()` to rethrow.
66
+ let queue = Promise.resolve();
67
+ let failure;
68
+ return {
69
+ hasUI: true,
70
+ confirmTool,
71
+ notifyToolSkipped: (toolCallId) => {
72
+ queue = queue.then(async () => {
73
+ try {
74
+ await transcript.appendText(`Skipped a repeat of a declined tool call (${toolCallId}).`);
75
+ }
76
+ catch (error) {
77
+ failure ??= error;
78
+ }
79
+ });
80
+ },
81
+ drain: async () => {
82
+ await queue;
83
+ if (failure !== undefined) {
84
+ throw new Error(`failed to write a skipped-tool notice to the thread: ${failure instanceof Error ? failure.message : String(failure)}`, { cause: failure });
85
+ }
86
+ }
87
+ };
88
+ }
package/esm/host.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { ToolsHost } from '@agentic-kit/pi/host';
2
+ export interface PiHostValues {
3
+ userId: string;
4
+ accessToken: string;
5
+ apiKey?: string;
6
+ apiEndpoint?: string;
7
+ modulesEndpoint?: string;
8
+ }
9
+ /**
10
+ * A `ToolsHost` built from values.
11
+ *
12
+ * The desktop host reads an Electron singleton; a Job has a token and two
13
+ * endpoints, and nothing else. It offers no preview token, no data-auth broker
14
+ * and no step-up, so the data-plane tools decline cleanly rather than half-work,
15
+ * and no `deliverSecret`, so `create_api_key` refuses to mint a secret that has
16
+ * nowhere safe to go.
17
+ */
18
+ export declare function createPiToolsHost(values: PiHostValues): ToolsHost;
package/esm/host.js ADDED
@@ -0,0 +1,26 @@
1
+ // The `ToolsHost` a Job can honestly provide.
2
+ /**
3
+ * A `ToolsHost` built from values.
4
+ *
5
+ * The desktop host reads an Electron singleton; a Job has a token and two
6
+ * endpoints, and nothing else. It offers no preview token, no data-auth broker
7
+ * and no step-up, so the data-plane tools decline cleanly rather than half-work,
8
+ * and no `deliverSecret`, so `create_api_key` refuses to mint a secret that has
9
+ * nowhere safe to go.
10
+ */
11
+ export function createPiToolsHost(values) {
12
+ if (!values.accessToken)
13
+ throw new Error('a pi tools host needs an access token');
14
+ return {
15
+ account: () => ({
16
+ userId: values.userId,
17
+ accessToken: values.accessToken,
18
+ ...(values.apiKey ? { apiKey: values.apiKey } : {})
19
+ }),
20
+ backendConfig: () => ({
21
+ ...(values.apiEndpoint ? { apiEndpoint: values.apiEndpoint } : {}),
22
+ ...(values.modulesEndpoint ? { modulesEndpoint: values.modulesEndpoint } : {})
23
+ }),
24
+ signInHint: 'This run was launched without a usable platform token.'
25
+ };
26
+ }
package/esm/index.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type { TranscriptWriterOptions } from './events';
2
+ export { assistantText, parseTodos, resultText, TODO_TOOL_NAMES, TranscriptWriter } from './events';
3
+ export type { ThreadGateHost, ThreadGateHostOptions } from './gate';
4
+ export { createThreadGateHost, DEFAULT_APPROVAL_TIMEOUT_MS } from './gate';
5
+ export type { PiHostValues } from './host';
6
+ export { createPiToolsHost } from './host';
7
+ export type { MeteredIdentity, MeteredModel, MeteredModelOptions } from './model';
8
+ export { createMeteredModel, gatewayApiRoot, meteringHeaders } from './model';
9
+ export type { LoadPersonaInput, PersonaConfig, PersonaRow, PersonaSelection, SelectPersonaInput, SkillResource } from './persona';
10
+ export { loadPersona, loadPersonaSkills, PersonaModelUnresolvedError, PersonaNotFoundError, selectPersona, UnknownPersonaToolError } from './persona';
11
+ export type { ProjectContextRequest } from './project-context';
12
+ export { isInside, materializeProjectContext, ProjectContextInsideWorkTreeError } from './project-context';
13
+ export type { GatedToolset, GatedToolsetOptions } from './tools';
14
+ export { CLONE_GATE_DEPS, createGatedToolset } from './tools';
15
+ export type { WorkspaceToolsOptions } from './workspace-tools';
16
+ export { createWorkspaceTools, DEFAULT_COMMAND_TIMEOUT_MS, MAX_READ_BYTES, OutsideWorkspaceError, WORKSPACE_TOOL_NAMES } from './workspace-tools';
package/esm/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // @agentic-kit/pi-host — the host half of a headless agent run: the
2
+ // harness's `GateHost` answered by the conversation thread, persona selection,
3
+ // the gated toolset, and `AgentEvent` → transcript. Every dependency arrives as
4
+ // a value; nothing here knows what a `ctx` is.
5
+ export { assistantText, parseTodos, resultText, TODO_TOOL_NAMES, TranscriptWriter } from './events';
6
+ export { createThreadGateHost, DEFAULT_APPROVAL_TIMEOUT_MS } from './gate';
7
+ export { createPiToolsHost } from './host';
8
+ export { createMeteredModel, gatewayApiRoot, meteringHeaders } from './model';
9
+ export { loadPersona, loadPersonaSkills, PersonaModelUnresolvedError, PersonaNotFoundError, selectPersona, UnknownPersonaToolError } from './persona';
10
+ export { isInside, materializeProjectContext, ProjectContextInsideWorkTreeError } from './project-context';
11
+ export { CLONE_GATE_DEPS, createGatedToolset } from './tools';
12
+ export { createWorkspaceTools, DEFAULT_COMMAND_TIMEOUT_MS, MAX_READ_BYTES, OutsideWorkspaceError, WORKSPACE_TOOL_NAMES } from './workspace-tools';
package/esm/model.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { AssistantMessageEventStream, Context, ModelDescriptor, StreamOptions } from '@agentic-kit/chat';
2
+ /** Who the inference is billed to. Mirrors the http runtime's `AgentHeaders`. */
3
+ export interface MeteredIdentity {
4
+ databaseId: string;
5
+ entityId?: string | null;
6
+ organizationId?: string | null;
7
+ actorId?: string | null;
8
+ }
9
+ export interface MeteredModelOptions extends MeteredIdentity {
10
+ /** `AGENTIC_SERVER_URL` — the gateway's root, with or without `/v1`. */
11
+ agenticServerUrl: string;
12
+ /** The model id the persona selected. */
13
+ model: string;
14
+ /** Injectable for tests; defaults to the runtime's `fetch`. */
15
+ fetchImpl?: typeof fetch;
16
+ }
17
+ export interface MeteredModel {
18
+ model: ModelDescriptor;
19
+ /** `Agent`'s `streamFn`. */
20
+ streamFn: (model: ModelDescriptor, context: Context, options?: StreamOptions) => AssistantMessageEventStream;
21
+ }
22
+ /**
23
+ * The identity headers `agentic-server` meters against. `X-Database-Id` is
24
+ * required; the billing entity falls back the way the node runtime falls back,
25
+ * so a run launched without an entity still bills the actor's personal org.
26
+ */
27
+ export declare function meteringHeaders(identity: MeteredIdentity): Record<string, string>;
28
+ /**
29
+ * The gateway's OpenAI-compatible api root, `<host>/v1`, whichever form the
30
+ * projection took.
31
+ *
32
+ * `AGENTIC_SERVER_URL` is documented as the bare root, but an operator reading a
33
+ * provider's own docs sets it with `/v1` — and `resolveMeteredGateway` accepts
34
+ * both, so the two halves of one stack disagreed: appending `/v1` unconditionally
35
+ * posted to `<host>/v1/v1/chat/completions`, which the gateway answers with a 404
36
+ * that surfaces as "no assistant message" naming no url.
37
+ */
38
+ export declare function gatewayApiRoot(agenticServerUrl: string): string;
39
+ export declare function createMeteredModel(options: MeteredModelOptions): MeteredModel;