@clearlysh/beehaven 0.8.16
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 +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- package/skills/pair-on-canvas/SKILL.md +92 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-omni-principal — Omni's own tools, moved out of the Mac app.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Omni's tool set lived in `apps/electron/electron/ea-agent.ts` and was handed
|
|
7
|
+
* to an SDK `query()` that ELECTRON spawned. That made a second `claude` on the
|
|
8
|
+
* machine — the daemon already runs one properly (`daemon-omni.ts`) — on the
|
|
9
|
+
* same BYOK login, with neither aware of the other's conversation. Verified by
|
|
10
|
+
* process tree: two `claude --output-format stream-json` children, one under
|
|
11
|
+
* `Clearly Dev.app`, one under `daemon-entry.js`.
|
|
12
|
+
*
|
|
13
|
+
* These are the same tools, defined the same way — `createSdkMcpServer` +
|
|
14
|
+
* `tool()`, dispatched IN PROCESS with no subprocess, stdio or socket — just
|
|
15
|
+
* living where the session lives now. Nothing here speaks MCP over a wire; the
|
|
16
|
+
* name describes the schema shape, not a transport.
|
|
17
|
+
*
|
|
18
|
+
* ## The context is the whole design
|
|
19
|
+
*
|
|
20
|
+
* A tool needs one of two things, never both, and the split is what let this
|
|
21
|
+
* move at all:
|
|
22
|
+
*
|
|
23
|
+
* • **`invokeRpc`** — the workspace. Memory, the board, design docs, comments.
|
|
24
|
+
* These were already round-tripping to the DO from Electron, so moving them
|
|
25
|
+
* changes only which process makes the call.
|
|
26
|
+
* • **`emit` / `ask`** — the PRINCIPAL's screen. Replies, questions, windows,
|
|
27
|
+
* previews. These are the reason the move is not trivial: the daemon has no
|
|
28
|
+
* UI, so it emits and whatever is attached (the Mac app's chat, the avatar
|
|
29
|
+
* panel, a CLI) renders. `ask` additionally BLOCKS until an answer returns,
|
|
30
|
+
* which is what makes a question a question rather than a remark.
|
|
31
|
+
*
|
|
32
|
+
* ⚠ `ask` MUST be able to time out and MUST distinguish its outcomes. A turn is
|
|
33
|
+
* parked inside the tool call while it waits; "they chose B", "they dismissed
|
|
34
|
+
* it" and "they never saw it" warrant opposite behaviour, and collapsing them is
|
|
35
|
+
* how an agent decides silence is consent.
|
|
36
|
+
*/
|
|
37
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
38
|
+
import { z } from 'zod';
|
|
39
|
+
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* ⚠ `ticket.data` IS WRITTEN AS AN OBJECT AND READ BACK AS A JSON STRING, and
|
|
42
|
+
* `ticket-update` REPLACES it wholesale. Every write here is therefore
|
|
43
|
+
* read-merge-write; a blind write destroys `type`, sub-tasks, the design-doc
|
|
44
|
+
* link and any comments already on the card.
|
|
45
|
+
*/
|
|
46
|
+
function readData(raw) {
|
|
47
|
+
if (raw && typeof raw === 'object')
|
|
48
|
+
return raw;
|
|
49
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
50
|
+
return {};
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Project key for scoping memory — the folder BASENAME, not its path, so the
|
|
60
|
+
* same repo cloned elsewhere is the same project. */
|
|
61
|
+
function projectTag(cwd) {
|
|
62
|
+
const b = (cwd ?? '').replace(/\/+$/, '').split('/').filter(Boolean).pop() ?? '';
|
|
63
|
+
return b ? `project:${b.toLowerCase()}` : '';
|
|
64
|
+
}
|
|
65
|
+
/** The board's four columns. Legacy words still resolve server-side, but writing
|
|
66
|
+
* one puts a card in a column the kanban does not draw. */
|
|
67
|
+
const STATUSES = ['todo', 'in-progress', 'in-review', 'done'];
|
|
68
|
+
// ── session state ────────────────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* The card the session is working on — what the design gate checks against, and
|
|
71
|
+
* the default target for comments and design docs.
|
|
72
|
+
*
|
|
73
|
+
* Module-scoped rather than per-tool because it is genuinely session state: the
|
|
74
|
+
* gate in `daemon-omni-hooks` has to read the same value the tools write.
|
|
75
|
+
*/
|
|
76
|
+
let activeTicketId = '';
|
|
77
|
+
let activeTicketTitle = '';
|
|
78
|
+
export const getActiveTicket = () => ({ id: activeTicketId, title: activeTicketTitle });
|
|
79
|
+
export function setActiveTicket(id, title = '') {
|
|
80
|
+
activeTicketId = id || '';
|
|
81
|
+
if (title)
|
|
82
|
+
activeTicketTitle = title;
|
|
83
|
+
}
|
|
84
|
+
/** ticketId → design doc id, so the write gate is not a network call per edit. */
|
|
85
|
+
const designDocByTicket = new Map();
|
|
86
|
+
/** Doc ids whose CONTENT has been read this session — the precondition for
|
|
87
|
+
* closing a card, and what makes the pre-merge review more than a checkbox. */
|
|
88
|
+
const designDocsRead = new Set();
|
|
89
|
+
const designReviews = new Map();
|
|
90
|
+
export const designReviewed = (t) => designReviews.has(t);
|
|
91
|
+
export function resetPrincipalSession() {
|
|
92
|
+
activeTicketId = '';
|
|
93
|
+
activeTicketTitle = '';
|
|
94
|
+
designDocsRead.clear();
|
|
95
|
+
designReviews.clear();
|
|
96
|
+
designDocByTicket.clear();
|
|
97
|
+
/**
|
|
98
|
+
* ⚠ THE CONTEXT IS NOT CLEARED HERE, deliberately.
|
|
99
|
+
*
|
|
100
|
+
* `resetPrincipalSession` ends a CONVERSATION; the context belongs to the
|
|
101
|
+
* MOUNT and outlives it. Clearing it would turn the design gate off after the
|
|
102
|
+
* first session ended — `principalGateActive()` would go false and every
|
|
103
|
+
* subsequent edit would sail through ungated, which is the silent failure this
|
|
104
|
+
* whole mechanism exists to prevent. Unmount with `setPrincipalTools(null)`.
|
|
105
|
+
*/
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The live context, kept so the HOOKS can reach the workspace too.
|
|
109
|
+
*
|
|
110
|
+
* ⚠ The design gate runs in `daemon-omni-hooks`, which is a different module and
|
|
111
|
+
* has no context of its own — it only sees the SDK's hook input. Without this it
|
|
112
|
+
* could not resolve whether the active card has a design doc, which is the entire
|
|
113
|
+
* question it exists to answer.
|
|
114
|
+
*/
|
|
115
|
+
let _ctx = null;
|
|
116
|
+
/** Whether the gate should apply at all. False for a headless session with no
|
|
117
|
+
* principal attached — such a session has no board to write to, so gating its
|
|
118
|
+
* edits on one would refuse every write forever. */
|
|
119
|
+
export const principalGateActive = () => !!_ctx;
|
|
120
|
+
/** Drop the held context. Called on UNMOUNT, never on session reset. */
|
|
121
|
+
export function clearPrincipalContext() {
|
|
122
|
+
_ctx = null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The ACTIVE card's lifecycle state, straight from the board.
|
|
126
|
+
*
|
|
127
|
+
* ⚠⚠ THIS REPLACES A LOOKUP THAT COULD NOT GO STALE-SAFE. `designDocIdFor` reads a `designDocId`
|
|
128
|
+
* out of the card's `data` blob and memoises it in `designDocByTicket` — a cache that is never
|
|
129
|
+
* invalidated, so DELETING the design document leaves the gate satisfied forever. It also knows
|
|
130
|
+
* about exactly one kind of artifact, found by a 200-row `ticket-list` scan.
|
|
131
|
+
*
|
|
132
|
+
* `work-status` answers all four gates from `ticket_artifacts`, and — the part that matters —
|
|
133
|
+
* RESOLVES each link before counting it, so a deleted target un-satisfies its gate.
|
|
134
|
+
*
|
|
135
|
+
* ⚠ RETURNS null ON ANY FAILURE, and every caller must treat that as "unknown", never as "no".
|
|
136
|
+
* An unreachable workspace must not start refusing edits.
|
|
137
|
+
*/
|
|
138
|
+
export async function activeWorkStatus() {
|
|
139
|
+
if (!_ctx || !activeTicketId)
|
|
140
|
+
return null;
|
|
141
|
+
try {
|
|
142
|
+
const r = await _ctx.invokeRpc('work-status', { ticketId: activeTicketId });
|
|
143
|
+
return r?.ok ? r : null;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Does the ACTIVE card have a design doc? The gate's only question. */
|
|
150
|
+
export async function activeTicketHasDesignDoc() {
|
|
151
|
+
if (!_ctx || !activeTicketId)
|
|
152
|
+
return false;
|
|
153
|
+
try {
|
|
154
|
+
/**
|
|
155
|
+
* ⚠⚠ THE BOARD IS ASKED FIRST, because it is the only source that can tell a LIVE design doc
|
|
156
|
+
* from a deleted one. The legacy `designDocId` path memoises an id and never re-checks it, so
|
|
157
|
+
* deleting the document leaves this gate passing forever — a gate that cannot fail is not a
|
|
158
|
+
* gate.
|
|
159
|
+
*/
|
|
160
|
+
const work = await activeWorkStatus();
|
|
161
|
+
if (work)
|
|
162
|
+
return work.gates['eng-design'] === true || work.gates.design === true;
|
|
163
|
+
// ⚠ FALLBACK, NOT A SECOND SOURCE OF TRUTH: only reached when `work-status` could not answer
|
|
164
|
+
// (an older worker, or an unreachable workspace). Cards written before `ticket_artifacts`
|
|
165
|
+
// existed still carry `designDocId`, and refusing them would be a regression.
|
|
166
|
+
return !!(await designDocIdFor(_ctx, activeTicketId));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// ⚠ FAIL OPEN. An unreachable workspace must not block every edit.
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Comment on the active card from outside the tool set — the PR-link hook. */
|
|
174
|
+
export async function commentOnActiveCard(body) {
|
|
175
|
+
if (!_ctx || !activeTicketId)
|
|
176
|
+
return false;
|
|
177
|
+
try {
|
|
178
|
+
const listed = await _ctx.invokeRpc('ticket-list', { limit: 200 });
|
|
179
|
+
const row = (listed?.tickets ?? []).find((t) => String(t.id) === activeTicketId);
|
|
180
|
+
if (!row)
|
|
181
|
+
return false;
|
|
182
|
+
const data = readData(row.data);
|
|
183
|
+
const comments = Array.isArray(data.comments) ? data.comments : [];
|
|
184
|
+
const activity = Array.isArray(data.activity) ? data.activity : [];
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
comments.push({ id: `c-${now}-${Math.random().toString(36).slice(2, 7)}`, author: 'Omni', body: body.slice(0, 4000), ts: now });
|
|
187
|
+
activity.push({ ts: now, by: 'Omni', kind: 'comment', msg: 'commented' });
|
|
188
|
+
await _ctx.invokeRpc('ticket-update', { id: activeTicketId, updates: { data: { ...data, comments, activity } } });
|
|
189
|
+
_ctx.emit({ kind: 'workspace', text: 'PR linked on the card' });
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Resolve a card's design doc — cache first, then the card's own `data`. */
|
|
197
|
+
export async function designDocIdFor(ctx, ticketId) {
|
|
198
|
+
if (!ticketId)
|
|
199
|
+
return '';
|
|
200
|
+
const cached = designDocByTicket.get(ticketId);
|
|
201
|
+
if (cached)
|
|
202
|
+
return cached;
|
|
203
|
+
try {
|
|
204
|
+
const listed = await ctx.invokeRpc('ticket-list', { limit: 200 });
|
|
205
|
+
const row = (listed?.tickets ?? []).find((t) => String(t.id) === ticketId);
|
|
206
|
+
const id = String(readData(row?.data).designDocId || '');
|
|
207
|
+
if (id)
|
|
208
|
+
designDocByTicket.set(ticketId, id);
|
|
209
|
+
return id;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return '';
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// ── the server ───────────────────────────────────────────────────────────────
|
|
216
|
+
export function createPrincipalMcpServer(ctx) {
|
|
217
|
+
// Held for the hooks — see `_ctx`. Set here rather than in `setPrincipalTools`
|
|
218
|
+
// so the two can never disagree about which context is live.
|
|
219
|
+
_ctx = ctx;
|
|
220
|
+
const cwd = () => ctx.cwd?.();
|
|
221
|
+
/**
|
|
222
|
+
* ⚠ THE ONLY CHANNEL TO THE PRINCIPAL. Ordinary assistant text is not shown —
|
|
223
|
+
* it goes to the work log — so a turn that never calls this is a turn where
|
|
224
|
+
* Omni did work and said nothing.
|
|
225
|
+
*/
|
|
226
|
+
const reply = tool('reply_to_principal', 'Say something to the principal. This is the ONLY way to reach them — your ordinary response text is never shown, it is your own working space. Plain executive prose in markdown: no code, no diffs, no file paths, no command output. Call it again mid-task to report progress.', {
|
|
227
|
+
text: z.string().describe('What the principal reads. Outcome first. Markdown — bold the one thing that matters, no headings, no code blocks.'),
|
|
228
|
+
tone: z.enum(['neutral', 'working', 'good', 'caution', 'problem', 'thinking']).optional()
|
|
229
|
+
.describe('How it should land. Drives both the motion and the speaking voice.'),
|
|
230
|
+
}, async (args) => {
|
|
231
|
+
const text = String(args.text ?? '').trim();
|
|
232
|
+
if (!text)
|
|
233
|
+
return { content: [{ type: 'text', text: 'Empty message — nothing was shown.' }], isError: true };
|
|
234
|
+
ctx.emit({ kind: 'prose', text, tone: args.tone });
|
|
235
|
+
return { content: [{ type: 'text', text: 'Delivered.' }] };
|
|
236
|
+
});
|
|
237
|
+
const askPrincipal = tool('ask_principal', 'Ask the principal a question and WAIT for the answer. Use at a genuine fork: two defensible options with different consequences, an ambiguous requirement, a destructive step. Prefer options over free text — one tap beats a paragraph. Never for reassurance or permission to start.', {
|
|
238
|
+
question: z.string().describe('The question, one line, in their language.'),
|
|
239
|
+
detail: z.string().optional().describe('Markdown above the options — the trade-off, what you found.'),
|
|
240
|
+
options: z.array(z.object({
|
|
241
|
+
value: z.string(), label: z.string(), detail: z.string().optional(),
|
|
242
|
+
})).optional().describe('2-4 options, your recommendation first. Omit ONLY when the answer cannot be enumerated.'),
|
|
243
|
+
}, async (args) => {
|
|
244
|
+
const hasOptions = Array.isArray(args.options) && args.options.length > 0;
|
|
245
|
+
const a = await ctx.ask({
|
|
246
|
+
kind: hasOptions ? 'choice' : 'text',
|
|
247
|
+
question: args.question,
|
|
248
|
+
detail: args.detail,
|
|
249
|
+
options: hasOptions ? args.options.slice(0, 4) : undefined,
|
|
250
|
+
});
|
|
251
|
+
if (a.outcome === 'timeout') {
|
|
252
|
+
return { content: [{ type: 'text', text: 'No answer — they may be away from the machine. Do NOT treat this as agreement. Take the reversible option and say you did, or stop and leave it for them.' }] };
|
|
253
|
+
}
|
|
254
|
+
if (a.outcome === 'dismissed') {
|
|
255
|
+
return { content: [{ type: 'text', text: 'They dismissed the question. Take that as "not now" — do not re-ask it this turn.' }] };
|
|
256
|
+
}
|
|
257
|
+
return { content: [{ type: 'text', text: `They answered: ${a.value}` }] };
|
|
258
|
+
});
|
|
259
|
+
const proposePlan = tool('propose_plan', 'Put a plan in front of the principal and wait for approval before building. Required before code changes when they have asked to approve plans; otherwise use it when building the wrong thing would be expensive.', {
|
|
260
|
+
summary: z.string().describe('One line: what you are going to do.'),
|
|
261
|
+
plan: z.string().describe('Markdown. What you will change, in what order, what it touches, what you are unsure about. Readable in thirty seconds.'),
|
|
262
|
+
}, async (args) => {
|
|
263
|
+
const a = await ctx.ask({
|
|
264
|
+
kind: 'approve',
|
|
265
|
+
question: args.summary,
|
|
266
|
+
detail: args.plan,
|
|
267
|
+
options: [
|
|
268
|
+
{ value: 'approve', label: 'Go ahead', detail: 'Build it as described' },
|
|
269
|
+
{ value: 'revise', label: 'Change something', detail: 'Tell them what to alter' },
|
|
270
|
+
{ value: 'reject', label: 'Not this', detail: 'Do not build it' },
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
if (a.outcome !== 'answered') {
|
|
274
|
+
return { content: [{ type: 'text', text: 'The plan was not answered. Do not start building — an unanswered plan is not an approved one. Say you are waiting on them.' }] };
|
|
275
|
+
}
|
|
276
|
+
if (a.value === 'approve')
|
|
277
|
+
return { content: [{ type: 'text', text: 'Approved. Make the card, write the design doc from this plan, then build it.' }] };
|
|
278
|
+
if (a.value === 'revise')
|
|
279
|
+
return { content: [{ type: 'text', text: 'They want changes. Ask what to change with ask_principal, then propose again. Do NOT start the original plan.' }] };
|
|
280
|
+
return { content: [{ type: 'text', text: 'Rejected. Do not build it. Ask what they would rather do, or move on.' }] };
|
|
281
|
+
});
|
|
282
|
+
// ── memory ────────────────────────────────────────────────────────────────
|
|
283
|
+
const remember = tool('remember', 'Save something worth knowing in every future conversation — how the principal works, a fact about this project, a constraint you discovered. Persists on the workspace across sessions and machines.', {
|
|
284
|
+
key: z.string().describe('Short stable identifier, kebab-case. Reusing a key UPDATES that memory rather than adding a second.'),
|
|
285
|
+
value: z.string().describe('The fact, one or two sentences, written to be read cold in six months.'),
|
|
286
|
+
category: z.enum(['fact', 'preference', 'context', 'decision', 'note']).optional(),
|
|
287
|
+
importance: z.number().min(0).max(1).optional().describe('0-1. Above 0.7 for things that change how you behave.'),
|
|
288
|
+
global: z.boolean().optional().describe('True when this is about the PRINCIPAL and applies everywhere, not just this folder.'),
|
|
289
|
+
}, async (args) => {
|
|
290
|
+
const tag = args.global ? '' : projectTag(cwd());
|
|
291
|
+
try {
|
|
292
|
+
const r = await ctx.invokeRpc('agent-memory-remember', {
|
|
293
|
+
key: String(args.key).trim(),
|
|
294
|
+
value: String(args.value).trim(),
|
|
295
|
+
category: args.category ?? 'note',
|
|
296
|
+
importance: typeof args.importance === 'number' ? args.importance : 0.5,
|
|
297
|
+
tags: tag ? [tag] : [],
|
|
298
|
+
});
|
|
299
|
+
if (r?.ok)
|
|
300
|
+
ctx.emit({ kind: 'workspace', text: `Remembered: ${args.key}` });
|
|
301
|
+
return { content: [{ type: 'text', text: r?.ok ? 'Saved.' : 'Could not save it; continue anyway.' }] };
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return { content: [{ type: 'text', text: 'Could not save it; continue anyway.' }] };
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
const recall = tool('recall', 'Search your memory of this workspace for something not already in your context — an old decision and its reasoning, a fact about a part of the project you have not touched. Search before you assume something was never decided.', { query: z.string().describe('What you are looking for, in plain words.') }, async (args) => {
|
|
308
|
+
try {
|
|
309
|
+
const r = await ctx.invokeRpc('agent-memory-search', { query: args.query, limit: 12 });
|
|
310
|
+
const hits = r?.memories ?? [];
|
|
311
|
+
if (!hits.length) {
|
|
312
|
+
// ⚠ "Nothing found" and "the lookup failed" are the same string to a
|
|
313
|
+
// model and warrant opposite behaviour. Say which it was.
|
|
314
|
+
return { content: [{ type: 'text', text: `Nothing in memory for "${args.query}". Either it was never recorded, or the workspace is unreachable — do not treat this as proof it was never decided.` }] };
|
|
315
|
+
}
|
|
316
|
+
return { content: [{ type: 'text', text: hits.map((m) => `- [${m.category}] **${m.key}** — ${String(m.value ?? '').slice(0, 600)}`).join('\n') }] };
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return { content: [{ type: 'text', text: 'Memory is unreachable right now — do not treat that as an absence.' }] };
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
// ── the board ─────────────────────────────────────────────────────────────
|
|
323
|
+
const createTicket = tool('create_ticket', 'Create a card on the project board. Do this BEFORE starting any coding work — one card per unit of work. Also use it to capture work you are NOT doing now (a bug you noticed, a follow-up) so it stops living in your head.', {
|
|
324
|
+
title: z.string().describe('One line, outcome-shaped.'),
|
|
325
|
+
body: z.string().optional().describe('What needs doing and how you will know it is done. The principal reads this.'),
|
|
326
|
+
status: z.enum(STATUSES).optional(),
|
|
327
|
+
priority: z.number().min(0).max(3).optional(),
|
|
328
|
+
issueType: z.enum(['epic', 'story', 'task', 'bug', 'subtask']).optional(),
|
|
329
|
+
parentId: z.string().optional().describe('The epic or story this belongs under.'),
|
|
330
|
+
}, async (args) => {
|
|
331
|
+
try {
|
|
332
|
+
// ⚠ `issueType`, NOT `type` — the wire protocol uses `type` as the ACTION
|
|
333
|
+
// discriminator, so a payload field of that name is overwritten by the
|
|
334
|
+
// action name before the handler sees it.
|
|
335
|
+
const r = await ctx.invokeRpc('ticket-create', {
|
|
336
|
+
title: String(args.title).slice(0, 200),
|
|
337
|
+
...(args.body ? { body: String(args.body).slice(0, 8000) } : {}),
|
|
338
|
+
status: args.status ?? 'todo',
|
|
339
|
+
priority: typeof args.priority === 'number' ? args.priority : 1,
|
|
340
|
+
issueType: args.issueType ?? 'task',
|
|
341
|
+
...(args.parentId ? { parentId: args.parentId } : {}),
|
|
342
|
+
});
|
|
343
|
+
const id = String(r?.id ?? '');
|
|
344
|
+
if (!id)
|
|
345
|
+
return { content: [{ type: 'text', text: 'Could not create the card. Carry on with the work and say the board is not recording it.' }] };
|
|
346
|
+
setActiveTicket(id, String(args.title));
|
|
347
|
+
ctx.emit({ kind: 'workspace', text: `Card: ${r?.key ? `${r.key} ` : ''}${args.title}` });
|
|
348
|
+
return { content: [{ type: 'text', text: `Created ${r?.key ?? id} — id \`${id}\`. This is now the active card. Write its design doc with design_doc before editing any file.` }] };
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return { content: [{ type: 'text', text: 'Could not create the card. Carry on with the work and say so.' }] };
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
const updateTicket = tool('update_ticket', 'Move a card or change it. in-progress when you START, in-review when the work is done but unverified, done when you have VERIFIED it. A board that lags the work is worse than no board.', {
|
|
355
|
+
id: z.string(),
|
|
356
|
+
status: z.enum(STATUSES).optional(),
|
|
357
|
+
title: z.string().optional(),
|
|
358
|
+
body: z.string().optional(),
|
|
359
|
+
priority: z.number().min(0).max(3).optional(),
|
|
360
|
+
}, async (args) => {
|
|
361
|
+
const { id, ...updates } = args;
|
|
362
|
+
// ⚠ THE MERGE GATE — a card cannot reach `done` until its design doc was
|
|
363
|
+
// re-read THIS SESSION and a review recorded.
|
|
364
|
+
if (updates.status === 'done') {
|
|
365
|
+
const docId = await designDocIdFor(ctx, id);
|
|
366
|
+
if (docId && !designReviews.has(id)) {
|
|
367
|
+
return { content: [{ type: 'text', text: `Not moved. Before a card is done you re-read its design doc and compare it against what you built: design_doc op "read" for ${id}, then record_design_review. Write down any deviation — a documented one is fine, an undocumented one is how the doc becomes fiction.` }], isError: true };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const r = await ctx.invokeRpc('ticket-update', { id, updates });
|
|
372
|
+
const ok = !!r && r.error == null;
|
|
373
|
+
if (ok && updates.status) {
|
|
374
|
+
ctx.emit({ kind: 'workspace', text: `Moved to ${updates.status}` });
|
|
375
|
+
if (updates.status === 'in-progress')
|
|
376
|
+
setActiveTicket(id, updates.title ?? '');
|
|
377
|
+
}
|
|
378
|
+
return { content: [{ type: 'text', text: ok ? `Updated ${id}.` : `Could not update ${id}. Carry on with the work.` }] };
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return { content: [{ type: 'text', text: `Could not update ${id}. Carry on with the work.` }] };
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
const listTickets = tool('list_tickets', 'Read the board. Call this when you need current state, want the done column, or are looking for a card you did not create.', { status: z.enum(STATUSES).optional() }, async (args) => {
|
|
385
|
+
try {
|
|
386
|
+
const r = await ctx.invokeRpc('ticket-list', { ...(args.status ? { status: args.status } : {}), limit: 100 });
|
|
387
|
+
const rows = r?.tickets ?? [];
|
|
388
|
+
if (!rows.length)
|
|
389
|
+
return { content: [{ type: 'text', text: args.status ? `No cards in ${args.status}.` : 'The board is empty.' }] };
|
|
390
|
+
return { content: [{ type: 'text', text: rows.map((t) => `- [${t.status}] ${t.key ? `${t.key} ` : ''}${t.title} — id \`${t.id}\``).join('\n') }] };
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return { content: [{ type: 'text', text: 'The board is unreachable right now.' }] };
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
const commentOnCard = tool('comment_on_card', 'Post a comment on a card — a status update, what you found, why something took longer. Appears in the card\'s Comments tab. A PR opened with `gh pr create` posts its own link, so do not duplicate that.', {
|
|
397
|
+
ticketId: z.string().optional().describe('Defaults to the active card.'),
|
|
398
|
+
body: z.string().describe('Markdown.'),
|
|
399
|
+
}, async (args) => {
|
|
400
|
+
const ticketId = String(args.ticketId || activeTicketId || '');
|
|
401
|
+
if (!ticketId)
|
|
402
|
+
return { content: [{ type: 'text', text: 'No card is active — pass a ticketId.' }], isError: true };
|
|
403
|
+
try {
|
|
404
|
+
// Read-merge-write: `data` is replaced wholesale by ticket-update.
|
|
405
|
+
const listed = await ctx.invokeRpc('ticket-list', { limit: 200 });
|
|
406
|
+
const row = (listed?.tickets ?? []).find((t) => String(t.id) === ticketId);
|
|
407
|
+
if (!row)
|
|
408
|
+
return { content: [{ type: 'text', text: 'No such card.' }], isError: true };
|
|
409
|
+
const data = readData(row.data);
|
|
410
|
+
const comments = Array.isArray(data.comments) ? data.comments : [];
|
|
411
|
+
const activity = Array.isArray(data.activity) ? data.activity : [];
|
|
412
|
+
const now = Date.now();
|
|
413
|
+
// Shapes mirror the board page's CardComment / CardActivity exactly — a
|
|
414
|
+
// different shape renders as a blank row rather than failing visibly.
|
|
415
|
+
comments.push({ id: `c-${now}-${Math.random().toString(36).slice(2, 7)}`, author: 'Omni', body: String(args.body).slice(0, 4000), ts: now });
|
|
416
|
+
activity.push({ ts: now, by: 'Omni', kind: 'comment', msg: 'commented' });
|
|
417
|
+
await ctx.invokeRpc('ticket-update', { id: ticketId, updates: { data: { ...data, comments, activity } } });
|
|
418
|
+
ctx.emit({ kind: 'workspace', text: 'Commented on the card' });
|
|
419
|
+
return { content: [{ type: 'text', text: 'Comment posted.' }] };
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return { content: [{ type: 'text', text: 'Could not post the comment. Carry on with the work.' }] };
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
// ── the design doc ────────────────────────────────────────────────────────
|
|
426
|
+
const designDoc = tool('design_doc', 'Write, read or revise the engineering design for a card. REQUIRED before any code change — you cannot edit a file until the active card has one. Also how you re-read a design before closing a card.', {
|
|
427
|
+
op: z.enum(['create', 'read', 'revise']),
|
|
428
|
+
ticketId: z.string().optional().describe('Defaults to the active card.'),
|
|
429
|
+
title: z.string().optional(),
|
|
430
|
+
problem: z.string().optional().describe('create: what is wrong today and who it affects. Concrete.'),
|
|
431
|
+
approach: z.string().optional().describe('create: what you will build, in enough detail that someone else could build the same thing.'),
|
|
432
|
+
rejected: z.string().optional().describe('create: what you considered and why not. The most valuable section and the one people skip.'),
|
|
433
|
+
touches: z.string().optional().describe('create: which files, routes, tables or services this changes.'),
|
|
434
|
+
verification: z.string().optional().describe('create: how you will KNOW it works — the test, the command, the thing you will open.'),
|
|
435
|
+
note: z.string().optional().describe('revise: what changed about the design and why.'),
|
|
436
|
+
}, async (args) => {
|
|
437
|
+
const ticketId = String(args.ticketId || activeTicketId || '');
|
|
438
|
+
if (!ticketId)
|
|
439
|
+
return { content: [{ type: 'text', text: 'No card is active. Create one with create_ticket first — a design doc documents a unit of work, so it needs the unit.' }], isError: true };
|
|
440
|
+
if (args.op === 'read') {
|
|
441
|
+
const docId = await designDocIdFor(ctx, ticketId);
|
|
442
|
+
if (!docId)
|
|
443
|
+
return { content: [{ type: 'text', text: `No design doc for card ${ticketId}.` }], isError: true };
|
|
444
|
+
try {
|
|
445
|
+
const d = await ctx.invokeRpc('document-get', { id: docId });
|
|
446
|
+
designDocsRead.add(docId);
|
|
447
|
+
return { content: [{ type: 'text', text: `# ${d?.title ?? ''}\n(document id \`${docId}\`)\n\n${d?.markdown ?? d?.html ?? ''}` }] };
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return { content: [{ type: 'text', text: 'Could not read the design doc.' }], isError: true };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (args.op === 'revise') {
|
|
454
|
+
const docId = await designDocIdFor(ctx, ticketId);
|
|
455
|
+
if (!docId)
|
|
456
|
+
return { content: [{ type: 'text', text: 'No design doc to revise.' }], isError: true };
|
|
457
|
+
try {
|
|
458
|
+
await ctx.invokeRpc('document-append', { id: docId, markdown: `\n\n## Design revised mid-flight\n${String(args.note ?? '').slice(0, 2000)}` });
|
|
459
|
+
return { content: [{ type: 'text', text: 'Revision appended to the design doc.' }] };
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return { content: [{ type: 'text', text: 'Could not append the revision.' }] };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const missing = ['title', 'problem', 'approach', 'verification'].filter((k) => !String(args[k] ?? '').trim());
|
|
466
|
+
if (missing.length) {
|
|
467
|
+
return { content: [{ type: 'text', text: `Cannot create the design — missing: ${missing.join(', ')}. These four are required.` }], isError: true };
|
|
468
|
+
}
|
|
469
|
+
const title = `Design — ${args.title}`.slice(0, 200);
|
|
470
|
+
const markdown = [
|
|
471
|
+
`# ${title}`,
|
|
472
|
+
`\n## The problem`, args.problem,
|
|
473
|
+
`\n## The approach`, args.approach,
|
|
474
|
+
args.rejected ? `\n## Considered and rejected\n${args.rejected}` : '',
|
|
475
|
+
args.touches ? `\n## What it touches\n${args.touches}` : '',
|
|
476
|
+
`\n## How we will know it worked`, args.verification,
|
|
477
|
+
].filter(Boolean).join('\n');
|
|
478
|
+
try {
|
|
479
|
+
const doc = await ctx.invokeRpc('document-create', { title, markdown });
|
|
480
|
+
const docId = String(doc?.id ?? '');
|
|
481
|
+
if (!docId)
|
|
482
|
+
throw new Error('no id');
|
|
483
|
+
designDocByTicket.set(ticketId, docId);
|
|
484
|
+
// Written by us, so it counts as read — re-fetching a document Omni just
|
|
485
|
+
// authored would be ceremony, not verification.
|
|
486
|
+
designDocsRead.add(docId);
|
|
487
|
+
// ⚠ MERGE the link into `data`, never overwrite it.
|
|
488
|
+
const listed = await ctx.invokeRpc('ticket-list', { limit: 200 });
|
|
489
|
+
const row = (listed?.tickets ?? []).find((t) => String(t.id) === ticketId);
|
|
490
|
+
await ctx.invokeRpc('ticket-update', { id: ticketId, updates: { data: { ...readData(row?.data), designDocId: docId } } });
|
|
491
|
+
ctx.emit({ kind: 'workspace', text: `Design doc: ${title}` });
|
|
492
|
+
return { content: [{ type: 'text', text: `Design filed — document id \`${docId}\`. You can now make the change.` }] };
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
/**
|
|
496
|
+
* ⚠ FAILS OPEN, AND SAYS SO. If the workspace is unreachable the design
|
|
497
|
+
* cannot be filed, and blocking every edit on that would make an offline
|
|
498
|
+
* daemon stop all work. The gate exists to make the doc habitual, not to
|
|
499
|
+
* make the daemon a dependency of the editor.
|
|
500
|
+
*/
|
|
501
|
+
return { content: [{ type: 'text', text: 'Could not save the design doc (the workspace may be unreachable). Say so in your next reply, keep the design in the conversation, and carry on — but file it when the workspace is back.' }] };
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
const recordDesignReview = tool('record_design_review', 'Record your pre-merge check: you re-read the design doc and compared it against what you actually built. Required before a card can move to done.', {
|
|
505
|
+
ticketId: z.string().optional(),
|
|
506
|
+
verdict: z.string().describe('What you found. Whether it matches, and where it deviated and why. "Matches" alone is not a review.'),
|
|
507
|
+
}, async (args) => {
|
|
508
|
+
const ticketId = String(args.ticketId || activeTicketId || '');
|
|
509
|
+
if (!ticketId)
|
|
510
|
+
return { content: [{ type: 'text', text: 'No card is active.' }], isError: true };
|
|
511
|
+
const docId = await designDocIdFor(ctx, ticketId);
|
|
512
|
+
// ⚠ THE DOC MUST HAVE BEEN READ THIS SESSION, or this is a checkbox a model
|
|
513
|
+
// can tick about a document it has never seen.
|
|
514
|
+
if (!docId || !designDocsRead.has(docId)) {
|
|
515
|
+
return { content: [{ type: 'text', text: 'Read the design first — design_doc op "read" for this card. A review written without opening the document is not a review.' }], isError: true };
|
|
516
|
+
}
|
|
517
|
+
designReviews.set(ticketId, String(args.verdict));
|
|
518
|
+
try {
|
|
519
|
+
await ctx.invokeRpc('document-append', { id: docId, markdown: `\n\n## Pre-merge review\n${String(args.verdict).slice(0, 4000)}` });
|
|
520
|
+
}
|
|
521
|
+
catch { /* recorded in-session either way — losing the append must not block shipping verified work */ }
|
|
522
|
+
ctx.emit({ kind: 'workspace', text: 'Design review recorded' });
|
|
523
|
+
return { content: [{ type: 'text', text: 'Review recorded on the design doc. The card can move to done.' }] };
|
|
524
|
+
});
|
|
525
|
+
// ── their screen ──────────────────────────────────────────────────────────
|
|
526
|
+
/**
|
|
527
|
+
* ⚠ THE ONE CAPABILITY THAT IS NOT UNIVERSAL.
|
|
528
|
+
*
|
|
529
|
+
* Floating windows need a windowing surface — the Mac app. A terminal user has
|
|
530
|
+
* everything else (replies, questions, the board, design docs, memory, the
|
|
531
|
+
* preview URL), and this is the single thing they cannot have.
|
|
532
|
+
*
|
|
533
|
+
* The tool stays REGISTERED either way and reports honestly when it lands
|
|
534
|
+
* nowhere. Hiding it would be worse: the tool set would differ per surface, so
|
|
535
|
+
* the same prompt would behave differently depending on who was attached, and
|
|
536
|
+
* an agent told a capability exists and then silently denied it is exactly how
|
|
537
|
+
* a model ends up insisting it showed you something it did not.
|
|
538
|
+
*/
|
|
539
|
+
const openWindow = tool('open_window', "Open something on the principal's screen in a floating window beside the conversation — the project board, a document, or a canvas composition. Several can be open at once. Only works when they are on the desktop app; on a terminal it does nothing and you will be told.", {
|
|
540
|
+
kind: z.enum(['board', 'document', 'composition']),
|
|
541
|
+
id: z.string().optional().describe('The specific document/composition/project. Omit to open the collection view.'),
|
|
542
|
+
title: z.string().optional(),
|
|
543
|
+
}, async (args) => {
|
|
544
|
+
const target = `${args.kind}:${args.id || 'all'}`;
|
|
545
|
+
ctx.emit({ kind: 'window', op: 'open', target, title: args.title });
|
|
546
|
+
if (ctx.canOpenWindows?.() === false) {
|
|
547
|
+
return { content: [{ type: 'text', text: `They are on a terminal, so no window opened. Describe what you would have shown, or use show_preview to hand them a URL.` }] };
|
|
548
|
+
}
|
|
549
|
+
return { content: [{ type: 'text', text: `Opened ${args.kind} (window id "${target}").` }] };
|
|
550
|
+
});
|
|
551
|
+
const closeWindow = tool('close_window', 'Close a floating window. Close things when they stop being relevant — a screen covered in windows from three topics ago is worse than none.', { windowId: z.string().optional(), all: z.boolean().optional() }, async (args) => {
|
|
552
|
+
ctx.emit({ kind: 'window', op: 'close', target: args.all ? '*' : String(args.windowId ?? '') });
|
|
553
|
+
return { content: [{ type: 'text', text: args.all ? 'Closed all windows.' : `Closed ${args.windowId}.` }] };
|
|
554
|
+
});
|
|
555
|
+
const showPreview = tool('show_preview', 'Show a live page you just made run — a dev server, a built page. It opens beside the conversation and reloads on later calls. NEVER tell the principal to open a URL themselves.', { url: z.string(), caption: z.string().optional() }, async (args) => {
|
|
556
|
+
ctx.emit({ kind: 'preview', url: String(args.url), caption: String(args.caption ?? 'Open it') });
|
|
557
|
+
return { content: [{ type: 'text', text: 'Shown.' }] };
|
|
558
|
+
});
|
|
559
|
+
const showImage = tool('show_image', 'Show an image — a chart, a screenshot of a failure, a diff worth seeing. Takes a data URI.', { dataUri: z.string(), caption: z.string().optional() }, async (args) => {
|
|
560
|
+
ctx.emit({ kind: 'image', dataUri: String(args.dataUri), caption: String(args.caption ?? '') });
|
|
561
|
+
return { content: [{ type: 'text', text: 'Shown.' }] };
|
|
562
|
+
});
|
|
563
|
+
return createSdkMcpServer({
|
|
564
|
+
name: 'principal',
|
|
565
|
+
version: '1.0.0',
|
|
566
|
+
/**
|
|
567
|
+
* ⚠ NEVER DEFERRED BEHIND TOOL SEARCH. These are the difference between
|
|
568
|
+
* showing the work and telling the principal to go and look for it — the
|
|
569
|
+
* model must always be able to see that they exist.
|
|
570
|
+
*/
|
|
571
|
+
alwaysLoad: true,
|
|
572
|
+
tools: [
|
|
573
|
+
reply, askPrincipal, proposePlan,
|
|
574
|
+
remember, recall,
|
|
575
|
+
createTicket, updateTicket, listTickets, commentOnCard,
|
|
576
|
+
designDoc, recordDesignReview,
|
|
577
|
+
openWindow, closeWindow, showPreview, showImage,
|
|
578
|
+
],
|
|
579
|
+
});
|
|
580
|
+
}
|