@cabane/companion 0.6.53 → 0.6.59
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/dist/cli.js +269 -69
- package/dist/runtime.js +269 -69
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2423,7 +2423,7 @@ var CabaneApi = class {
|
|
|
2423
2423
|
return this.request("GET", `/api/workspaces/${workspaceId}/conversations/${conversationId}`);
|
|
2424
2424
|
}
|
|
2425
2425
|
// CT339: spawn a sub-agent conversation — the public create-conversation
|
|
2426
|
-
// endpoint (`POST /conversations/messages`) with
|
|
2426
|
+
// endpoint (`POST /conversations/messages`) with an addressed agent ask,
|
|
2427
2427
|
// the machinery the `sub_agent` turn-control tool is sugar over. Two things make
|
|
2428
2428
|
// it distinct from an ordinary `request` call, so it does its own `fetch`:
|
|
2429
2429
|
// - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
|
|
@@ -2431,10 +2431,10 @@ var CabaneApi = class {
|
|
|
2431
2431
|
// cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
|
|
2432
2432
|
// - the `x-cabane-active-conversation` header naming the caller's turn, which
|
|
2433
2433
|
// the server verifies against the live run to resolve the caller pair for the
|
|
2434
|
-
//
|
|
2434
|
+
// immutable birth tether (the same origin that stamps provenance).
|
|
2435
2435
|
// Single-shot (no outbox/retry): a `sub_agent` spawn is a live, user-visible
|
|
2436
2436
|
// action, and its result is returned to the agent immediately. Returns
|
|
2437
|
-
// `{ status, body }` un-thrown so the caller maps
|
|
2437
|
+
// `{ status, body }` un-thrown so the caller maps validation errors
|
|
2438
2438
|
// to a legible tool result rather than an exception.
|
|
2439
2439
|
async createSubAgentConversation(workspaceId, activeConversationId, bearer, body) {
|
|
2440
2440
|
const res = await fetch(`${this.base}/api/workspaces/${workspaceId}/conversations/messages`, {
|
|
@@ -2494,7 +2494,7 @@ var CabaneApi = class {
|
|
|
2494
2494
|
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
|
|
2495
2495
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
2496
2496
|
}
|
|
2497
|
-
// CT714: read a turn's recorded turn-control intent (ask/wake/
|
|
2497
|
+
// CT714: read a turn's recorded turn-control intent (ask/wake/send/skip). An
|
|
2498
2498
|
// EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
|
|
2499
2499
|
// `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
|
|
2500
2500
|
// in-memory closures, so the dispatcher fetches this once at settle — by
|
|
@@ -3326,7 +3326,7 @@ var turnRequestSchema = z8.object({
|
|
|
3326
3326
|
// EXTERNAL adapters (Codex / opencode) mount it by URL under the key
|
|
3327
3327
|
// `cabane_companion` — using the same `bearer` (the turn token) and the same
|
|
3328
3328
|
// active-conversation header they send to the `cabane` server — so their
|
|
3329
|
-
// agents get `ask`/`wake_me`/`
|
|
3329
|
+
// agents get `ask`/`wake_me`/`send`/`sub_agent`/`skip_turn`, the
|
|
3330
3330
|
// verbs they can't get from the companion's in-process SDK server. Optional:
|
|
3331
3331
|
// claude-code ignores it (it mounts the in-process instance instead), and
|
|
3332
3332
|
// every existing `cabane`-block fixture keeps parsing unchanged; the
|
|
@@ -3361,7 +3361,7 @@ var turnRequestSchema = z8.object({
|
|
|
3361
3361
|
// default in place (see `buildClaudeCodeOptions`).
|
|
3362
3362
|
claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
|
|
3363
3363
|
}),
|
|
3364
|
-
// Host-owned injected servers (host-filled) — e.g. the
|
|
3364
|
+
// Host-owned injected servers (host-filled) — e.g. the send server.
|
|
3365
3365
|
extra: z8.object({
|
|
3366
3366
|
mcpServers: hostInjectedServersSchema
|
|
3367
3367
|
})
|
|
@@ -3692,8 +3692,9 @@ var CLAUDE_CODE_ADDENDUM = [
|
|
|
3692
3692
|
"`mcp__cabane__sdk`; the ancillary tools are `mcp__cabane__list_workspaces`,",
|
|
3693
3693
|
"`mcp__cabane__read_binary`, `mcp__cabane__upload`, `mcp__cabane__begin_upload`,",
|
|
3694
3694
|
"`mcp__cabane__finalize_upload`, and `mcp__cabane__mint_render_token`; the turn tools are",
|
|
3695
|
-
"`mcp__cabane_companion__ask`, `
|
|
3696
|
-
"`
|
|
3695
|
+
"`mcp__cabane_companion__ask`, `mcp__cabane_companion__reply_to`,",
|
|
3696
|
+
"`mcp__cabane_companion__wake_me`,",
|
|
3697
|
+
"`mcp__cabane_companion__send`, `mcp__cabane_companion__sub_agent`, and",
|
|
3697
3698
|
"`mcp__cabane_companion__skip_turn`. Where this prompt names a tool by its bare verb, call",
|
|
3698
3699
|
"the prefixed form from your tool list. Your harness's own tools (shell, local file",
|
|
3699
3700
|
"read/edit) keep their ordinary names and act on the local machine only."
|
|
@@ -3806,8 +3807,19 @@ function decideResume(stored, currentCwd) {
|
|
|
3806
3807
|
// packages/agent-runtime/src/claude-code/options.ts
|
|
3807
3808
|
var CABANE_MCP_SERVER = "cabane";
|
|
3808
3809
|
var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
|
|
3809
|
-
async function
|
|
3810
|
+
async function preToolUseHook(input) {
|
|
3810
3811
|
const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
3812
|
+
const toolName = "tool_name" in input ? input.tool_name : void 0;
|
|
3813
|
+
if (typeof toolName === "string" && SUBAGENT_TOOLS.includes(toolName) && (toolInput.run_in_background !== false || toolInput.isolation === "remote")) {
|
|
3814
|
+
return {
|
|
3815
|
+
continue: true,
|
|
3816
|
+
hookSpecificOutput: {
|
|
3817
|
+
hookEventName: "PreToolUse",
|
|
3818
|
+
permissionDecision: "deny",
|
|
3819
|
+
permissionDecisionReason: "Background subagents don't survive a Cabane turn: they're children of this turn's process and are killed when it ends, so nothing will notify you. Re-run this with run_in_background: false to get the result inside this turn, or arm a wake and check back."
|
|
3820
|
+
}
|
|
3821
|
+
};
|
|
3822
|
+
}
|
|
3811
3823
|
return {
|
|
3812
3824
|
continue: true,
|
|
3813
3825
|
hookSpecificOutput: {
|
|
@@ -3892,7 +3904,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
3892
3904
|
settingSources: ["project"],
|
|
3893
3905
|
allowedTools,
|
|
3894
3906
|
disallowedTools,
|
|
3895
|
-
hooks: { PreToolUse: [{ hooks: [
|
|
3907
|
+
hooks: { PreToolUse: [{ hooks: [preToolUseHook] }] }
|
|
3896
3908
|
};
|
|
3897
3909
|
} else {
|
|
3898
3910
|
options = {
|
|
@@ -3968,6 +3980,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3968
3980
|
const err = msg.error;
|
|
3969
3981
|
if (typeof err === "string" && err.length > 0) authError = err;
|
|
3970
3982
|
} else if (msg.type === "result") {
|
|
3983
|
+
const origin = msg.origin;
|
|
3984
|
+
if (origin?.kind === "task-notification") continue;
|
|
3971
3985
|
sawResult = true;
|
|
3972
3986
|
usage = readSdkUsage(msg);
|
|
3973
3987
|
if (usage) {
|
|
@@ -4160,6 +4174,10 @@ var resultSuccess = (sessionId) => ({
|
|
|
4160
4174
|
subtype: "success",
|
|
4161
4175
|
session_id: sessionId
|
|
4162
4176
|
});
|
|
4177
|
+
var resultSuccessFull = (sessionId, extra) => ({
|
|
4178
|
+
...resultSuccess(sessionId),
|
|
4179
|
+
...extra
|
|
4180
|
+
});
|
|
4163
4181
|
var resultError = (subtype) => ({
|
|
4164
4182
|
type: "result",
|
|
4165
4183
|
subtype,
|
|
@@ -4313,6 +4331,116 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
4313
4331
|
nativeStream: [init("s1"), resultSuccess("s1")],
|
|
4314
4332
|
expected: [sessionEvent("s1"), { type: "result", ok: true }]
|
|
4315
4333
|
},
|
|
4334
|
+
{
|
|
4335
|
+
// CT1220: on resume, a completed background task can emit a notification and
|
|
4336
|
+
// its own zero-token result before the prompt's frames. That result is not the
|
|
4337
|
+
// turn boundary; decoding continues through the prompt's reply and real usage.
|
|
4338
|
+
name: "task-notification result does not end a resumed turn",
|
|
4339
|
+
request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
|
|
4340
|
+
nativeStream: [
|
|
4341
|
+
init("s1"),
|
|
4342
|
+
{
|
|
4343
|
+
type: "system",
|
|
4344
|
+
subtype: "task_notification",
|
|
4345
|
+
task_id: "task-1",
|
|
4346
|
+
status: "completed",
|
|
4347
|
+
output_file: "/tmp/task-1.output",
|
|
4348
|
+
summary: "Background work completed.",
|
|
4349
|
+
session_id: "s1"
|
|
4350
|
+
},
|
|
4351
|
+
resultSuccessFull("s1", {
|
|
4352
|
+
is_error: false,
|
|
4353
|
+
origin: { kind: "task-notification" },
|
|
4354
|
+
usage: {
|
|
4355
|
+
input_tokens: 0,
|
|
4356
|
+
output_tokens: 0,
|
|
4357
|
+
cache_read_input_tokens: 0,
|
|
4358
|
+
cache_creation_input_tokens: 0
|
|
4359
|
+
}
|
|
4360
|
+
}),
|
|
4361
|
+
assistantTextTool("Checking the returned work.", {
|
|
4362
|
+
id: "tu1",
|
|
4363
|
+
name: "mcp__cabane__cabane_read",
|
|
4364
|
+
input: { path: "result.md" }
|
|
4365
|
+
}),
|
|
4366
|
+
toolResult("tu1", [{ type: "text", text: "ready" }]),
|
|
4367
|
+
assistantText("The prompt completed normally."),
|
|
4368
|
+
resultSuccessFull("s1", {
|
|
4369
|
+
is_error: false,
|
|
4370
|
+
usage: {
|
|
4371
|
+
input_tokens: 120,
|
|
4372
|
+
output_tokens: 30,
|
|
4373
|
+
cache_read_input_tokens: 80,
|
|
4374
|
+
cache_creation_input_tokens: 10
|
|
4375
|
+
}
|
|
4376
|
+
})
|
|
4377
|
+
],
|
|
4378
|
+
expected: [
|
|
4379
|
+
{ type: "text", body: "Checking the returned work.", terminal: false },
|
|
4380
|
+
{
|
|
4381
|
+
type: "tool",
|
|
4382
|
+
id: "tu1",
|
|
4383
|
+
name: "cabane_read",
|
|
4384
|
+
phase: "start",
|
|
4385
|
+
summary: "result.md",
|
|
4386
|
+
input: { path: "result.md" },
|
|
4387
|
+
mcpServer: "cabane"
|
|
4388
|
+
},
|
|
4389
|
+
{
|
|
4390
|
+
type: "tool",
|
|
4391
|
+
id: "tu1",
|
|
4392
|
+
name: "cabane_read",
|
|
4393
|
+
phase: "done",
|
|
4394
|
+
summary: "result.md",
|
|
4395
|
+
input: { path: "result.md" },
|
|
4396
|
+
result: [{ type: "text", text: "ready" }],
|
|
4397
|
+
mcpServer: "cabane"
|
|
4398
|
+
},
|
|
4399
|
+
{ type: "text", body: "The prompt completed normally.", terminal: true },
|
|
4400
|
+
{
|
|
4401
|
+
type: "result",
|
|
4402
|
+
ok: true,
|
|
4403
|
+
usage: {
|
|
4404
|
+
inputTokens: 210,
|
|
4405
|
+
outputTokens: 30,
|
|
4406
|
+
cacheReadTokens: 80,
|
|
4407
|
+
cacheCreationTokens: 10
|
|
4408
|
+
}
|
|
4409
|
+
}
|
|
4410
|
+
]
|
|
4411
|
+
},
|
|
4412
|
+
{
|
|
4413
|
+
// CT1220: the filter is deliberately narrow. An origin-less zero-token result
|
|
4414
|
+
// keeps today's break-on-first-result behavior, even if more frames follow.
|
|
4415
|
+
name: "origin-less result still ends a resumed turn",
|
|
4416
|
+
request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
|
|
4417
|
+
nativeStream: [
|
|
4418
|
+
init("s1"),
|
|
4419
|
+
resultSuccessFull("s1", {
|
|
4420
|
+
is_error: false,
|
|
4421
|
+
usage: {
|
|
4422
|
+
input_tokens: 0,
|
|
4423
|
+
output_tokens: 0,
|
|
4424
|
+
cache_read_input_tokens: 0,
|
|
4425
|
+
cache_creation_input_tokens: 0
|
|
4426
|
+
}
|
|
4427
|
+
}),
|
|
4428
|
+
assistantText("This must not be emitted."),
|
|
4429
|
+
resultSuccess("s1")
|
|
4430
|
+
],
|
|
4431
|
+
expected: [
|
|
4432
|
+
{
|
|
4433
|
+
type: "result",
|
|
4434
|
+
ok: true,
|
|
4435
|
+
usage: {
|
|
4436
|
+
inputTokens: 0,
|
|
4437
|
+
outputTokens: 0,
|
|
4438
|
+
cacheReadTokens: 0,
|
|
4439
|
+
cacheCreationTokens: 0
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
]
|
|
4443
|
+
},
|
|
4316
4444
|
{
|
|
4317
4445
|
// Empty-final: a clean turn that ended on a tool call with no closing text.
|
|
4318
4446
|
// The adapter emits NO final text — empty-final promotion is host/pump
|
|
@@ -4494,7 +4622,7 @@ function selectAdapter(registry, runtime) {
|
|
|
4494
4622
|
// packages/agent-runtime/src/opencode/addendum.ts
|
|
4495
4623
|
var OPENCODE_ADDENDUM = [
|
|
4496
4624
|
"Every tool here goes by its plain name: the workspace tool is `sdk`; the turn tools are",
|
|
4497
|
-
"`ask`, `wake_me`, `
|
|
4625
|
+
"`ask`, `reply_to`, `wake_me`, `send`, `sub_agent`, `skip_turn`; the ancillary tools are",
|
|
4498
4626
|
"`list_workspaces`, `read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
|
|
4499
4627
|
"`mint_render_token`; and the host tools are plain verbs too (`bash`, `read`, `edit`). Mind",
|
|
4500
4628
|
"the collision: a bare `read` or `edit` is the HOST tool, acting on the local machine \u2014 a",
|
|
@@ -5470,8 +5598,9 @@ var CODEX_ADDENDUM = [
|
|
|
5470
5598
|
"workspace tool is `mcp__cabane__sdk` \u2014 if it isn't in your visible tool list, locate it in",
|
|
5471
5599
|
"the deferred-tool inventory and invoke that exact qualified name; never conclude the SDK is",
|
|
5472
5600
|
"absent without attempting discovery and invocation, and if a call fails, report the recorded",
|
|
5473
|
-
"error. The turn tools are `mcp__cabane_companion__ask` / `
|
|
5474
|
-
"`sub_agent` / `skip_turn`, and the ancillary tools
|
|
5601
|
+
"error. The turn tools are `mcp__cabane_companion__ask` / `reply_to` / `wake_me` /",
|
|
5602
|
+
"`send` / `sub_agent` / `skip_turn`, and the ancillary tools",
|
|
5603
|
+
"`mcp__cabane__list_workspaces` /",
|
|
5475
5604
|
"`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
|
|
5476
5605
|
"possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
|
|
5477
5606
|
"`cabane.*` calls inside your program. One more harness fact: you can interleave narration",
|
|
@@ -6969,23 +7098,29 @@ import { createHash as createHash2, randomUUID } from "crypto";
|
|
|
6969
7098
|
import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
|
|
6970
7099
|
import { join as join14 } from "path";
|
|
6971
7100
|
|
|
6972
|
-
// src/
|
|
7101
|
+
// src/turn-control-tools.ts
|
|
6973
7102
|
import { z as z13 } from "zod";
|
|
6974
7103
|
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
6975
|
-
var
|
|
6976
|
-
var
|
|
7104
|
+
var SEND_TOOL = "send";
|
|
7105
|
+
var SEND_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SEND_TOOL}`;
|
|
6977
7106
|
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
6978
7107
|
var SKIP_TURN_TOOL = "skip_turn";
|
|
6979
7108
|
var ASK_TOOL = "ask";
|
|
6980
7109
|
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
7110
|
+
var REPLY_TO_TOOL = "reply_to";
|
|
7111
|
+
var REPLY_TO_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${REPLY_TO_TOOL}`;
|
|
7112
|
+
var MAX_ASK_ITEMS = 20;
|
|
6981
7113
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6982
7114
|
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
6983
7115
|
var WAKE_ME_TOOL = "wake_me";
|
|
6984
7116
|
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6985
7117
|
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
6986
7118
|
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
6987
|
-
function
|
|
6988
|
-
return {
|
|
7119
|
+
function createReplyState() {
|
|
7120
|
+
return { answersMessageId: null };
|
|
7121
|
+
}
|
|
7122
|
+
function createSendState() {
|
|
7123
|
+
return { agentId: null, message: null };
|
|
6989
7124
|
}
|
|
6990
7125
|
function createSkipState() {
|
|
6991
7126
|
return { skipped: false, reason: null };
|
|
@@ -6996,6 +7131,19 @@ function createAskState() {
|
|
|
6996
7131
|
function createWakeState() {
|
|
6997
7132
|
return { afterSeconds: null, at: null, note: null, cancelled: false };
|
|
6998
7133
|
}
|
|
7134
|
+
function resolveDeclaredReplyField(input) {
|
|
7135
|
+
const explicit = input.replyState.answersMessageId;
|
|
7136
|
+
if (explicit) return { answersMessageId: explicit };
|
|
7137
|
+
if (input.kind !== "final") return {};
|
|
7138
|
+
if (!input.owedReplyMessageId) return {};
|
|
7139
|
+
const outwardSend = Boolean(
|
|
7140
|
+
input.sendState.agentId && input.sendState.message && input.sendState.agentId !== input.agentId
|
|
7141
|
+
);
|
|
7142
|
+
const askRaised = Boolean(input.askState.targetUserId);
|
|
7143
|
+
const wakeArmed = input.wakeState.afterSeconds !== null || input.wakeState.at !== null;
|
|
7144
|
+
if (outwardSend || askRaised || wakeArmed || input.spawnedSubAgent) return {};
|
|
7145
|
+
return { answersMessageId: input.owedReplyMessageId };
|
|
7146
|
+
}
|
|
6999
7147
|
function wakeCommitField(state) {
|
|
7000
7148
|
if (state.cancelled) return { wake: { cancel: true } };
|
|
7001
7149
|
const { afterSeconds, at, note } = state;
|
|
@@ -7008,27 +7156,50 @@ function wakeCommitField(state) {
|
|
|
7008
7156
|
}
|
|
7009
7157
|
};
|
|
7010
7158
|
}
|
|
7011
|
-
function
|
|
7159
|
+
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
|
|
7012
7160
|
return createSdkMcpServer({
|
|
7013
7161
|
name: COMPANION_LOCAL_MCP_SERVER,
|
|
7014
7162
|
version: "0.0.0",
|
|
7015
7163
|
tools: [
|
|
7016
7164
|
tool(
|
|
7017
|
-
|
|
7018
|
-
"
|
|
7165
|
+
SEND_TOOL,
|
|
7166
|
+
"Send one addressed message to another agent in THIS conversation. Pass the peer's `agentId` and the complete `message` they should act on; the server writes it separately from your terminal reply. Writing `@handle` in prose dispatches nobody. Single target \u2014 the last call wins. Sending to yourself is a no-op. A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.",
|
|
7019
7167
|
{
|
|
7020
7168
|
agentId: z13.string().uuid().describe(
|
|
7021
|
-
"The peer agent to
|
|
7022
|
-
)
|
|
7169
|
+
"The peer agent to address \u2014 a workspace agent id, from your turn context's roster."
|
|
7170
|
+
),
|
|
7171
|
+
message: z13.string().min(1).max(65536).describe("The complete new request the peer should receive and act on.")
|
|
7023
7172
|
},
|
|
7024
7173
|
async (args) => {
|
|
7025
|
-
|
|
7174
|
+
sendState.agentId = args.agentId;
|
|
7175
|
+
sendState.message = args.message;
|
|
7026
7176
|
return {
|
|
7027
|
-
content: [{ type: "text", text: JSON.stringify({
|
|
7177
|
+
content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
|
|
7028
7178
|
};
|
|
7029
7179
|
},
|
|
7030
7180
|
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
7031
7181
|
),
|
|
7182
|
+
...replyState ? [
|
|
7183
|
+
tool(
|
|
7184
|
+
REPLY_TO_TOOL,
|
|
7185
|
+
"Declare which addressed ask your final response answers. Pass the `messageId` shown as the owed reply id in your turn context, then finish your response normally. This records lineage only: it does not send another message. The server verifies that this agent owes that ask in this conversation; one reply per turn, last call wins.",
|
|
7186
|
+
{
|
|
7187
|
+
messageId: z13.string().uuid().describe("The owed ask message id from this turn context.")
|
|
7188
|
+
},
|
|
7189
|
+
async (args) => {
|
|
7190
|
+
replyState.answersMessageId = args.messageId;
|
|
7191
|
+
return {
|
|
7192
|
+
content: [
|
|
7193
|
+
{
|
|
7194
|
+
type: "text",
|
|
7195
|
+
text: JSON.stringify({ answersMessageId: args.messageId })
|
|
7196
|
+
}
|
|
7197
|
+
]
|
|
7198
|
+
};
|
|
7199
|
+
},
|
|
7200
|
+
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
7201
|
+
)
|
|
7202
|
+
] : [],
|
|
7032
7203
|
...skipState ? [
|
|
7033
7204
|
tool(
|
|
7034
7205
|
SKIP_TURN_TOOL,
|
|
@@ -7049,7 +7220,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
7049
7220
|
...askState ? [
|
|
7050
7221
|
tool(
|
|
7051
7222
|
ASK_TOOL,
|
|
7052
|
-
|
|
7223
|
+
`Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label or a whole sentence. Provide EITHER \`question\` (single) or \`questions\` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
|
|
7053
7224
|
{
|
|
7054
7225
|
targetUserId: z13.string().uuid().describe(
|
|
7055
7226
|
"The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
|
|
@@ -7069,8 +7240,8 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
7069
7240
|
body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
|
|
7070
7241
|
options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
|
|
7071
7242
|
})
|
|
7072
|
-
).min(1).max(
|
|
7073
|
-
|
|
7243
|
+
).min(1).max(MAX_ASK_ITEMS).optional().describe(
|
|
7244
|
+
`MULTI-question form: 1\u2013${MAX_ASK_ITEMS} questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or \`question\`/\`headline\`/\`options\`, not both.`
|
|
7074
7245
|
)
|
|
7075
7246
|
},
|
|
7076
7247
|
async (args) => {
|
|
@@ -7122,10 +7293,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
7122
7293
|
...subAgentCreate ? [
|
|
7123
7294
|
tool(
|
|
7124
7295
|
SUB_AGENT_TOOL,
|
|
7125
|
-
"
|
|
7296
|
+
"Ask into a child conversation \u2014 a bounded question or piece of work whose ANSWER returns to you automatically. This creates the child and dispatches its agent now, returning the child's `conversationId` immediately; the answer lands LATER as a message in THIS conversation, and you're woken once every child you asked this turn has answered. So DON'T wait: after asking, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic and it carries the answer). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake carrying all the answers); series = one call per turn. `prompt` is the ask itself \u2014 write it as a request that expects an answer back, not a role you are assigning or a job description: self-contained (the child starts fresh, with only this prompt), and say what shape the answer should take. `agentId` (optional) asks a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is a fresh context window of you. `title` (optional) names the child thread (answers link it, so a legible title helps). A single child buys no wall-clock time (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't ask a child for a lookup you can do in-turn with your own tools. The answer returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead.",
|
|
7126
7297
|
{
|
|
7127
7298
|
prompt: z13.string().min(1).max(65536).describe(
|
|
7128
|
-
"The
|
|
7299
|
+
"The ask \u2014 a self-contained request that expects an answer back (the child starts fresh: only this prompt + the thread it lands in). Say what shape the answer should take."
|
|
7129
7300
|
),
|
|
7130
7301
|
agentId: z13.string().uuid().optional().describe(
|
|
7131
7302
|
"Optional peer to run the sub-agent as (a workspace agent id, from your turn context's roster); omit to spawn yourself with a fresh context window."
|
|
@@ -7146,7 +7317,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
7146
7317
|
type: "text",
|
|
7147
7318
|
text: JSON.stringify({
|
|
7148
7319
|
conversationId: result.conversationId,
|
|
7149
|
-
note: "
|
|
7320
|
+
note: "Asked. Don't wait for it \u2014 finish what else this turn can do, then end your turn; the answer posts back here and you're woken once every child you asked this turn has answered."
|
|
7150
7321
|
})
|
|
7151
7322
|
}
|
|
7152
7323
|
]
|
|
@@ -7285,10 +7456,9 @@ function buildCompanionTurnRequest(params) {
|
|
|
7285
7456
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
7286
7457
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
7287
7458
|
},
|
|
7288
|
-
// Host-injected
|
|
7289
|
-
// under its own namespace).
|
|
7459
|
+
// Host-injected turn-control tools, under their own namespace.
|
|
7290
7460
|
extra: {
|
|
7291
|
-
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.
|
|
7461
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
|
|
7292
7462
|
}
|
|
7293
7463
|
};
|
|
7294
7464
|
}
|
|
@@ -7563,7 +7733,7 @@ var TurnCommitter = class {
|
|
|
7563
7733
|
turnId: deps.turnId,
|
|
7564
7734
|
seq,
|
|
7565
7735
|
parentMessageId: deps.parentMessageId,
|
|
7566
|
-
...kind === "final" ? this.turnControlFields() : {}
|
|
7736
|
+
...kind === "final" ? this.turnControlFields("final") : {}
|
|
7567
7737
|
},
|
|
7568
7738
|
deps.signal
|
|
7569
7739
|
);
|
|
@@ -7658,27 +7828,45 @@ var TurnCommitter = class {
|
|
|
7658
7828
|
return this.pump.finalEmitted;
|
|
7659
7829
|
}
|
|
7660
7830
|
// A closing textual reply and a wordless terminal marker carry the same
|
|
7661
|
-
// per-turn control state. Keep this one projection so ask/wake/
|
|
7662
|
-
// silently diverge when the agent ends without words.
|
|
7663
|
-
|
|
7831
|
+
// per-turn control state. Keep this one projection so ask/wake/send cannot
|
|
7832
|
+
// silently diverge when the agent ends without words. The KIND matters to
|
|
7833
|
+
// exactly one field: the auto-declared reply binds only a textual `final` —
|
|
7834
|
+
// a wordless turn has no answer to bind, and the server's mute-settle
|
|
7835
|
+
// notice speaks for it.
|
|
7836
|
+
turnControlFields(kind) {
|
|
7664
7837
|
return {
|
|
7665
|
-
...this.
|
|
7838
|
+
...this.answersField(kind),
|
|
7839
|
+
...this.sendField(),
|
|
7666
7840
|
...this.askField(),
|
|
7667
7841
|
...this.wakeField()
|
|
7668
7842
|
};
|
|
7669
7843
|
}
|
|
7670
|
-
//
|
|
7671
|
-
//
|
|
7672
|
-
//
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7844
|
+
// The declared reply: explicit `reply_to` first, else the runtime's own
|
|
7845
|
+
// declaration for the turn that just answers — see
|
|
7846
|
+
// `resolveDeclaredReplyField` for the whole rule and its reasons.
|
|
7847
|
+
answersField(kind) {
|
|
7848
|
+
return resolveDeclaredReplyField({
|
|
7849
|
+
kind,
|
|
7850
|
+
owedReplyMessageId: this.deps.owedReplyMessageId,
|
|
7851
|
+
agentId: this.deps.agentId,
|
|
7852
|
+
replyState: this.deps.replyState,
|
|
7853
|
+
sendState: this.deps.sendState,
|
|
7854
|
+
askState: this.deps.askState,
|
|
7855
|
+
wakeState: this.deps.wakeState,
|
|
7856
|
+
spawnedSubAgent: this.deps.spawnedSubAgent()
|
|
7857
|
+
});
|
|
7858
|
+
}
|
|
7859
|
+
// Carry both halves of the addressed send. The server writes `dispatchBody`
|
|
7860
|
+
// on a distinct message row; the turn row itself remains unaddressed speech.
|
|
7861
|
+
sendField() {
|
|
7862
|
+
const { agentId: target, message } = this.deps.sendState;
|
|
7863
|
+
if (!target || !message || target === this.deps.agentId) return {};
|
|
7864
|
+
return { dispatch: target, dispatchBody: message };
|
|
7677
7865
|
}
|
|
7678
7866
|
// CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
|
|
7679
7867
|
// The server validates the target (must be a workspace member/owner) and
|
|
7680
7868
|
// creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
|
|
7681
|
-
// independent of `
|
|
7869
|
+
// independent of `sendField` — a turn can send and ask independently.
|
|
7682
7870
|
askField() {
|
|
7683
7871
|
const { targetUserId, question, headline, options, questions } = this.deps.askState;
|
|
7684
7872
|
if (!targetUserId) return {};
|
|
@@ -7708,7 +7896,7 @@ var TurnCommitter = class {
|
|
|
7708
7896
|
// CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
|
|
7709
7897
|
// The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
|
|
7710
7898
|
// null = no wake this turn → attach nothing). The server computes `fire_at` and
|
|
7711
|
-
// arms the CT441 schedule. Kept independent of
|
|
7899
|
+
// arms the CT441 schedule. Kept independent of send/ask — a turn could
|
|
7712
7900
|
// conceivably ask AND arm a wake.
|
|
7713
7901
|
//
|
|
7714
7902
|
// CT990: the field now carries three states, and the third can't be spelled by
|
|
@@ -7945,15 +8133,9 @@ function describeErrorBody(body) {
|
|
|
7945
8133
|
function describeSubAgentError(status2, body) {
|
|
7946
8134
|
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
7947
8135
|
switch (code) {
|
|
7948
|
-
case "callout_cap_exceeded":
|
|
7949
|
-
return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
|
|
7950
|
-
case "callout_depth_exceeded":
|
|
7951
|
-
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
7952
8136
|
case "dispatch_agent_not_found":
|
|
7953
8137
|
return "sub_agent: no live agent in this workspace matches that `agentId`. Check the roster in your turn context, or omit `agentId` to spawn yourself.";
|
|
7954
|
-
case "
|
|
7955
|
-
case "dispatch_return_requires_agent":
|
|
7956
|
-
case "dispatch_return_requires_dispatch":
|
|
8138
|
+
case "dispatch_ask_requires_dispatch":
|
|
7957
8139
|
return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
|
|
7958
8140
|
default:
|
|
7959
8141
|
return `sub_agent: the spawn failed (${code ?? `HTTP ${status2}`}).`;
|
|
@@ -8119,7 +8301,12 @@ var Dispatcher = class {
|
|
|
8119
8301
|
}
|
|
8120
8302
|
let hookEnv;
|
|
8121
8303
|
const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
|
|
8122
|
-
const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
|
|
8304
|
+
const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
|
|
8305
|
+
dispatch: turnContext.dispatchedByAgentId,
|
|
8306
|
+
dispatchBody: `${PREPARE_FAILED_PREFIX}
|
|
8307
|
+
|
|
8308
|
+
The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
|
|
8309
|
+
} : {};
|
|
8123
8310
|
if (prepareHook) {
|
|
8124
8311
|
let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
|
|
8125
8312
|
if (cached2 && !checkoutState(cached2.cwd).ok) {
|
|
@@ -8273,10 +8460,12 @@ ${reason}`,
|
|
|
8273
8460
|
"dispatcher: active-run flag set failed terminally; proceeding"
|
|
8274
8461
|
);
|
|
8275
8462
|
}
|
|
8276
|
-
const
|
|
8463
|
+
const sendState = createSendState();
|
|
8277
8464
|
const skipState = createSkipState();
|
|
8278
8465
|
const askState = createAskState();
|
|
8279
8466
|
const wakeState = createWakeState();
|
|
8467
|
+
const replyState = createReplyState();
|
|
8468
|
+
let spawnedSubAgent = false;
|
|
8280
8469
|
const subAgentCreate = async (args) => {
|
|
8281
8470
|
const dispatchTarget = args.agentId ?? payload.agentId;
|
|
8282
8471
|
const projectId = turnContext.conversation.projectId;
|
|
@@ -8289,7 +8478,7 @@ ${reason}`,
|
|
|
8289
8478
|
...args.title ? { title: args.title } : {},
|
|
8290
8479
|
body: args.prompt,
|
|
8291
8480
|
dispatch: dispatchTarget,
|
|
8292
|
-
|
|
8481
|
+
dispatchAsk: true
|
|
8293
8482
|
}
|
|
8294
8483
|
);
|
|
8295
8484
|
if (status2 >= 400) return { ok: false, error: describeSubAgentError(status2, body) };
|
|
@@ -8297,14 +8486,16 @@ ${reason}`,
|
|
|
8297
8486
|
if (!conversationId) {
|
|
8298
8487
|
return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
|
|
8299
8488
|
}
|
|
8489
|
+
spawnedSubAgent = true;
|
|
8300
8490
|
return { ok: true, conversationId };
|
|
8301
8491
|
};
|
|
8302
|
-
const
|
|
8303
|
-
|
|
8492
|
+
const turnControlServer = createTurnControlMcpServer(
|
|
8493
|
+
sendState,
|
|
8304
8494
|
skipState,
|
|
8305
8495
|
askState,
|
|
8306
8496
|
subAgentCreate,
|
|
8307
|
-
wakeState
|
|
8497
|
+
wakeState,
|
|
8498
|
+
replyState
|
|
8308
8499
|
);
|
|
8309
8500
|
const request = buildCompanionTurnRequest({
|
|
8310
8501
|
turnContext,
|
|
@@ -8318,7 +8509,7 @@ ${reason}`,
|
|
|
8318
8509
|
// CT1103: the prepare-hook env plus the authoritative host-access token.
|
|
8319
8510
|
env: turnEnv,
|
|
8320
8511
|
mcpServers: resolvedMcpServers,
|
|
8321
|
-
|
|
8512
|
+
turnControlServer,
|
|
8322
8513
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
8323
8514
|
// header so a cross-thread post/spawn stamps its origin.
|
|
8324
8515
|
activeConversationId: payload.conversationId,
|
|
@@ -8567,15 +8758,20 @@ ${reason}`,
|
|
|
8567
8758
|
signal: abortController.signal,
|
|
8568
8759
|
log: turnLog,
|
|
8569
8760
|
nextSeq,
|
|
8570
|
-
//
|
|
8571
|
-
//
|
|
8572
|
-
|
|
8761
|
+
// The committer reads this at commit to attach the addressed send onto the
|
|
8762
|
+
// turn's `final` row.
|
|
8763
|
+
sendState,
|
|
8573
8764
|
// CT326: likewise the ask payload — attached to the `final` row so the
|
|
8574
8765
|
// server creates the `asks` row atomically with the message it rides on.
|
|
8575
8766
|
askState,
|
|
8576
8767
|
// CT442: likewise the wake payload — attached to the `final` row so the
|
|
8577
8768
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
8578
|
-
wakeState
|
|
8769
|
+
wakeState,
|
|
8770
|
+
replyState,
|
|
8771
|
+
// The ledger-derived owed reply, for the runtime's own declaration when
|
|
8772
|
+
// the agent doesn't call `reply_to` (resolveDeclaredReplyField).
|
|
8773
|
+
owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
|
|
8774
|
+
spawnedSubAgent: () => spawnedSubAgent
|
|
8579
8775
|
});
|
|
8580
8776
|
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
8581
8777
|
let turnControlIntentFetched = false;
|
|
@@ -8616,7 +8812,11 @@ ${reason}`,
|
|
|
8616
8812
|
wakeState.note = intent.wake.note;
|
|
8617
8813
|
}
|
|
8618
8814
|
}
|
|
8619
|
-
if (intent.
|
|
8815
|
+
if (intent.sendAgentId && intent.sendBody) {
|
|
8816
|
+
sendState.agentId = intent.sendAgentId;
|
|
8817
|
+
sendState.message = intent.sendBody;
|
|
8818
|
+
}
|
|
8819
|
+
if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
|
|
8620
8820
|
if (intent.skipped) {
|
|
8621
8821
|
skipState.skipped = true;
|
|
8622
8822
|
skipState.reason = intent.skipReason;
|
|
@@ -8754,10 +8954,10 @@ ${reason}`,
|
|
|
8754
8954
|
turnId,
|
|
8755
8955
|
seq: nextSeq(),
|
|
8756
8956
|
parentMessageId: payload.messageId,
|
|
8757
|
-
// ask, wake and
|
|
8957
|
+
// ask, wake and send must survive a wordless turn exactly as
|
|
8758
8958
|
// they survive a textual final; dropping one can strand a person
|
|
8759
8959
|
// or the next actor with no visible failure.
|
|
8760
|
-
...committer.turnControlFields()
|
|
8960
|
+
...committer.turnControlFields("silent")
|
|
8761
8961
|
});
|
|
8762
8962
|
silentMarkerEmitted = true;
|
|
8763
8963
|
} catch (err) {
|
package/dist/runtime.js
CHANGED
|
@@ -1843,7 +1843,7 @@ var CabaneApi = class {
|
|
|
1843
1843
|
return this.request("GET", `/api/workspaces/${workspaceId}/conversations/${conversationId}`);
|
|
1844
1844
|
}
|
|
1845
1845
|
// CT339: spawn a sub-agent conversation — the public create-conversation
|
|
1846
|
-
// endpoint (`POST /conversations/messages`) with
|
|
1846
|
+
// endpoint (`POST /conversations/messages`) with an addressed agent ask,
|
|
1847
1847
|
// the machinery the `sub_agent` turn-control tool is sugar over. Two things make
|
|
1848
1848
|
// it distinct from an ordinary `request` call, so it does its own `fetch`:
|
|
1849
1849
|
// - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
|
|
@@ -1851,10 +1851,10 @@ var CabaneApi = class {
|
|
|
1851
1851
|
// cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
|
|
1852
1852
|
// - the `x-cabane-active-conversation` header naming the caller's turn, which
|
|
1853
1853
|
// the server verifies against the live run to resolve the caller pair for the
|
|
1854
|
-
//
|
|
1854
|
+
// immutable birth tether (the same origin that stamps provenance).
|
|
1855
1855
|
// Single-shot (no outbox/retry): a `sub_agent` spawn is a live, user-visible
|
|
1856
1856
|
// action, and its result is returned to the agent immediately. Returns
|
|
1857
|
-
// `{ status, body }` un-thrown so the caller maps
|
|
1857
|
+
// `{ status, body }` un-thrown so the caller maps validation errors
|
|
1858
1858
|
// to a legible tool result rather than an exception.
|
|
1859
1859
|
async createSubAgentConversation(workspaceId, activeConversationId, bearer, body) {
|
|
1860
1860
|
const res = await fetch(`${this.base}/api/workspaces/${workspaceId}/conversations/messages`, {
|
|
@@ -1914,7 +1914,7 @@ var CabaneApi = class {
|
|
|
1914
1914
|
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
|
|
1915
1915
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
1916
1916
|
}
|
|
1917
|
-
// CT714: read a turn's recorded turn-control intent (ask/wake/
|
|
1917
|
+
// CT714: read a turn's recorded turn-control intent (ask/wake/send/skip). An
|
|
1918
1918
|
// EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
|
|
1919
1919
|
// `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
|
|
1920
1920
|
// in-memory closures, so the dispatcher fetches this once at settle — by
|
|
@@ -2825,7 +2825,7 @@ var turnRequestSchema = z8.object({
|
|
|
2825
2825
|
// EXTERNAL adapters (Codex / opencode) mount it by URL under the key
|
|
2826
2826
|
// `cabane_companion` — using the same `bearer` (the turn token) and the same
|
|
2827
2827
|
// active-conversation header they send to the `cabane` server — so their
|
|
2828
|
-
// agents get `ask`/`wake_me`/`
|
|
2828
|
+
// agents get `ask`/`wake_me`/`send`/`sub_agent`/`skip_turn`, the
|
|
2829
2829
|
// verbs they can't get from the companion's in-process SDK server. Optional:
|
|
2830
2830
|
// claude-code ignores it (it mounts the in-process instance instead), and
|
|
2831
2831
|
// every existing `cabane`-block fixture keeps parsing unchanged; the
|
|
@@ -2860,7 +2860,7 @@ var turnRequestSchema = z8.object({
|
|
|
2860
2860
|
// default in place (see `buildClaudeCodeOptions`).
|
|
2861
2861
|
claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
|
|
2862
2862
|
}),
|
|
2863
|
-
// Host-owned injected servers (host-filled) — e.g. the
|
|
2863
|
+
// Host-owned injected servers (host-filled) — e.g. the send server.
|
|
2864
2864
|
extra: z8.object({
|
|
2865
2865
|
mcpServers: hostInjectedServersSchema
|
|
2866
2866
|
})
|
|
@@ -3191,8 +3191,9 @@ var CLAUDE_CODE_ADDENDUM = [
|
|
|
3191
3191
|
"`mcp__cabane__sdk`; the ancillary tools are `mcp__cabane__list_workspaces`,",
|
|
3192
3192
|
"`mcp__cabane__read_binary`, `mcp__cabane__upload`, `mcp__cabane__begin_upload`,",
|
|
3193
3193
|
"`mcp__cabane__finalize_upload`, and `mcp__cabane__mint_render_token`; the turn tools are",
|
|
3194
|
-
"`mcp__cabane_companion__ask`, `
|
|
3195
|
-
"`
|
|
3194
|
+
"`mcp__cabane_companion__ask`, `mcp__cabane_companion__reply_to`,",
|
|
3195
|
+
"`mcp__cabane_companion__wake_me`,",
|
|
3196
|
+
"`mcp__cabane_companion__send`, `mcp__cabane_companion__sub_agent`, and",
|
|
3196
3197
|
"`mcp__cabane_companion__skip_turn`. Where this prompt names a tool by its bare verb, call",
|
|
3197
3198
|
"the prefixed form from your tool list. Your harness's own tools (shell, local file",
|
|
3198
3199
|
"read/edit) keep their ordinary names and act on the local machine only."
|
|
@@ -3305,8 +3306,19 @@ function decideResume(stored, currentCwd) {
|
|
|
3305
3306
|
// packages/agent-runtime/src/claude-code/options.ts
|
|
3306
3307
|
var CABANE_MCP_SERVER = "cabane";
|
|
3307
3308
|
var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
|
|
3308
|
-
async function
|
|
3309
|
+
async function preToolUseHook(input) {
|
|
3309
3310
|
const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
3311
|
+
const toolName = "tool_name" in input ? input.tool_name : void 0;
|
|
3312
|
+
if (typeof toolName === "string" && SUBAGENT_TOOLS.includes(toolName) && (toolInput.run_in_background !== false || toolInput.isolation === "remote")) {
|
|
3313
|
+
return {
|
|
3314
|
+
continue: true,
|
|
3315
|
+
hookSpecificOutput: {
|
|
3316
|
+
hookEventName: "PreToolUse",
|
|
3317
|
+
permissionDecision: "deny",
|
|
3318
|
+
permissionDecisionReason: "Background subagents don't survive a Cabane turn: they're children of this turn's process and are killed when it ends, so nothing will notify you. Re-run this with run_in_background: false to get the result inside this turn, or arm a wake and check back."
|
|
3319
|
+
}
|
|
3320
|
+
};
|
|
3321
|
+
}
|
|
3310
3322
|
return {
|
|
3311
3323
|
continue: true,
|
|
3312
3324
|
hookSpecificOutput: {
|
|
@@ -3391,7 +3403,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
3391
3403
|
settingSources: ["project"],
|
|
3392
3404
|
allowedTools,
|
|
3393
3405
|
disallowedTools,
|
|
3394
|
-
hooks: { PreToolUse: [{ hooks: [
|
|
3406
|
+
hooks: { PreToolUse: [{ hooks: [preToolUseHook] }] }
|
|
3395
3407
|
};
|
|
3396
3408
|
} else {
|
|
3397
3409
|
options = {
|
|
@@ -3467,6 +3479,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3467
3479
|
const err = msg.error;
|
|
3468
3480
|
if (typeof err === "string" && err.length > 0) authError = err;
|
|
3469
3481
|
} else if (msg.type === "result") {
|
|
3482
|
+
const origin = msg.origin;
|
|
3483
|
+
if (origin?.kind === "task-notification") continue;
|
|
3470
3484
|
sawResult = true;
|
|
3471
3485
|
usage = readSdkUsage(msg);
|
|
3472
3486
|
if (usage) {
|
|
@@ -3659,6 +3673,10 @@ var resultSuccess = (sessionId) => ({
|
|
|
3659
3673
|
subtype: "success",
|
|
3660
3674
|
session_id: sessionId
|
|
3661
3675
|
});
|
|
3676
|
+
var resultSuccessFull = (sessionId, extra) => ({
|
|
3677
|
+
...resultSuccess(sessionId),
|
|
3678
|
+
...extra
|
|
3679
|
+
});
|
|
3662
3680
|
var resultError = (subtype) => ({
|
|
3663
3681
|
type: "result",
|
|
3664
3682
|
subtype,
|
|
@@ -3812,6 +3830,116 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
3812
3830
|
nativeStream: [init("s1"), resultSuccess("s1")],
|
|
3813
3831
|
expected: [sessionEvent("s1"), { type: "result", ok: true }]
|
|
3814
3832
|
},
|
|
3833
|
+
{
|
|
3834
|
+
// CT1220: on resume, a completed background task can emit a notification and
|
|
3835
|
+
// its own zero-token result before the prompt's frames. That result is not the
|
|
3836
|
+
// turn boundary; decoding continues through the prompt's reply and real usage.
|
|
3837
|
+
name: "task-notification result does not end a resumed turn",
|
|
3838
|
+
request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
|
|
3839
|
+
nativeStream: [
|
|
3840
|
+
init("s1"),
|
|
3841
|
+
{
|
|
3842
|
+
type: "system",
|
|
3843
|
+
subtype: "task_notification",
|
|
3844
|
+
task_id: "task-1",
|
|
3845
|
+
status: "completed",
|
|
3846
|
+
output_file: "/tmp/task-1.output",
|
|
3847
|
+
summary: "Background work completed.",
|
|
3848
|
+
session_id: "s1"
|
|
3849
|
+
},
|
|
3850
|
+
resultSuccessFull("s1", {
|
|
3851
|
+
is_error: false,
|
|
3852
|
+
origin: { kind: "task-notification" },
|
|
3853
|
+
usage: {
|
|
3854
|
+
input_tokens: 0,
|
|
3855
|
+
output_tokens: 0,
|
|
3856
|
+
cache_read_input_tokens: 0,
|
|
3857
|
+
cache_creation_input_tokens: 0
|
|
3858
|
+
}
|
|
3859
|
+
}),
|
|
3860
|
+
assistantTextTool("Checking the returned work.", {
|
|
3861
|
+
id: "tu1",
|
|
3862
|
+
name: "mcp__cabane__cabane_read",
|
|
3863
|
+
input: { path: "result.md" }
|
|
3864
|
+
}),
|
|
3865
|
+
toolResult("tu1", [{ type: "text", text: "ready" }]),
|
|
3866
|
+
assistantText("The prompt completed normally."),
|
|
3867
|
+
resultSuccessFull("s1", {
|
|
3868
|
+
is_error: false,
|
|
3869
|
+
usage: {
|
|
3870
|
+
input_tokens: 120,
|
|
3871
|
+
output_tokens: 30,
|
|
3872
|
+
cache_read_input_tokens: 80,
|
|
3873
|
+
cache_creation_input_tokens: 10
|
|
3874
|
+
}
|
|
3875
|
+
})
|
|
3876
|
+
],
|
|
3877
|
+
expected: [
|
|
3878
|
+
{ type: "text", body: "Checking the returned work.", terminal: false },
|
|
3879
|
+
{
|
|
3880
|
+
type: "tool",
|
|
3881
|
+
id: "tu1",
|
|
3882
|
+
name: "cabane_read",
|
|
3883
|
+
phase: "start",
|
|
3884
|
+
summary: "result.md",
|
|
3885
|
+
input: { path: "result.md" },
|
|
3886
|
+
mcpServer: "cabane"
|
|
3887
|
+
},
|
|
3888
|
+
{
|
|
3889
|
+
type: "tool",
|
|
3890
|
+
id: "tu1",
|
|
3891
|
+
name: "cabane_read",
|
|
3892
|
+
phase: "done",
|
|
3893
|
+
summary: "result.md",
|
|
3894
|
+
input: { path: "result.md" },
|
|
3895
|
+
result: [{ type: "text", text: "ready" }],
|
|
3896
|
+
mcpServer: "cabane"
|
|
3897
|
+
},
|
|
3898
|
+
{ type: "text", body: "The prompt completed normally.", terminal: true },
|
|
3899
|
+
{
|
|
3900
|
+
type: "result",
|
|
3901
|
+
ok: true,
|
|
3902
|
+
usage: {
|
|
3903
|
+
inputTokens: 210,
|
|
3904
|
+
outputTokens: 30,
|
|
3905
|
+
cacheReadTokens: 80,
|
|
3906
|
+
cacheCreationTokens: 10
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
]
|
|
3910
|
+
},
|
|
3911
|
+
{
|
|
3912
|
+
// CT1220: the filter is deliberately narrow. An origin-less zero-token result
|
|
3913
|
+
// keeps today's break-on-first-result behavior, even if more frames follow.
|
|
3914
|
+
name: "origin-less result still ends a resumed turn",
|
|
3915
|
+
request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
|
|
3916
|
+
nativeStream: [
|
|
3917
|
+
init("s1"),
|
|
3918
|
+
resultSuccessFull("s1", {
|
|
3919
|
+
is_error: false,
|
|
3920
|
+
usage: {
|
|
3921
|
+
input_tokens: 0,
|
|
3922
|
+
output_tokens: 0,
|
|
3923
|
+
cache_read_input_tokens: 0,
|
|
3924
|
+
cache_creation_input_tokens: 0
|
|
3925
|
+
}
|
|
3926
|
+
}),
|
|
3927
|
+
assistantText("This must not be emitted."),
|
|
3928
|
+
resultSuccess("s1")
|
|
3929
|
+
],
|
|
3930
|
+
expected: [
|
|
3931
|
+
{
|
|
3932
|
+
type: "result",
|
|
3933
|
+
ok: true,
|
|
3934
|
+
usage: {
|
|
3935
|
+
inputTokens: 0,
|
|
3936
|
+
outputTokens: 0,
|
|
3937
|
+
cacheReadTokens: 0,
|
|
3938
|
+
cacheCreationTokens: 0
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
]
|
|
3942
|
+
},
|
|
3815
3943
|
{
|
|
3816
3944
|
// Empty-final: a clean turn that ended on a tool call with no closing text.
|
|
3817
3945
|
// The adapter emits NO final text — empty-final promotion is host/pump
|
|
@@ -3993,7 +4121,7 @@ function selectAdapter(registry, runtime) {
|
|
|
3993
4121
|
// packages/agent-runtime/src/opencode/addendum.ts
|
|
3994
4122
|
var OPENCODE_ADDENDUM = [
|
|
3995
4123
|
"Every tool here goes by its plain name: the workspace tool is `sdk`; the turn tools are",
|
|
3996
|
-
"`ask`, `wake_me`, `
|
|
4124
|
+
"`ask`, `reply_to`, `wake_me`, `send`, `sub_agent`, `skip_turn`; the ancillary tools are",
|
|
3997
4125
|
"`list_workspaces`, `read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
|
|
3998
4126
|
"`mint_render_token`; and the host tools are plain verbs too (`bash`, `read`, `edit`). Mind",
|
|
3999
4127
|
"the collision: a bare `read` or `edit` is the HOST tool, acting on the local machine \u2014 a",
|
|
@@ -4969,8 +5097,9 @@ var CODEX_ADDENDUM = [
|
|
|
4969
5097
|
"workspace tool is `mcp__cabane__sdk` \u2014 if it isn't in your visible tool list, locate it in",
|
|
4970
5098
|
"the deferred-tool inventory and invoke that exact qualified name; never conclude the SDK is",
|
|
4971
5099
|
"absent without attempting discovery and invocation, and if a call fails, report the recorded",
|
|
4972
|
-
"error. The turn tools are `mcp__cabane_companion__ask` / `
|
|
4973
|
-
"`sub_agent` / `skip_turn`, and the ancillary tools
|
|
5100
|
+
"error. The turn tools are `mcp__cabane_companion__ask` / `reply_to` / `wake_me` /",
|
|
5101
|
+
"`send` / `sub_agent` / `skip_turn`, and the ancillary tools",
|
|
5102
|
+
"`mcp__cabane__list_workspaces` /",
|
|
4974
5103
|
"`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
|
|
4975
5104
|
"possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
|
|
4976
5105
|
"`cabane.*` calls inside your program. One more harness fact: you can interleave narration",
|
|
@@ -6468,23 +6597,29 @@ import { createHash as createHash2, randomUUID } from "crypto";
|
|
|
6468
6597
|
import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
|
|
6469
6598
|
import { join as join14 } from "path";
|
|
6470
6599
|
|
|
6471
|
-
// src/
|
|
6600
|
+
// src/turn-control-tools.ts
|
|
6472
6601
|
import { z as z13 } from "zod";
|
|
6473
6602
|
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
6474
|
-
var
|
|
6475
|
-
var
|
|
6603
|
+
var SEND_TOOL = "send";
|
|
6604
|
+
var SEND_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SEND_TOOL}`;
|
|
6476
6605
|
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
6477
6606
|
var SKIP_TURN_TOOL = "skip_turn";
|
|
6478
6607
|
var ASK_TOOL = "ask";
|
|
6479
6608
|
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
6609
|
+
var REPLY_TO_TOOL = "reply_to";
|
|
6610
|
+
var REPLY_TO_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${REPLY_TO_TOOL}`;
|
|
6611
|
+
var MAX_ASK_ITEMS = 20;
|
|
6480
6612
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6481
6613
|
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
6482
6614
|
var WAKE_ME_TOOL = "wake_me";
|
|
6483
6615
|
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6484
6616
|
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
6485
6617
|
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
6486
|
-
function
|
|
6487
|
-
return {
|
|
6618
|
+
function createReplyState() {
|
|
6619
|
+
return { answersMessageId: null };
|
|
6620
|
+
}
|
|
6621
|
+
function createSendState() {
|
|
6622
|
+
return { agentId: null, message: null };
|
|
6488
6623
|
}
|
|
6489
6624
|
function createSkipState() {
|
|
6490
6625
|
return { skipped: false, reason: null };
|
|
@@ -6495,6 +6630,19 @@ function createAskState() {
|
|
|
6495
6630
|
function createWakeState() {
|
|
6496
6631
|
return { afterSeconds: null, at: null, note: null, cancelled: false };
|
|
6497
6632
|
}
|
|
6633
|
+
function resolveDeclaredReplyField(input) {
|
|
6634
|
+
const explicit = input.replyState.answersMessageId;
|
|
6635
|
+
if (explicit) return { answersMessageId: explicit };
|
|
6636
|
+
if (input.kind !== "final") return {};
|
|
6637
|
+
if (!input.owedReplyMessageId) return {};
|
|
6638
|
+
const outwardSend = Boolean(
|
|
6639
|
+
input.sendState.agentId && input.sendState.message && input.sendState.agentId !== input.agentId
|
|
6640
|
+
);
|
|
6641
|
+
const askRaised = Boolean(input.askState.targetUserId);
|
|
6642
|
+
const wakeArmed = input.wakeState.afterSeconds !== null || input.wakeState.at !== null;
|
|
6643
|
+
if (outwardSend || askRaised || wakeArmed || input.spawnedSubAgent) return {};
|
|
6644
|
+
return { answersMessageId: input.owedReplyMessageId };
|
|
6645
|
+
}
|
|
6498
6646
|
function wakeCommitField(state) {
|
|
6499
6647
|
if (state.cancelled) return { wake: { cancel: true } };
|
|
6500
6648
|
const { afterSeconds, at, note } = state;
|
|
@@ -6507,27 +6655,50 @@ function wakeCommitField(state) {
|
|
|
6507
6655
|
}
|
|
6508
6656
|
};
|
|
6509
6657
|
}
|
|
6510
|
-
function
|
|
6658
|
+
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
|
|
6511
6659
|
return createSdkMcpServer({
|
|
6512
6660
|
name: COMPANION_LOCAL_MCP_SERVER,
|
|
6513
6661
|
version: "0.0.0",
|
|
6514
6662
|
tools: [
|
|
6515
6663
|
tool(
|
|
6516
|
-
|
|
6517
|
-
"
|
|
6664
|
+
SEND_TOOL,
|
|
6665
|
+
"Send one addressed message to another agent in THIS conversation. Pass the peer's `agentId` and the complete `message` they should act on; the server writes it separately from your terminal reply. Writing `@handle` in prose dispatches nobody. Single target \u2014 the last call wins. Sending to yourself is a no-op. A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.",
|
|
6518
6666
|
{
|
|
6519
6667
|
agentId: z13.string().uuid().describe(
|
|
6520
|
-
"The peer agent to
|
|
6521
|
-
)
|
|
6668
|
+
"The peer agent to address \u2014 a workspace agent id, from your turn context's roster."
|
|
6669
|
+
),
|
|
6670
|
+
message: z13.string().min(1).max(65536).describe("The complete new request the peer should receive and act on.")
|
|
6522
6671
|
},
|
|
6523
6672
|
async (args) => {
|
|
6524
|
-
|
|
6673
|
+
sendState.agentId = args.agentId;
|
|
6674
|
+
sendState.message = args.message;
|
|
6525
6675
|
return {
|
|
6526
|
-
content: [{ type: "text", text: JSON.stringify({
|
|
6676
|
+
content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
|
|
6527
6677
|
};
|
|
6528
6678
|
},
|
|
6529
6679
|
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
6530
6680
|
),
|
|
6681
|
+
...replyState ? [
|
|
6682
|
+
tool(
|
|
6683
|
+
REPLY_TO_TOOL,
|
|
6684
|
+
"Declare which addressed ask your final response answers. Pass the `messageId` shown as the owed reply id in your turn context, then finish your response normally. This records lineage only: it does not send another message. The server verifies that this agent owes that ask in this conversation; one reply per turn, last call wins.",
|
|
6685
|
+
{
|
|
6686
|
+
messageId: z13.string().uuid().describe("The owed ask message id from this turn context.")
|
|
6687
|
+
},
|
|
6688
|
+
async (args) => {
|
|
6689
|
+
replyState.answersMessageId = args.messageId;
|
|
6690
|
+
return {
|
|
6691
|
+
content: [
|
|
6692
|
+
{
|
|
6693
|
+
type: "text",
|
|
6694
|
+
text: JSON.stringify({ answersMessageId: args.messageId })
|
|
6695
|
+
}
|
|
6696
|
+
]
|
|
6697
|
+
};
|
|
6698
|
+
},
|
|
6699
|
+
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
6700
|
+
)
|
|
6701
|
+
] : [],
|
|
6531
6702
|
...skipState ? [
|
|
6532
6703
|
tool(
|
|
6533
6704
|
SKIP_TURN_TOOL,
|
|
@@ -6548,7 +6719,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6548
6719
|
...askState ? [
|
|
6549
6720
|
tool(
|
|
6550
6721
|
ASK_TOOL,
|
|
6551
|
-
|
|
6722
|
+
`Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label or a whole sentence. Provide EITHER \`question\` (single) or \`questions\` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
|
|
6552
6723
|
{
|
|
6553
6724
|
targetUserId: z13.string().uuid().describe(
|
|
6554
6725
|
"The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
|
|
@@ -6568,8 +6739,8 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6568
6739
|
body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
|
|
6569
6740
|
options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
|
|
6570
6741
|
})
|
|
6571
|
-
).min(1).max(
|
|
6572
|
-
|
|
6742
|
+
).min(1).max(MAX_ASK_ITEMS).optional().describe(
|
|
6743
|
+
`MULTI-question form: 1\u2013${MAX_ASK_ITEMS} questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or \`question\`/\`headline\`/\`options\`, not both.`
|
|
6573
6744
|
)
|
|
6574
6745
|
},
|
|
6575
6746
|
async (args) => {
|
|
@@ -6621,10 +6792,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6621
6792
|
...subAgentCreate ? [
|
|
6622
6793
|
tool(
|
|
6623
6794
|
SUB_AGENT_TOOL,
|
|
6624
|
-
"
|
|
6795
|
+
"Ask into a child conversation \u2014 a bounded question or piece of work whose ANSWER returns to you automatically. This creates the child and dispatches its agent now, returning the child's `conversationId` immediately; the answer lands LATER as a message in THIS conversation, and you're woken once every child you asked this turn has answered. So DON'T wait: after asking, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic and it carries the answer). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake carrying all the answers); series = one call per turn. `prompt` is the ask itself \u2014 write it as a request that expects an answer back, not a role you are assigning or a job description: self-contained (the child starts fresh, with only this prompt), and say what shape the answer should take. `agentId` (optional) asks a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is a fresh context window of you. `title` (optional) names the child thread (answers link it, so a legible title helps). A single child buys no wall-clock time (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't ask a child for a lookup you can do in-turn with your own tools. The answer returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead.",
|
|
6625
6796
|
{
|
|
6626
6797
|
prompt: z13.string().min(1).max(65536).describe(
|
|
6627
|
-
"The
|
|
6798
|
+
"The ask \u2014 a self-contained request that expects an answer back (the child starts fresh: only this prompt + the thread it lands in). Say what shape the answer should take."
|
|
6628
6799
|
),
|
|
6629
6800
|
agentId: z13.string().uuid().optional().describe(
|
|
6630
6801
|
"Optional peer to run the sub-agent as (a workspace agent id, from your turn context's roster); omit to spawn yourself with a fresh context window."
|
|
@@ -6645,7 +6816,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6645
6816
|
type: "text",
|
|
6646
6817
|
text: JSON.stringify({
|
|
6647
6818
|
conversationId: result.conversationId,
|
|
6648
|
-
note: "
|
|
6819
|
+
note: "Asked. Don't wait for it \u2014 finish what else this turn can do, then end your turn; the answer posts back here and you're woken once every child you asked this turn has answered."
|
|
6649
6820
|
})
|
|
6650
6821
|
}
|
|
6651
6822
|
]
|
|
@@ -6784,10 +6955,9 @@ function buildCompanionTurnRequest(params) {
|
|
|
6784
6955
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
6785
6956
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
6786
6957
|
},
|
|
6787
|
-
// Host-injected
|
|
6788
|
-
// under its own namespace).
|
|
6958
|
+
// Host-injected turn-control tools, under their own namespace.
|
|
6789
6959
|
extra: {
|
|
6790
|
-
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.
|
|
6960
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
|
|
6791
6961
|
}
|
|
6792
6962
|
};
|
|
6793
6963
|
}
|
|
@@ -7062,7 +7232,7 @@ var TurnCommitter = class {
|
|
|
7062
7232
|
turnId: deps.turnId,
|
|
7063
7233
|
seq,
|
|
7064
7234
|
parentMessageId: deps.parentMessageId,
|
|
7065
|
-
...kind === "final" ? this.turnControlFields() : {}
|
|
7235
|
+
...kind === "final" ? this.turnControlFields("final") : {}
|
|
7066
7236
|
},
|
|
7067
7237
|
deps.signal
|
|
7068
7238
|
);
|
|
@@ -7157,27 +7327,45 @@ var TurnCommitter = class {
|
|
|
7157
7327
|
return this.pump.finalEmitted;
|
|
7158
7328
|
}
|
|
7159
7329
|
// A closing textual reply and a wordless terminal marker carry the same
|
|
7160
|
-
// per-turn control state. Keep this one projection so ask/wake/
|
|
7161
|
-
// silently diverge when the agent ends without words.
|
|
7162
|
-
|
|
7330
|
+
// per-turn control state. Keep this one projection so ask/wake/send cannot
|
|
7331
|
+
// silently diverge when the agent ends without words. The KIND matters to
|
|
7332
|
+
// exactly one field: the auto-declared reply binds only a textual `final` —
|
|
7333
|
+
// a wordless turn has no answer to bind, and the server's mute-settle
|
|
7334
|
+
// notice speaks for it.
|
|
7335
|
+
turnControlFields(kind) {
|
|
7163
7336
|
return {
|
|
7164
|
-
...this.
|
|
7337
|
+
...this.answersField(kind),
|
|
7338
|
+
...this.sendField(),
|
|
7165
7339
|
...this.askField(),
|
|
7166
7340
|
...this.wakeField()
|
|
7167
7341
|
};
|
|
7168
7342
|
}
|
|
7169
|
-
//
|
|
7170
|
-
//
|
|
7171
|
-
//
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7343
|
+
// The declared reply: explicit `reply_to` first, else the runtime's own
|
|
7344
|
+
// declaration for the turn that just answers — see
|
|
7345
|
+
// `resolveDeclaredReplyField` for the whole rule and its reasons.
|
|
7346
|
+
answersField(kind) {
|
|
7347
|
+
return resolveDeclaredReplyField({
|
|
7348
|
+
kind,
|
|
7349
|
+
owedReplyMessageId: this.deps.owedReplyMessageId,
|
|
7350
|
+
agentId: this.deps.agentId,
|
|
7351
|
+
replyState: this.deps.replyState,
|
|
7352
|
+
sendState: this.deps.sendState,
|
|
7353
|
+
askState: this.deps.askState,
|
|
7354
|
+
wakeState: this.deps.wakeState,
|
|
7355
|
+
spawnedSubAgent: this.deps.spawnedSubAgent()
|
|
7356
|
+
});
|
|
7357
|
+
}
|
|
7358
|
+
// Carry both halves of the addressed send. The server writes `dispatchBody`
|
|
7359
|
+
// on a distinct message row; the turn row itself remains unaddressed speech.
|
|
7360
|
+
sendField() {
|
|
7361
|
+
const { agentId: target, message } = this.deps.sendState;
|
|
7362
|
+
if (!target || !message || target === this.deps.agentId) return {};
|
|
7363
|
+
return { dispatch: target, dispatchBody: message };
|
|
7176
7364
|
}
|
|
7177
7365
|
// CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
|
|
7178
7366
|
// The server validates the target (must be a workspace member/owner) and
|
|
7179
7367
|
// creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
|
|
7180
|
-
// independent of `
|
|
7368
|
+
// independent of `sendField` — a turn can send and ask independently.
|
|
7181
7369
|
askField() {
|
|
7182
7370
|
const { targetUserId, question, headline, options, questions } = this.deps.askState;
|
|
7183
7371
|
if (!targetUserId) return {};
|
|
@@ -7207,7 +7395,7 @@ var TurnCommitter = class {
|
|
|
7207
7395
|
// CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
|
|
7208
7396
|
// The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
|
|
7209
7397
|
// null = no wake this turn → attach nothing). The server computes `fire_at` and
|
|
7210
|
-
// arms the CT441 schedule. Kept independent of
|
|
7398
|
+
// arms the CT441 schedule. Kept independent of send/ask — a turn could
|
|
7211
7399
|
// conceivably ask AND arm a wake.
|
|
7212
7400
|
//
|
|
7213
7401
|
// CT990: the field now carries three states, and the third can't be spelled by
|
|
@@ -7444,15 +7632,9 @@ function describeErrorBody(body) {
|
|
|
7444
7632
|
function describeSubAgentError(status, body) {
|
|
7445
7633
|
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
7446
7634
|
switch (code) {
|
|
7447
|
-
case "callout_cap_exceeded":
|
|
7448
|
-
return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
|
|
7449
|
-
case "callout_depth_exceeded":
|
|
7450
|
-
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
7451
7635
|
case "dispatch_agent_not_found":
|
|
7452
7636
|
return "sub_agent: no live agent in this workspace matches that `agentId`. Check the roster in your turn context, or omit `agentId` to spawn yourself.";
|
|
7453
|
-
case "
|
|
7454
|
-
case "dispatch_return_requires_agent":
|
|
7455
|
-
case "dispatch_return_requires_dispatch":
|
|
7637
|
+
case "dispatch_ask_requires_dispatch":
|
|
7456
7638
|
return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
|
|
7457
7639
|
default:
|
|
7458
7640
|
return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
|
|
@@ -7618,7 +7800,12 @@ var Dispatcher = class {
|
|
|
7618
7800
|
}
|
|
7619
7801
|
let hookEnv;
|
|
7620
7802
|
const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
|
|
7621
|
-
const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
|
|
7803
|
+
const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
|
|
7804
|
+
dispatch: turnContext.dispatchedByAgentId,
|
|
7805
|
+
dispatchBody: `${PREPARE_FAILED_PREFIX}
|
|
7806
|
+
|
|
7807
|
+
The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
|
|
7808
|
+
} : {};
|
|
7622
7809
|
if (prepareHook) {
|
|
7623
7810
|
let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
|
|
7624
7811
|
if (cached2 && !checkoutState(cached2.cwd).ok) {
|
|
@@ -7772,10 +7959,12 @@ ${reason}`,
|
|
|
7772
7959
|
"dispatcher: active-run flag set failed terminally; proceeding"
|
|
7773
7960
|
);
|
|
7774
7961
|
}
|
|
7775
|
-
const
|
|
7962
|
+
const sendState = createSendState();
|
|
7776
7963
|
const skipState = createSkipState();
|
|
7777
7964
|
const askState = createAskState();
|
|
7778
7965
|
const wakeState = createWakeState();
|
|
7966
|
+
const replyState = createReplyState();
|
|
7967
|
+
let spawnedSubAgent = false;
|
|
7779
7968
|
const subAgentCreate = async (args) => {
|
|
7780
7969
|
const dispatchTarget = args.agentId ?? payload.agentId;
|
|
7781
7970
|
const projectId = turnContext.conversation.projectId;
|
|
@@ -7788,7 +7977,7 @@ ${reason}`,
|
|
|
7788
7977
|
...args.title ? { title: args.title } : {},
|
|
7789
7978
|
body: args.prompt,
|
|
7790
7979
|
dispatch: dispatchTarget,
|
|
7791
|
-
|
|
7980
|
+
dispatchAsk: true
|
|
7792
7981
|
}
|
|
7793
7982
|
);
|
|
7794
7983
|
if (status >= 400) return { ok: false, error: describeSubAgentError(status, body) };
|
|
@@ -7796,14 +7985,16 @@ ${reason}`,
|
|
|
7796
7985
|
if (!conversationId) {
|
|
7797
7986
|
return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
|
|
7798
7987
|
}
|
|
7988
|
+
spawnedSubAgent = true;
|
|
7799
7989
|
return { ok: true, conversationId };
|
|
7800
7990
|
};
|
|
7801
|
-
const
|
|
7802
|
-
|
|
7991
|
+
const turnControlServer = createTurnControlMcpServer(
|
|
7992
|
+
sendState,
|
|
7803
7993
|
skipState,
|
|
7804
7994
|
askState,
|
|
7805
7995
|
subAgentCreate,
|
|
7806
|
-
wakeState
|
|
7996
|
+
wakeState,
|
|
7997
|
+
replyState
|
|
7807
7998
|
);
|
|
7808
7999
|
const request = buildCompanionTurnRequest({
|
|
7809
8000
|
turnContext,
|
|
@@ -7817,7 +8008,7 @@ ${reason}`,
|
|
|
7817
8008
|
// CT1103: the prepare-hook env plus the authoritative host-access token.
|
|
7818
8009
|
env: turnEnv,
|
|
7819
8010
|
mcpServers: resolvedMcpServers,
|
|
7820
|
-
|
|
8011
|
+
turnControlServer,
|
|
7821
8012
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
7822
8013
|
// header so a cross-thread post/spawn stamps its origin.
|
|
7823
8014
|
activeConversationId: payload.conversationId,
|
|
@@ -8066,15 +8257,20 @@ ${reason}`,
|
|
|
8066
8257
|
signal: abortController.signal,
|
|
8067
8258
|
log: turnLog,
|
|
8068
8259
|
nextSeq,
|
|
8069
|
-
//
|
|
8070
|
-
//
|
|
8071
|
-
|
|
8260
|
+
// The committer reads this at commit to attach the addressed send onto the
|
|
8261
|
+
// turn's `final` row.
|
|
8262
|
+
sendState,
|
|
8072
8263
|
// CT326: likewise the ask payload — attached to the `final` row so the
|
|
8073
8264
|
// server creates the `asks` row atomically with the message it rides on.
|
|
8074
8265
|
askState,
|
|
8075
8266
|
// CT442: likewise the wake payload — attached to the `final` row so the
|
|
8076
8267
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
8077
|
-
wakeState
|
|
8268
|
+
wakeState,
|
|
8269
|
+
replyState,
|
|
8270
|
+
// The ledger-derived owed reply, for the runtime's own declaration when
|
|
8271
|
+
// the agent doesn't call `reply_to` (resolveDeclaredReplyField).
|
|
8272
|
+
owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
|
|
8273
|
+
spawnedSubAgent: () => spawnedSubAgent
|
|
8078
8274
|
});
|
|
8079
8275
|
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
8080
8276
|
let turnControlIntentFetched = false;
|
|
@@ -8115,7 +8311,11 @@ ${reason}`,
|
|
|
8115
8311
|
wakeState.note = intent.wake.note;
|
|
8116
8312
|
}
|
|
8117
8313
|
}
|
|
8118
|
-
if (intent.
|
|
8314
|
+
if (intent.sendAgentId && intent.sendBody) {
|
|
8315
|
+
sendState.agentId = intent.sendAgentId;
|
|
8316
|
+
sendState.message = intent.sendBody;
|
|
8317
|
+
}
|
|
8318
|
+
if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
|
|
8119
8319
|
if (intent.skipped) {
|
|
8120
8320
|
skipState.skipped = true;
|
|
8121
8321
|
skipState.reason = intent.skipReason;
|
|
@@ -8253,10 +8453,10 @@ ${reason}`,
|
|
|
8253
8453
|
turnId,
|
|
8254
8454
|
seq: nextSeq(),
|
|
8255
8455
|
parentMessageId: payload.messageId,
|
|
8256
|
-
// ask, wake and
|
|
8456
|
+
// ask, wake and send must survive a wordless turn exactly as
|
|
8257
8457
|
// they survive a textual final; dropping one can strand a person
|
|
8258
8458
|
// or the next actor with no visible failure.
|
|
8259
|
-
...committer.turnControlFields()
|
|
8459
|
+
...committer.turnControlFields("silent")
|
|
8260
8460
|
});
|
|
8261
8461
|
silentMarkerEmitted = true;
|
|
8262
8462
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
|
|
6
6
|
"license": "UNLICENSED",
|