cowork-harness 0.1.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/.env.example +16 -0
- package/CHANGELOG.md +190 -0
- package/LICENSE +21 -0
- package/README.md +470 -0
- package/baselines/desktop-1.11847.5.json +78 -0
- package/baselines/desktop-1.12603.1.json +140 -0
- package/baselines/prompts/desktop-1.12603.1/host-loop-append.md +8 -0
- package/baselines/prompts/desktop-1.12603.1/subagent-append-vm.md +3 -0
- package/baselines/prompts/desktop-1.12603.1/system-prompt-append.md +18 -0
- package/dist/agent/session.js +465 -0
- package/dist/assert.js +159 -0
- package/dist/baseline.js +87 -0
- package/dist/boundary.js +114 -0
- package/dist/canary/grants.js +37 -0
- package/dist/cli.js +1107 -0
- package/dist/decide/decider.js +521 -0
- package/dist/decide/external-channel.js +262 -0
- package/dist/decide/llm-transport.js +52 -0
- package/dist/dotenv.js +52 -0
- package/dist/egress/proxy.js +138 -0
- package/dist/egress/sidecar.js +125 -0
- package/dist/hostloop/provenance.js +110 -0
- package/dist/hostloop/workspace-handler.js +226 -0
- package/dist/loop-decision.js +62 -0
- package/dist/prompt.js +43 -0
- package/dist/run/cassette.js +420 -0
- package/dist/run/chat.js +194 -0
- package/dist/run/envelope.js +31 -0
- package/dist/run/execute.js +533 -0
- package/dist/run/renderer.js +179 -0
- package/dist/run/run.js +347 -0
- package/dist/run/trace-view.js +227 -0
- package/dist/runtime/argv.js +126 -0
- package/dist/runtime/container.js +76 -0
- package/dist/runtime/host-env.js +28 -0
- package/dist/runtime/hostloop.js +129 -0
- package/dist/runtime/lima.js +177 -0
- package/dist/runtime/microvm.js +151 -0
- package/dist/runtime/protocol.js +79 -0
- package/dist/runtime/stage.js +52 -0
- package/dist/secrets.js +42 -0
- package/dist/session.js +315 -0
- package/dist/sync/cowork-sync.js +215 -0
- package/dist/types.js +127 -0
- package/docker/Dockerfile.agent +31 -0
- package/docker/Dockerfile.proxy +12 -0
- package/docker/compose.yml +31 -0
- package/fixtures/subagent-grants.json +5 -0
- package/package.json +70 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
{
|
|
2
|
+
"baselineVersion": 1,
|
|
3
|
+
"appVersion": "1.12603.1",
|
|
4
|
+
"agentVersion": "2.1.170",
|
|
5
|
+
"agentBinary": {
|
|
6
|
+
"stagedPath": "~/Library/Application Support/Claude/claude-code-vm/2.1.170/claude",
|
|
7
|
+
"format": "elf-aarch64",
|
|
8
|
+
"$comment": "There is NO npm path — the Linux/arm64 ELF is bind-mounted from this staged Desktop install (or COWORK_AGENT_BINARY). npmPackage/preferReuseStaged removed (Q1)."
|
|
9
|
+
},
|
|
10
|
+
"guest": {
|
|
11
|
+
"os": "linux",
|
|
12
|
+
"arch": "arm64",
|
|
13
|
+
"baseImage": "ubuntu:22.04"
|
|
14
|
+
},
|
|
15
|
+
"spawn": {
|
|
16
|
+
"$comment": "Binary-verified Desktop->agent spawn contract (asar 1.12603.1). See docs/cowork-spawn-contract-1.12603.1.md.",
|
|
17
|
+
"configDirInGuest": "mnt/.claude",
|
|
18
|
+
"settingSources": ["user"],
|
|
19
|
+
"permissionMode": "default",
|
|
20
|
+
"maxThinkingTokens": 31999,
|
|
21
|
+
"effortDefault": "medium",
|
|
22
|
+
"tools": ["Task","Bash","Glob","Grep","Read","Edit","Write","NotebookEdit","WebFetch","TaskCreate","TaskUpdate","TaskGet","TaskList","TaskStop","WebSearch","Skill","REPL","JavaScript","AskUserQuestion","ToolSearch"],
|
|
23
|
+
"allowedTools": ["Task","Bash","Glob","Grep","Read","Edit","Write","NotebookEdit","WebFetch","TaskCreate","TaskUpdate","TaskGet","TaskList","TaskStop","WebSearch","Skill","REPL","JavaScript","ToolSearch"],
|
|
24
|
+
"env": {
|
|
25
|
+
"CLAUDE_CODE_IS_COWORK": "1",
|
|
26
|
+
"CLAUDE_CODE_ENTRYPOINT": "local-agent",
|
|
27
|
+
"CLAUDE_CODE_TAGS": "lam_session_type:chat",
|
|
28
|
+
"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": "1",
|
|
29
|
+
"CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL": "true",
|
|
30
|
+
"CLAUDE_CODE_DISABLE_CRON": "1",
|
|
31
|
+
"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
|
|
32
|
+
"CLAUDE_CODE_DISABLE_AGENTS_FLEET": "1",
|
|
33
|
+
"CLAUDE_CODE_ENABLE_APPEND_SUBAGENT_PROMPT": "1",
|
|
34
|
+
"CLAUDE_CODE_ENABLE_TASKS": "true",
|
|
35
|
+
"CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1",
|
|
36
|
+
"ENABLE_PROMPT_CACHING_1H": "1",
|
|
37
|
+
"DISABLE_MICROCOMPACT": "1",
|
|
38
|
+
"MCP_CONNECTION_NONBLOCKING": "true"
|
|
39
|
+
},
|
|
40
|
+
"$comment_notSet": "Deliberately NOT set: CLAUDE_CODE_USE_COWORK_PLUGINS (Desktop never sets it; would flip the agent to cowork_settings.json/cowork_plugins). Host-derived (TZ, account UUIDs, WORKSPACE_HOST_PATHS, OTEL) injected at runtime, not pinned.",
|
|
41
|
+
"promptTemplate": "prompts/desktop-1.12603.1/system-prompt-append.md",
|
|
42
|
+
"subagentAppend": "prompts/desktop-1.12603.1/subagent-append-vm.md",
|
|
43
|
+
"$comment_prompts": "Reconstructed cowork-specific sections (not the full base prompt — not cleanly extractable). The main append is delivered via the --append-system-prompt CLI flag (layered on the agent's built-in base prompt), NOT the initialize handshake; only the subagent append goes over initialize (appendSubagentSystemPrompt), gated on CLAUDE_CODE_ENABLE_APPEND_SUBAGENT_PROMPT."
|
|
44
|
+
},
|
|
45
|
+
"mountLayout": {
|
|
46
|
+
"sessionRoot": "/sessions/{sessionId}",
|
|
47
|
+
"cwd": "/sessions/{sessionId}",
|
|
48
|
+
"mntRoot": "/sessions/{sessionId}/mnt",
|
|
49
|
+
"$comment_modes": "Mount modes are binary-verified against app.asar 1.12603.1: uploads = 'ro' (read-only); outputs + projects(.projects/<id>) default to 'rw' (delete DENIED) via IX(name,approvedSet,bypass) — they become 'rwd' (delete enabled) ONLY when the mount is in the session's fileDeleteApprovedMounts (toggled by the always-ask allow_cowork_file_delete tool); local/remote-plugins = 'r'. Vocabulary: 'r'=read-only (asar 'ro'), 'rw'=write-no-delete, 'rwd'=write+delete. These are the DEFAULT (unapproved) modes. NOTE: descriptive today (resolveMounts does not enforce them) — the faithful enforcement is deferred sub-project #9-A.",
|
|
50
|
+
"mounts": [
|
|
51
|
+
{
|
|
52
|
+
"name": "uploads",
|
|
53
|
+
"mountPath": "uploads",
|
|
54
|
+
"mode": "r",
|
|
55
|
+
"purpose": "user-uploaded files (read-only — asar 'ro')"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "projects",
|
|
59
|
+
"mountPath": ".projects/{projectId}",
|
|
60
|
+
"mode": "rw",
|
|
61
|
+
"purpose": "selected work folders (a Space) — delete denied by default (asar IX)"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "local-plugins",
|
|
65
|
+
"mountPath": ".local-plugins/cache",
|
|
66
|
+
"mode": "r",
|
|
67
|
+
"purpose": "marketplace skills/plugins, runtime-discovered"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "remote-plugins",
|
|
71
|
+
"mountPath": ".remote-plugins",
|
|
72
|
+
"mode": "r",
|
|
73
|
+
"purpose": "org-remote plugins, runtime-discovered"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "outputs",
|
|
77
|
+
"mountPath": "outputs",
|
|
78
|
+
"mode": "rw",
|
|
79
|
+
"purpose": "session outputs/artifacts — delete denied by default (asar IX); rwd only when approved"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
"network": {
|
|
84
|
+
"mode": "gvisor",
|
|
85
|
+
"allowKind": "allowlist",
|
|
86
|
+
"allowDomains": [
|
|
87
|
+
"sentry.io",
|
|
88
|
+
"preview.claude.ai",
|
|
89
|
+
"downloads.claude.ai",
|
|
90
|
+
"api.anthropic.com",
|
|
91
|
+
"a-cdn.anthropic.com",
|
|
92
|
+
"a-api.anthropic.com",
|
|
93
|
+
"console.anthropic.com",
|
|
94
|
+
"api-staging.anthropic.com",
|
|
95
|
+
"docs.anthropic.com",
|
|
96
|
+
"mcp-proxy.anthropic.com",
|
|
97
|
+
"pivot.claude.ai",
|
|
98
|
+
"support.anthropic.com",
|
|
99
|
+
"assets.claude.ai"
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
"bgEnvStrip": {
|
|
103
|
+
"knownVars": [
|
|
104
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
105
|
+
"CLAUDE_CODE_SESSION_KIND",
|
|
106
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
107
|
+
"CLAUDE_CODE_SESSION_NAME",
|
|
108
|
+
"CLAUDE_CODE_SESSION_LOG"
|
|
109
|
+
]
|
|
110
|
+
},
|
|
111
|
+
"$comment": "Platform baseline auto-derived by `cowork-harness sync` from a live Claude Desktop install + app.asar. VOLATILE per-release facts only. Regenerate per release; review the diff. Captured 2026-06-12 on macOS arm64.",
|
|
112
|
+
"capturedAt": "2026-06-12",
|
|
113
|
+
"platform": "darwin-arm64",
|
|
114
|
+
"settings": {
|
|
115
|
+
"autoMountFolders": {
|
|
116
|
+
"key": "autoMountFolders",
|
|
117
|
+
"default": false
|
|
118
|
+
},
|
|
119
|
+
"localAgentModeTrustedFolders": {
|
|
120
|
+
"key": "localAgentModeTrustedFolders",
|
|
121
|
+
"default": []
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"provenance": {
|
|
125
|
+
"asarPath": "/Applications/Claude.app/Contents/Resources/app.asar",
|
|
126
|
+
"asarFingerprint": "887a3ce86bf07c7f",
|
|
127
|
+
"gates": {
|
|
128
|
+
"hostLoop:1143815894": "on(force)",
|
|
129
|
+
"taskDispatchLimiter:1648655587": "on(force) perTask=1 global=3 (host-side SKIP: recordSkipAndEmit/GCA.PerTaskLimit — NOT queue/deny). A dispatch session launches <=1 sub-task; <=3 concurrent globally.",
|
|
130
|
+
"coworkRuntimeConfig:1978029737": "on(force) coworkWebFetchViaApi=true coworkWebFetchPrompt=true workspaceBashWaitLonger=true sessionsBridgePollBlockMs=30 — web_fetch is host/API-routed (POST /api/organizations/<org>/cowork/web_fetch), NOT container egress; gated by a separate web-fetch hostname allowlist + URL provenance.",
|
|
131
|
+
"bridgeSdkTransport:583857784": "on(force) — Cowork uses the SDK-based transport (control protocol), confirming the harness's sdkMcpServers/mcp_message path is the production transport.",
|
|
132
|
+
"pluginSyncSparkplug:2340532315": "on(force) — startup syncPlugins(); plugins load via --plugin-dir (registry inert in-VM).",
|
|
133
|
+
"cliPlugin:2307090146": "off(default) — the CLI-plugin credential broker is dark-launched off for standard interactive accounts (Ch23/L106).",
|
|
134
|
+
"$comment": "Production GrowthBook gate states decoded from ~/Library/Application Support/Claude/fcache (standard interactive Anthropic account, 2026-06-13; binary-verified app.asar 1.12603.1). Pin per release. Behavior-affecting gates the harness models: 1143815894 (loop), 1648655587 (dispatch cap), 1978029737 (web_fetch routing). Telemetry/auth-internal gates omitted."
|
|
135
|
+
},
|
|
136
|
+
"eipcChannelUuid": "4f426349-8d6f-45f3-ae22-280fef323564",
|
|
137
|
+
"$comment": "eipcChannelUuid is per-build; recorded for provenance only — the harness does not use Desktop IPC."
|
|
138
|
+
},
|
|
139
|
+
"requireFullVmSandbox": null
|
|
140
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!-- host-loop "Shell access" section, reconstructed verbatim from the asar (D8r host
|
|
2
|
+
branch). Appended to the system prompt only in host-loop. {{vmMnt}} substituted. -->
|
|
3
|
+
|
|
4
|
+
## Shell access
|
|
5
|
+
|
|
6
|
+
Shell commands use `mcp__workspace__bash` and run in an isolated Linux environment. Each call is independent — no cwd or env carryover between calls. Use absolute paths.
|
|
7
|
+
|
|
8
|
+
Paths in bash differ from what file tools (Read/Write/Edit) see. Your connected folders are mounted in bash under {{vmMnt}}/. A file you Read at a host path is reached in bash under {{vmMnt}}/ — translate host paths to their {{vmMnt}} equivalent before using them in bash. In particular, `${CLAUDE_PLUGIN_ROOT}` is a host path; to run a plugin's scripts in bash, locate them under {{vmMnt}} (e.g. `find {{vmMnt}} -path '*/skills/*/scripts'`) rather than using the host path directly.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
## Cowork environment
|
|
2
|
+
|
|
3
|
+
You are running as a subagent inside a Cowork session. Shell commands execute in an isolated Linux sandbox rooted at {{cwd}} — files created there (or under /tmp) exist only in the sandbox and are not visible to the user unless written to the outputs directory. User-attached folders are mounted under {{cwd}}/mnt/.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- Reconstructed from verbatim asar fragments (build 1.12603.1). NOT the full base
|
|
2
|
+
prompt (which is assembled from many interpolated fragments and not cleanly
|
|
3
|
+
extractable) — only the cowork-specific sections that drive skill behavior.
|
|
4
|
+
Delivered via the `--append-system-prompt` CLI flag (layered on the agent's built-in
|
|
5
|
+
base prompt), NOT the initialize handshake. Only the subagent append goes over
|
|
6
|
+
`initialize` (appendSubagentSystemPrompt). Tokens substituted by src/prompt.ts
|
|
7
|
+
mirroring the Desktop builder `y8r`. -->
|
|
8
|
+
|
|
9
|
+
<high_level_computer_use_explanation>
|
|
10
|
+
Claude runs in a lightweight Linux VM (Ubuntu 22) on the user's computer. This VM provides a secure sandbox for executing code while allowing controlled access to user files. The working directory is {{cwd}}; user-attached folders are mounted under {{cwd}}/mnt/.
|
|
11
|
+
</high_level_computer_use_explanation>
|
|
12
|
+
|
|
13
|
+
<file_handling_rules>
|
|
14
|
+
- The outputs directory ({{workspaceFolder}}) is where user-visible deliverables belong. Files written there are surfaced to the user; files written elsewhere in the sandbox are not.
|
|
15
|
+
- Files in the outputs directory cannot be deleted (the operation is not permitted) — overwrite in place instead of delete-and-recreate.
|
|
16
|
+
- Never expose absolute /sessions/ sandbox paths to the user; refer to files by their name or relative location.
|
|
17
|
+
- Skills live under {{skillsDir}}/skills/. When a user request matches a skill, immediately Read the relevant SKILL.md and follow it.
|
|
18
|
+
</file_handling_rules>
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
// ---- Control-response envelopes (verified zod shape; the inner `response` nesting is load-bearing) ----
|
|
5
|
+
/** The one success-envelope shape every control_response shares; the four builders below differ ONLY in
|
|
6
|
+
* the inner `body`. Keeping a single core stops the wrapper drifting between them. */
|
|
7
|
+
function successEnvelope(requestId, body) {
|
|
8
|
+
return { type: "control_response", response: { subtype: "success", request_id: requestId, response: body } };
|
|
9
|
+
}
|
|
10
|
+
export function allowEnvelope(requestId, updatedInput) {
|
|
11
|
+
return successEnvelope(requestId, { behavior: "allow", updatedInput });
|
|
12
|
+
}
|
|
13
|
+
export function denyEnvelope(requestId, message) {
|
|
14
|
+
return successEnvelope(requestId, { behavior: "deny", message });
|
|
15
|
+
}
|
|
16
|
+
export function mcpResponseEnvelope(requestId, payload, id) {
|
|
17
|
+
return successEnvelope(requestId, id !== undefined && id !== null ? { mcp_response: { jsonrpc: "2.0", id, ...payload } } : {});
|
|
18
|
+
}
|
|
19
|
+
const dialogEnvelope = successEnvelope;
|
|
20
|
+
// ---- #8: PreToolUse hooks (the harness mirrors Cowork's host-installed hooks) ----
|
|
21
|
+
// Binary-verified (app.asar 1.12603.1): in cowork mode the host installs a PreToolUse `Task` hook that
|
|
22
|
+
// blocks `run_in_background` ("Background agents disabled"). Over the stream-json transport, `initialize`
|
|
23
|
+
// declares `hooks: {PreToolUse:[{matcher, hookCallbackIds:[id]}]}`; when the hook fires the agent sends a
|
|
24
|
+
// `control_request {subtype:"hook_callback", callback_id, input, tool_use_id}` and we reply with a
|
|
25
|
+
// success control_response carrying the hook output ({decision:"block",…} or {} to allow). The hook is
|
|
26
|
+
// evaluated in the agent loop → tier-uniform (container/microvm/host-loop) by construction.
|
|
27
|
+
const TASK_BG_HOOK_ID = "cowork-task-bg-block";
|
|
28
|
+
/** The PreToolUse hooks the harness installs in cowork mode (sent on `initialize`). */
|
|
29
|
+
export const COWORK_PRETOOLUSE_HOOKS = { PreToolUse: [{ matcher: "Task", hookCallbackIds: [TASK_BG_HOOK_ID] }] };
|
|
30
|
+
/** Pure output for a fired hook callback. Unknown ids → no-op (allow); the only installed hook blocks
|
|
31
|
+
* `Task` with `run_in_background` to match Cowork's verbatim reason string. */
|
|
32
|
+
export function hookOutput(callbackId, input) {
|
|
33
|
+
if (callbackId === TASK_BG_HOOK_ID && input?.tool_input?.run_in_background) {
|
|
34
|
+
return { decision: "block", reason: "Background agents disabled" };
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
/** The key the in-VM AskUserQuestion handler indexes answers by — it does
|
|
39
|
+
* `questions.map(({question}) => answers[question])`, so the answer map MUST be keyed by `question`,
|
|
40
|
+
* never `header`. (A header-only gate has an empty key; the run loop rejects it loud.) */
|
|
41
|
+
export function questionKey(q) {
|
|
42
|
+
return q.question ?? "";
|
|
43
|
+
}
|
|
44
|
+
/** Human-readable label for records/traces/regex-matching — falls back to `header` for display only. */
|
|
45
|
+
export function questionLabel(q) {
|
|
46
|
+
return q.question || q.header || "";
|
|
47
|
+
}
|
|
48
|
+
/** Serialize a DecisionResponse to the wire envelope for a given request. */
|
|
49
|
+
export function serializeDecision(req, r) {
|
|
50
|
+
if (req.kind === "permission" && r.kind === "permission") {
|
|
51
|
+
// Off-wire pin: a web_fetch grant must never be serialized (web_fetch approval is host-synthesized and
|
|
52
|
+
// never hits the wire). If a grant-bearing permission reaches here, a refactor routed web_fetch through
|
|
53
|
+
// the protocol — fail loud rather than silently drop `grant`.
|
|
54
|
+
if (r.grant !== undefined)
|
|
55
|
+
throw new Error("serializeDecision: a web_fetch `grant` permission must not be serialized (it is off-wire by construction)");
|
|
56
|
+
return r.behavior === "allow"
|
|
57
|
+
? allowEnvelope(req.id, r.updatedInput ?? req.input)
|
|
58
|
+
: denyEnvelope(req.id, r.message ?? "denied");
|
|
59
|
+
}
|
|
60
|
+
if (req.kind === "question" && r.kind === "question") {
|
|
61
|
+
// AskUserQuestion: the binary's BUILT-IN handler (enabled in cowork mode) executes with this
|
|
62
|
+
// updatedInput and does `questions.map(({question}) => answers[question])` to build the tool_result
|
|
63
|
+
// (ELF-verified: `mapToolResultToToolResultBlockParam`). So updatedInput MUST carry the full input —
|
|
64
|
+
// `questions` AND `answers`. Dropping `questions` → `undefined is not an object (evaluating 'q.map')`,
|
|
65
|
+
// the answer never reaches the model, and gate-steering silently no-ops (O7).
|
|
66
|
+
return allowEnvelope(req.id, { questions: req.questions, answers: r.answers });
|
|
67
|
+
}
|
|
68
|
+
if (req.kind === "dialog" && r.kind === "dialog") {
|
|
69
|
+
return dialogEnvelope(req.id, r.behavior === "ok" ? { behavior: "ok", choice: r.choice } : { behavior: "cancelled" });
|
|
70
|
+
}
|
|
71
|
+
if (req.kind === "elicit" && r.kind === "elicit") {
|
|
72
|
+
return dialogEnvelope(req.id, { action: r.action, ...(r.content !== undefined ? { content: r.content } : {}) });
|
|
73
|
+
}
|
|
74
|
+
// mismatched kinds → safe cancel/deny
|
|
75
|
+
return denyEnvelope(req.id, "decider returned a mismatched response kind");
|
|
76
|
+
}
|
|
77
|
+
// ---- Canonical-JSON comparator (for the O7 replay guard) ----
|
|
78
|
+
/** Recursively sort object keys then JSON.stringify — normalises insertion-order differences so a
|
|
79
|
+
* semantically-identical key reorder does NOT produce a false mismatch in the replay guard.
|
|
80
|
+
* `undefined`-valued keys are dropped by stringify, so absent-vs-undefined still normalises. */
|
|
81
|
+
export function canon(x) {
|
|
82
|
+
if (x === null || typeof x !== "object")
|
|
83
|
+
return JSON.stringify(x);
|
|
84
|
+
if (Array.isArray(x))
|
|
85
|
+
return "[" + x.map(canon).join(",") + "]";
|
|
86
|
+
const sorted = Object.keys(x)
|
|
87
|
+
.sort()
|
|
88
|
+
.reduce((acc, k) => {
|
|
89
|
+
const v = x[k];
|
|
90
|
+
if (v !== undefined)
|
|
91
|
+
acc[k] = v;
|
|
92
|
+
return acc;
|
|
93
|
+
}, {});
|
|
94
|
+
return ("{" +
|
|
95
|
+
Object.entries(sorted)
|
|
96
|
+
.map(([k, v]) => JSON.stringify(k) + ":" + canon(v))
|
|
97
|
+
.join(",") +
|
|
98
|
+
"}");
|
|
99
|
+
}
|
|
100
|
+
/** Declared inverse of `serializeDecision` — kept adjacent with a pinning comment.
|
|
101
|
+
* Input = the `response.response` body from a recorded `control_response` success envelope.
|
|
102
|
+
* Output = the `DecisionResponse` the live decider originally produced.
|
|
103
|
+
* Keyed on the LIVE `req.kind` (not the body) to stay consistent with `serializeDecision`.
|
|
104
|
+
*
|
|
105
|
+
* MUST NOT route through `serializeDecision` — that would make the O7 guard circular (the
|
|
106
|
+
* re-serialize-and-compare check in CassetteAgentSession.respond() would always match). */
|
|
107
|
+
export function deserializeDecision(req, body) {
|
|
108
|
+
if (req.kind === "permission") {
|
|
109
|
+
if (body.behavior === "deny") {
|
|
110
|
+
return { kind: "permission", behavior: "deny", message: String(body.message ?? "denied") };
|
|
111
|
+
}
|
|
112
|
+
// allow: recover updatedInput (may be req.input due to lossy default in serializeDecision:88)
|
|
113
|
+
if (body.behavior === "allow")
|
|
114
|
+
return { kind: "permission", behavior: "allow", updatedInput: body.updatedInput };
|
|
115
|
+
// Any OTHER permission body ({}, {behavior:"cancelled"}, garbage — a corrupt/truncated cassette) must
|
|
116
|
+
// NOT silently replay as allow. Map to a deny that will NOT re-serialize to the recorded body, so the
|
|
117
|
+
// O7 guard in respond() trips a loud replay_protocol_fidelity mismatch. Mirrors the elicit branch's
|
|
118
|
+
// known-action validation below (declared-inverse symmetry).
|
|
119
|
+
return { kind: "permission", behavior: "deny", message: "deserializeDecision: invalid permission behavior" };
|
|
120
|
+
}
|
|
121
|
+
if (req.kind === "question") {
|
|
122
|
+
// AskUserQuestion: body is { behavior:"allow", updatedInput:{ questions, answers } }
|
|
123
|
+
// We read `answers` back; `questions` was preserved in recording for the O7 guard.
|
|
124
|
+
const ui = (body.updatedInput ?? {});
|
|
125
|
+
return { kind: "question", answers: (ui.answers ?? {}) };
|
|
126
|
+
}
|
|
127
|
+
if (req.kind === "dialog") {
|
|
128
|
+
return {
|
|
129
|
+
kind: "dialog",
|
|
130
|
+
behavior: body.behavior === "ok" ? "ok" : "cancelled",
|
|
131
|
+
...(body.behavior === "ok" && body.choice !== undefined ? { choice: body.choice } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (req.kind === "elicit") {
|
|
135
|
+
// #22: validate against the known action set instead of an unchecked `as` cast. A recorded
|
|
136
|
+
// action that is missing or unrecognized (a corrupt/truncated cassette) maps to "decline" — a
|
|
137
|
+
// value that will NOT re-serialize back to the corrupt input, so the O7 guard in respond()
|
|
138
|
+
// trips a loud replay_protocol_fidelity mismatch rather than silently coercing. A valid
|
|
139
|
+
// "accept"/"cancel"/"decline" passes through and round-trips byte-identically.
|
|
140
|
+
const raw = body.action;
|
|
141
|
+
const action = raw === "accept" || raw === "cancel" ? raw : "decline";
|
|
142
|
+
return {
|
|
143
|
+
kind: "elicit",
|
|
144
|
+
action,
|
|
145
|
+
...(body.content !== undefined ? { content: body.content } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// fallback: deny-like
|
|
149
|
+
return { kind: "permission", behavior: "deny", message: "deserializeDecision: unknown req kind" };
|
|
150
|
+
}
|
|
151
|
+
export class LiveAgentSession {
|
|
152
|
+
proc;
|
|
153
|
+
outDir;
|
|
154
|
+
events;
|
|
155
|
+
controlOut;
|
|
156
|
+
reqById = new Map();
|
|
157
|
+
sdkMcp;
|
|
158
|
+
initWritten = false;
|
|
159
|
+
/** Reject function set when proc emits an error — bridges the callback into the async generator.
|
|
160
|
+
* Set before the generator loop starts; called at most once (the Promise settles once). */
|
|
161
|
+
rejectError;
|
|
162
|
+
constructor(proc, outDir) {
|
|
163
|
+
this.proc = proc;
|
|
164
|
+
this.outDir = outDir;
|
|
165
|
+
this.events = createWriteStream(join(outDir, "events.jsonl"), { flags: "a" });
|
|
166
|
+
this.controlOut = createWriteStream(join(outDir, "control-out.jsonl"), { flags: "a" });
|
|
167
|
+
const errLog = createWriteStream(join(outDir, "agent.stderr.log"), { flags: "a" });
|
|
168
|
+
this.proc.stderr.pipe(errLog);
|
|
169
|
+
// #15: attach stdin error listener once at construction so dead-child writes don't produce
|
|
170
|
+
// unhandled process errors. Routes to the same error path as spawn errors when possible.
|
|
171
|
+
this.proc.stdin.on("error", (e) => {
|
|
172
|
+
if (this.rejectError)
|
|
173
|
+
this.rejectError(e);
|
|
174
|
+
// else: the error fired before/after the generator — log it but don't throw
|
|
175
|
+
else
|
|
176
|
+
this.events.write(JSON.stringify({ _emu: "stdin_error", message: String(e) }) + "\n");
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Write the `initialize` control_request (idempotent). #45: `Run.drive` calls this BEFORE the first
|
|
181
|
+
* `sendUserTurn` so the wire order matches the SPEC (initialize precedes the user turn); `start()`
|
|
182
|
+
* also calls it so a standalone `start()` (no prior `init`) still initializes. Guarded so the two
|
|
183
|
+
* call sites never double-write init-1.
|
|
184
|
+
*/
|
|
185
|
+
init(opts = {}) {
|
|
186
|
+
if (this.initWritten)
|
|
187
|
+
return;
|
|
188
|
+
this.initWritten = true;
|
|
189
|
+
this.sdkMcp = opts.sdkMcp;
|
|
190
|
+
const initRequest = { subtype: "initialize" };
|
|
191
|
+
if (opts.subagentAppend)
|
|
192
|
+
initRequest.appendSubagentSystemPrompt = opts.subagentAppend;
|
|
193
|
+
if (opts.sdkMcp?.servers.length)
|
|
194
|
+
initRequest.sdkMcpServers = opts.sdkMcp.servers;
|
|
195
|
+
initRequest.hooks = COWORK_PRETOOLUSE_HOOKS; // #8: block Task run_in_background, mirroring cowork
|
|
196
|
+
this.write({ type: "control_request", request_id: "init-1", request: initRequest });
|
|
197
|
+
}
|
|
198
|
+
async *start(opts = {}) {
|
|
199
|
+
this.init(opts); // idempotent — a no-op if drive() already wrote init-1 before the first user turn
|
|
200
|
+
// #13: race-approach latch — `errorPromise` rejects when proc emits an error, which is
|
|
201
|
+
// outside the readline loop. We race each rl.next() against it so the generator yields a
|
|
202
|
+
// typed {type:"error"} event and terminates cleanly instead of silently blocking on stdout.
|
|
203
|
+
let errorPromise = new Promise((_res, rej) => (this.rejectError = rej));
|
|
204
|
+
// Also write the _emu entry for backwards-compat with any tooling that reads events.jsonl.
|
|
205
|
+
// #9: route the spawn error through `rejectError` (like the stdin handler) so the Promise.race
|
|
206
|
+
// below rejects and the generator yields a typed {type:"error"} event instead of hanging on
|
|
207
|
+
// stdout that will never arrive.
|
|
208
|
+
this.proc.on("error", (e) => {
|
|
209
|
+
this.events.write(JSON.stringify({ _emu: "spawn_error", message: String(e) }) + "\n");
|
|
210
|
+
if (this.rejectError)
|
|
211
|
+
this.rejectError(e instanceof Error ? e : new Error(String(e)));
|
|
212
|
+
});
|
|
213
|
+
const rl = readline.createInterface({ input: this.proc.stdout });
|
|
214
|
+
const iter = rl[Symbol.asyncIterator]();
|
|
215
|
+
try {
|
|
216
|
+
while (true) {
|
|
217
|
+
// Race the next readline item against a spawn/stdin error.
|
|
218
|
+
let next;
|
|
219
|
+
try {
|
|
220
|
+
next = await Promise.race([iter.next(), errorPromise]);
|
|
221
|
+
}
|
|
222
|
+
catch (spawnErr) {
|
|
223
|
+
// Error won the race — emit a typed error event and stop.
|
|
224
|
+
const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
|
|
225
|
+
yield { type: "error", source: "spawn", message: msg };
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (next.done)
|
|
229
|
+
break;
|
|
230
|
+
const line = next.value;
|
|
231
|
+
if (!line.trim())
|
|
232
|
+
continue;
|
|
233
|
+
this.events.write(line + "\n");
|
|
234
|
+
let msg;
|
|
235
|
+
try {
|
|
236
|
+
msg = JSON.parse(line);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
yield { type: "raw", line };
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
yield* this.translate(msg);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
this.rejectError = undefined; // generator is done; stop routing errors here
|
|
247
|
+
// #46: AWAIT the stream flush before the generator resolves. executeScenario reads/scans/scrubs
|
|
248
|
+
// events.jsonl + control-out.jsonl immediately after `drive()` returns; a fire-and-forget end()
|
|
249
|
+
// races the final buffered writes. end(cb) fires the callback on 'finish' (fully flushed).
|
|
250
|
+
await Promise.all([
|
|
251
|
+
new Promise((res) => this.events.end(() => res())),
|
|
252
|
+
new Promise((res) => this.controlOut.end(() => res())),
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async *translate(msg) {
|
|
257
|
+
// #8: a PreToolUse hook fired pre-dispatch (side-effecting, like mcp_message). Reply with the
|
|
258
|
+
// installed hook's output so the agent blocks/allows; a dropped reply would deadlock the agent.
|
|
259
|
+
if (msg.type === "control_request" && msg.request?.subtype === "hook_callback") {
|
|
260
|
+
this.write(successEnvelope(msg.request_id, hookOutput(msg.request.callback_id, msg.request.input)));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// mcp_message is the only side-effecting branch (the driver computes + writes the response).
|
|
264
|
+
if (msg.type === "control_request" && msg.request?.subtype === "mcp_message") {
|
|
265
|
+
const server = msg.request.server_name;
|
|
266
|
+
const jr = msg.request.message ?? {};
|
|
267
|
+
if (this.sdkMcp) {
|
|
268
|
+
let out;
|
|
269
|
+
try {
|
|
270
|
+
out = await this.sdkMcp.handle(server, jr); // #30: async (web_fetch may await an approval)
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
// A throw from handle() (e.g. a broken allow_if predicate in the decider) must NOT bypass the
|
|
274
|
+
// reply — an unanswered mcp_message blocks the in-VM agent on the round-trip forever (deadlock).
|
|
275
|
+
// Reply with a JSON-RPC error instead, mirroring the no-handler defense below.
|
|
276
|
+
const message = e?.message ?? String(e);
|
|
277
|
+
process.stderr.write(`::warning:: sdkMcp.handle threw for "${server}" — replying with a JSON-RPC error: ${message}\n`);
|
|
278
|
+
out = { error: { code: -32603, message: `handler error: ${message}` } };
|
|
279
|
+
}
|
|
280
|
+
this.write(mcpResponseEnvelope(msg.request_id, out, jr.id));
|
|
281
|
+
// Echo the MCP round-trip as a SYNTHETIC tool_use for provenance/trace only. The real tool call
|
|
282
|
+
// also arrives as an assistant tool_use block (live-verified: mcp__workspace__bash co-occurs with
|
|
283
|
+
// this mcp_message), which is what gets counted — so this is marked synthetic and excluded from
|
|
284
|
+
// toolsCalled/toolCounts to avoid polluting them with a bogus `mcp__server__*` entry.
|
|
285
|
+
if (server)
|
|
286
|
+
yield {
|
|
287
|
+
type: "tool_use",
|
|
288
|
+
name: jr.params?.name ? `mcp__${server}__${jr.params.name}` : `mcp__${server}__*`,
|
|
289
|
+
input: jr.params ?? {},
|
|
290
|
+
synthetic: true,
|
|
291
|
+
};
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// #10: an mcp_message arrived but no sdkMcp handler is configured. Reply with a JSON-RPC error
|
|
295
|
+
// (well-formed via mcpResponseEnvelope) instead of silently dropping it — a dropped request
|
|
296
|
+
// leaves the in-VM agent waiting on the round-trip forever (protocol deadlock in host-loop mode).
|
|
297
|
+
process.stderr.write(`::warning:: mcp_message for server "${server}" arrived but no sdkMcp handler is configured — replying with a JSON-RPC error (would otherwise deadlock)\n`);
|
|
298
|
+
this.write(mcpResponseEnvelope(msg.request_id, { error: { code: -32601, message: "no sdkMcp handler configured" } }, jr.id));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const ev of parseMessage(msg)) {
|
|
302
|
+
if (ev.type === "decision")
|
|
303
|
+
this.reqById.set(ev.request.id, ev.request);
|
|
304
|
+
yield ev;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
sendUserTurn(text) {
|
|
308
|
+
this.write({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
309
|
+
}
|
|
310
|
+
respond(decisionId, r) {
|
|
311
|
+
const req = this.reqById.get(decisionId);
|
|
312
|
+
if (!req) {
|
|
313
|
+
// #13: an id with no matching request_id is a protocol drift. Writing a guessed envelope would
|
|
314
|
+
// be worse, but a silent return leaves the agent blocked until timeout (looks like a hang).
|
|
315
|
+
process.stderr.write(`::warning:: respond() for unknown decision id "${decisionId}" — no matching request_id was seen; the agent may block until timeout (protocol drift)\n`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// #14: serializeDecision returns a safe deny envelope on a kind mismatch (defense in depth). That
|
|
319
|
+
// deny goes to the agent silently today — surface it loudly so the run record can't read "answered"
|
|
320
|
+
// while the agent actually received a deny. (serializeDecision stays a pure declared inverse of
|
|
321
|
+
// deserializeDecision; the warning lives here in the caller, not in the pure function.)
|
|
322
|
+
if (req.kind !== r.kind)
|
|
323
|
+
process.stderr.write(`::warning:: decider returned kind "${r.kind}" for a "${req.kind}" request (id ${decisionId}) → sending a safe deny/cancel; the agent did NOT receive an answer\n`);
|
|
324
|
+
this.write(serializeDecision(req, r));
|
|
325
|
+
}
|
|
326
|
+
close() {
|
|
327
|
+
try {
|
|
328
|
+
this.proc.stdin.end();
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
/* already gone */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
write(obj) {
|
|
335
|
+
const line = JSON.stringify(obj);
|
|
336
|
+
// #54: the control protocol writes small single-line JSON frames, so stdin backpressure
|
|
337
|
+
// effectively never engages; we intentionally ignore the write() return / drain here. If frame
|
|
338
|
+
// sizes ever grow, revisit with a drain-aware queue (which would make write()/respond() async).
|
|
339
|
+
// #47: guard that assumption — a frame past the threshold warns loudly so the "revisit" trigger
|
|
340
|
+
// fires instead of silently risking partial buffering on a frame far larger than expected.
|
|
341
|
+
if (line.length > 256 * 1024)
|
|
342
|
+
process.stderr.write(`::warning:: control frame is ${line.length} bytes (> 256 KiB) — stdin backpressure may engage; revisit write() with a drain-aware queue\n`);
|
|
343
|
+
this.controlOut.write(line + "\n");
|
|
344
|
+
this.proc.stdin.write(line + "\n");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Pure translation of one parsed stream-json message → AgentEvents (no side-effects). Shared by
|
|
348
|
+
* LiveAgentSession and CassetteAgentSession. mcp_message is handled by Live before this is called. */
|
|
349
|
+
export function parseMessage(msg) {
|
|
350
|
+
const ev = [];
|
|
351
|
+
switch (msg.type) {
|
|
352
|
+
case "system":
|
|
353
|
+
if (msg.subtype === "init")
|
|
354
|
+
ev.push({ type: "init", tools: msg.tools ?? [], mcpServers: msg.mcp_servers ?? [], cwd: msg.cwd });
|
|
355
|
+
else if (msg.subtype === "api_metrics")
|
|
356
|
+
ev.push({ type: "metrics", data: msg });
|
|
357
|
+
else if (msg.subtype === "thinking")
|
|
358
|
+
ev.push({ type: "thinking", text: String(msg.content ?? "") });
|
|
359
|
+
break;
|
|
360
|
+
case "control_request": {
|
|
361
|
+
const dr = toDecisionRequest(msg);
|
|
362
|
+
if (dr)
|
|
363
|
+
ev.push({ type: "decision", request: dr });
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "assistant": {
|
|
367
|
+
const parentToolUseId = msg.parent_tool_use_id ? String(msg.parent_tool_use_id) : undefined;
|
|
368
|
+
for (const block of msg.message?.content ?? []) {
|
|
369
|
+
if (block.type === "text")
|
|
370
|
+
ev.push({ type: "assistant_text", text: block.text, parentToolUseId });
|
|
371
|
+
else if (block.type === "thinking")
|
|
372
|
+
ev.push({ type: "thinking", text: block.thinking ?? block.text ?? "" });
|
|
373
|
+
else if (block.type === "tool_use") {
|
|
374
|
+
ev.push({
|
|
375
|
+
type: "tool_use",
|
|
376
|
+
name: block.name,
|
|
377
|
+
input: block.input,
|
|
378
|
+
parentToolUseId,
|
|
379
|
+
toolUseId: block.id ? String(block.id) : undefined,
|
|
380
|
+
});
|
|
381
|
+
// Sub-agent dispatch. The real cowork agent uses the `Agent` tool (`{description,
|
|
382
|
+
// subagent_type, prompt}`); older/other surfaces use `Task`. We recognize either name, plus
|
|
383
|
+
// any tool whose input carries `subagent_type` (rename-robust). Crucially we DON'T match the
|
|
384
|
+
// cowork `TaskCreate`/`TaskUpdate` todo-list tools (`{subject, description, activeForm}` /
|
|
385
|
+
// `{taskId, status}`) — they have no `subagent_type`, so they're excluded and never miscount.
|
|
386
|
+
const inp = (block.input ?? {});
|
|
387
|
+
if (block.name === "Agent" || block.name === "Task" || "subagent_type" in inp) {
|
|
388
|
+
const declared = Array.isArray(inp.tools)
|
|
389
|
+
? inp.tools.map(String)
|
|
390
|
+
: Array.isArray(inp.allowedTools)
|
|
391
|
+
? inp.allowedTools.map(String)
|
|
392
|
+
: []; // the `Agent` tool declares no tools list → []; declared-but-unused is legacy-`Task`-only
|
|
393
|
+
ev.push({
|
|
394
|
+
type: "subagent_dispatch",
|
|
395
|
+
toolUseId: String(block.id ?? ""),
|
|
396
|
+
// Skills often dispatch with only {description, prompt} (no subagent_type) → agentType is
|
|
397
|
+
// "unknown" but the description still identifies the dispatch (e.g. "TOP_DOWN market sizing").
|
|
398
|
+
agentType: String(inp.subagent_type ?? inp.subagentType ?? "unknown"),
|
|
399
|
+
declaredTools: declared,
|
|
400
|
+
description: inp.description != null ? String(inp.description) : undefined,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
case "user":
|
|
408
|
+
// Tool OUTCOMES come back as `user` messages carrying tool_result blocks. We never parsed these
|
|
409
|
+
// before, so every tool result — including the AskUserQuestion `q.map` error — was invisible to the
|
|
410
|
+
// recorder/trace. Capture them for delivery verification + `trace --tools`/`--gates` (Part 2).
|
|
411
|
+
for (const block of msg.message?.content ?? []) {
|
|
412
|
+
if (block.type === "tool_result")
|
|
413
|
+
ev.push({
|
|
414
|
+
type: "tool_result",
|
|
415
|
+
toolUseId: block.tool_use_id ? String(block.tool_use_id) : undefined,
|
|
416
|
+
isError: !!block.is_error,
|
|
417
|
+
text: toolResultText(block.content),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
case "result":
|
|
422
|
+
ev.push({ type: "result", isError: !!msg.is_error, usage: msg.usage });
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
return ev;
|
|
426
|
+
}
|
|
427
|
+
/** Flatten a tool_result `content` (a string, or an array of `{type:"text",text}` blocks) to a short string. */
|
|
428
|
+
function toolResultText(content) {
|
|
429
|
+
if (typeof content === "string")
|
|
430
|
+
return content.slice(0, 500);
|
|
431
|
+
if (Array.isArray(content))
|
|
432
|
+
return content
|
|
433
|
+
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
434
|
+
.join(" ")
|
|
435
|
+
.slice(0, 500);
|
|
436
|
+
return "";
|
|
437
|
+
}
|
|
438
|
+
export function toDecisionRequest(msg) {
|
|
439
|
+
const sub = msg.request?.subtype;
|
|
440
|
+
const id = msg.request_id;
|
|
441
|
+
if (sub === "can_use_tool") {
|
|
442
|
+
const tool = msg.request.tool_name ?? "";
|
|
443
|
+
if (tool === "AskUserQuestion")
|
|
444
|
+
// Capture the `toolu_…` tool_use_id (distinct from the UUID request_id) to pair the gate with its
|
|
445
|
+
// tool_result later (amendment #1). The SDK puts it on the request envelope.
|
|
446
|
+
return {
|
|
447
|
+
id,
|
|
448
|
+
kind: "question",
|
|
449
|
+
questions: (msg.request.input?.questions ?? []),
|
|
450
|
+
toolUseId: msg.request.tool_use_id ? String(msg.request.tool_use_id) : undefined,
|
|
451
|
+
};
|
|
452
|
+
return { id, kind: "permission", tool, input: msg.request.input ?? {} };
|
|
453
|
+
}
|
|
454
|
+
if (sub === "request_user_dialog")
|
|
455
|
+
return { id, kind: "dialog", dialogKind: msg.request.dialogKind ?? msg.request.dialog_kind ?? "unknown", payload: msg.request.payload };
|
|
456
|
+
if (sub === "elicitation" || sub === "side_question")
|
|
457
|
+
return {
|
|
458
|
+
id,
|
|
459
|
+
kind: "elicit",
|
|
460
|
+
server: msg.request.mcp_server_name ?? msg.request.server,
|
|
461
|
+
prompt: msg.request.message ?? msg.request.prompt,
|
|
462
|
+
schema: msg.request.requestedSchema,
|
|
463
|
+
};
|
|
464
|
+
return null;
|
|
465
|
+
}
|