@cabane/companion 0.6.54 → 0.6.60
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 +266 -67
- package/dist/runtime.js +266 -67
- 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,15 +7098,17 @@ 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}`;
|
|
6981
7112
|
var MAX_ASK_ITEMS = 20;
|
|
6982
7113
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6983
7114
|
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
@@ -6985,8 +7116,11 @@ var WAKE_ME_TOOL = "wake_me";
|
|
|
6985
7116
|
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6986
7117
|
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
6987
7118
|
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
6988
|
-
function
|
|
6989
|
-
return {
|
|
7119
|
+
function createReplyState() {
|
|
7120
|
+
return { answersMessageId: null };
|
|
7121
|
+
}
|
|
7122
|
+
function createSendState() {
|
|
7123
|
+
return { agentId: null, message: null };
|
|
6990
7124
|
}
|
|
6991
7125
|
function createSkipState() {
|
|
6992
7126
|
return { skipped: false, reason: null };
|
|
@@ -6997,6 +7131,19 @@ function createAskState() {
|
|
|
6997
7131
|
function createWakeState() {
|
|
6998
7132
|
return { afterSeconds: null, at: null, note: null, cancelled: false };
|
|
6999
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
|
+
}
|
|
7000
7147
|
function wakeCommitField(state) {
|
|
7001
7148
|
if (state.cancelled) return { wake: { cancel: true } };
|
|
7002
7149
|
const { afterSeconds, at, note } = state;
|
|
@@ -7009,27 +7156,50 @@ function wakeCommitField(state) {
|
|
|
7009
7156
|
}
|
|
7010
7157
|
};
|
|
7011
7158
|
}
|
|
7012
|
-
function
|
|
7159
|
+
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
|
|
7013
7160
|
return createSdkMcpServer({
|
|
7014
7161
|
name: COMPANION_LOCAL_MCP_SERVER,
|
|
7015
7162
|
version: "0.0.0",
|
|
7016
7163
|
tools: [
|
|
7017
7164
|
tool(
|
|
7018
|
-
|
|
7019
|
-
"
|
|
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.",
|
|
7020
7167
|
{
|
|
7021
7168
|
agentId: z13.string().uuid().describe(
|
|
7022
|
-
"The peer agent to
|
|
7023
|
-
)
|
|
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.")
|
|
7024
7172
|
},
|
|
7025
7173
|
async (args) => {
|
|
7026
|
-
|
|
7174
|
+
sendState.agentId = args.agentId;
|
|
7175
|
+
sendState.message = args.message;
|
|
7027
7176
|
return {
|
|
7028
|
-
content: [{ type: "text", text: JSON.stringify({
|
|
7177
|
+
content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
|
|
7029
7178
|
};
|
|
7030
7179
|
},
|
|
7031
7180
|
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
7032
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
|
+
] : [],
|
|
7033
7203
|
...skipState ? [
|
|
7034
7204
|
tool(
|
|
7035
7205
|
SKIP_TURN_TOOL,
|
|
@@ -7050,7 +7220,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
7050
7220
|
...askState ? [
|
|
7051
7221
|
tool(
|
|
7052
7222
|
ASK_TOOL,
|
|
7053
|
-
`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).
|
|
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). The human sends ONE response: supplied answers are recorded, omissions become terminally Unanswered; later answers require a fresh ask. Ask only when blocked \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.`,
|
|
7054
7224
|
{
|
|
7055
7225
|
targetUserId: z13.string().uuid().describe(
|
|
7056
7226
|
"The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
|
|
@@ -7123,10 +7293,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
7123
7293
|
...subAgentCreate ? [
|
|
7124
7294
|
tool(
|
|
7125
7295
|
SUB_AGENT_TOOL,
|
|
7126
|
-
"
|
|
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.",
|
|
7127
7297
|
{
|
|
7128
7298
|
prompt: z13.string().min(1).max(65536).describe(
|
|
7129
|
-
"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."
|
|
7130
7300
|
),
|
|
7131
7301
|
agentId: z13.string().uuid().optional().describe(
|
|
7132
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."
|
|
@@ -7147,7 +7317,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
7147
7317
|
type: "text",
|
|
7148
7318
|
text: JSON.stringify({
|
|
7149
7319
|
conversationId: result.conversationId,
|
|
7150
|
-
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."
|
|
7151
7321
|
})
|
|
7152
7322
|
}
|
|
7153
7323
|
]
|
|
@@ -7286,10 +7456,9 @@ function buildCompanionTurnRequest(params) {
|
|
|
7286
7456
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
7287
7457
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
7288
7458
|
},
|
|
7289
|
-
// Host-injected
|
|
7290
|
-
// under its own namespace).
|
|
7459
|
+
// Host-injected turn-control tools, under their own namespace.
|
|
7291
7460
|
extra: {
|
|
7292
|
-
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.
|
|
7461
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
|
|
7293
7462
|
}
|
|
7294
7463
|
};
|
|
7295
7464
|
}
|
|
@@ -7564,7 +7733,7 @@ var TurnCommitter = class {
|
|
|
7564
7733
|
turnId: deps.turnId,
|
|
7565
7734
|
seq,
|
|
7566
7735
|
parentMessageId: deps.parentMessageId,
|
|
7567
|
-
...kind === "final" ? this.turnControlFields() : {}
|
|
7736
|
+
...kind === "final" ? this.turnControlFields("final") : {}
|
|
7568
7737
|
},
|
|
7569
7738
|
deps.signal
|
|
7570
7739
|
);
|
|
@@ -7659,27 +7828,45 @@ var TurnCommitter = class {
|
|
|
7659
7828
|
return this.pump.finalEmitted;
|
|
7660
7829
|
}
|
|
7661
7830
|
// A closing textual reply and a wordless terminal marker carry the same
|
|
7662
|
-
// per-turn control state. Keep this one projection so ask/wake/
|
|
7663
|
-
// silently diverge when the agent ends without words.
|
|
7664
|
-
|
|
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) {
|
|
7665
7837
|
return {
|
|
7666
|
-
...this.
|
|
7838
|
+
...this.answersField(kind),
|
|
7839
|
+
...this.sendField(),
|
|
7667
7840
|
...this.askField(),
|
|
7668
7841
|
...this.wakeField()
|
|
7669
7842
|
};
|
|
7670
7843
|
}
|
|
7671
|
-
//
|
|
7672
|
-
//
|
|
7673
|
-
//
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
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 };
|
|
7678
7865
|
}
|
|
7679
7866
|
// CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
|
|
7680
7867
|
// The server validates the target (must be a workspace member/owner) and
|
|
7681
7868
|
// creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
|
|
7682
|
-
// independent of `
|
|
7869
|
+
// independent of `sendField` — a turn can send and ask independently.
|
|
7683
7870
|
askField() {
|
|
7684
7871
|
const { targetUserId, question, headline, options, questions } = this.deps.askState;
|
|
7685
7872
|
if (!targetUserId) return {};
|
|
@@ -7709,7 +7896,7 @@ var TurnCommitter = class {
|
|
|
7709
7896
|
// CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
|
|
7710
7897
|
// The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
|
|
7711
7898
|
// null = no wake this turn → attach nothing). The server computes `fire_at` and
|
|
7712
|
-
// arms the CT441 schedule. Kept independent of
|
|
7899
|
+
// arms the CT441 schedule. Kept independent of send/ask — a turn could
|
|
7713
7900
|
// conceivably ask AND arm a wake.
|
|
7714
7901
|
//
|
|
7715
7902
|
// CT990: the field now carries three states, and the third can't be spelled by
|
|
@@ -7946,15 +8133,9 @@ function describeErrorBody(body) {
|
|
|
7946
8133
|
function describeSubAgentError(status2, body) {
|
|
7947
8134
|
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
7948
8135
|
switch (code) {
|
|
7949
|
-
case "callout_cap_exceeded":
|
|
7950
|
-
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.";
|
|
7951
|
-
case "callout_depth_exceeded":
|
|
7952
|
-
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
7953
8136
|
case "dispatch_agent_not_found":
|
|
7954
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.";
|
|
7955
|
-
case "
|
|
7956
|
-
case "dispatch_return_requires_agent":
|
|
7957
|
-
case "dispatch_return_requires_dispatch":
|
|
8138
|
+
case "dispatch_ask_requires_dispatch":
|
|
7958
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.`;
|
|
7959
8140
|
default:
|
|
7960
8141
|
return `sub_agent: the spawn failed (${code ?? `HTTP ${status2}`}).`;
|
|
@@ -8120,7 +8301,12 @@ var Dispatcher = class {
|
|
|
8120
8301
|
}
|
|
8121
8302
|
let hookEnv;
|
|
8122
8303
|
const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
|
|
8123
|
-
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
|
+
} : {};
|
|
8124
8310
|
if (prepareHook) {
|
|
8125
8311
|
let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
|
|
8126
8312
|
if (cached2 && !checkoutState(cached2.cwd).ok) {
|
|
@@ -8274,10 +8460,12 @@ ${reason}`,
|
|
|
8274
8460
|
"dispatcher: active-run flag set failed terminally; proceeding"
|
|
8275
8461
|
);
|
|
8276
8462
|
}
|
|
8277
|
-
const
|
|
8463
|
+
const sendState = createSendState();
|
|
8278
8464
|
const skipState = createSkipState();
|
|
8279
8465
|
const askState = createAskState();
|
|
8280
8466
|
const wakeState = createWakeState();
|
|
8467
|
+
const replyState = createReplyState();
|
|
8468
|
+
let spawnedSubAgent = false;
|
|
8281
8469
|
const subAgentCreate = async (args) => {
|
|
8282
8470
|
const dispatchTarget = args.agentId ?? payload.agentId;
|
|
8283
8471
|
const projectId = turnContext.conversation.projectId;
|
|
@@ -8290,7 +8478,7 @@ ${reason}`,
|
|
|
8290
8478
|
...args.title ? { title: args.title } : {},
|
|
8291
8479
|
body: args.prompt,
|
|
8292
8480
|
dispatch: dispatchTarget,
|
|
8293
|
-
|
|
8481
|
+
dispatchAsk: true
|
|
8294
8482
|
}
|
|
8295
8483
|
);
|
|
8296
8484
|
if (status2 >= 400) return { ok: false, error: describeSubAgentError(status2, body) };
|
|
@@ -8298,14 +8486,16 @@ ${reason}`,
|
|
|
8298
8486
|
if (!conversationId) {
|
|
8299
8487
|
return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
|
|
8300
8488
|
}
|
|
8489
|
+
spawnedSubAgent = true;
|
|
8301
8490
|
return { ok: true, conversationId };
|
|
8302
8491
|
};
|
|
8303
|
-
const
|
|
8304
|
-
|
|
8492
|
+
const turnControlServer = createTurnControlMcpServer(
|
|
8493
|
+
sendState,
|
|
8305
8494
|
skipState,
|
|
8306
8495
|
askState,
|
|
8307
8496
|
subAgentCreate,
|
|
8308
|
-
wakeState
|
|
8497
|
+
wakeState,
|
|
8498
|
+
replyState
|
|
8309
8499
|
);
|
|
8310
8500
|
const request = buildCompanionTurnRequest({
|
|
8311
8501
|
turnContext,
|
|
@@ -8319,7 +8509,7 @@ ${reason}`,
|
|
|
8319
8509
|
// CT1103: the prepare-hook env plus the authoritative host-access token.
|
|
8320
8510
|
env: turnEnv,
|
|
8321
8511
|
mcpServers: resolvedMcpServers,
|
|
8322
|
-
|
|
8512
|
+
turnControlServer,
|
|
8323
8513
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
8324
8514
|
// header so a cross-thread post/spawn stamps its origin.
|
|
8325
8515
|
activeConversationId: payload.conversationId,
|
|
@@ -8568,15 +8758,20 @@ ${reason}`,
|
|
|
8568
8758
|
signal: abortController.signal,
|
|
8569
8759
|
log: turnLog,
|
|
8570
8760
|
nextSeq,
|
|
8571
|
-
//
|
|
8572
|
-
//
|
|
8573
|
-
|
|
8761
|
+
// The committer reads this at commit to attach the addressed send onto the
|
|
8762
|
+
// turn's `final` row.
|
|
8763
|
+
sendState,
|
|
8574
8764
|
// CT326: likewise the ask payload — attached to the `final` row so the
|
|
8575
8765
|
// server creates the `asks` row atomically with the message it rides on.
|
|
8576
8766
|
askState,
|
|
8577
8767
|
// CT442: likewise the wake payload — attached to the `final` row so the
|
|
8578
8768
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
8579
|
-
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
|
|
8580
8775
|
});
|
|
8581
8776
|
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
8582
8777
|
let turnControlIntentFetched = false;
|
|
@@ -8617,7 +8812,11 @@ ${reason}`,
|
|
|
8617
8812
|
wakeState.note = intent.wake.note;
|
|
8618
8813
|
}
|
|
8619
8814
|
}
|
|
8620
|
-
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;
|
|
8621
8820
|
if (intent.skipped) {
|
|
8622
8821
|
skipState.skipped = true;
|
|
8623
8822
|
skipState.reason = intent.skipReason;
|
|
@@ -8755,10 +8954,10 @@ ${reason}`,
|
|
|
8755
8954
|
turnId,
|
|
8756
8955
|
seq: nextSeq(),
|
|
8757
8956
|
parentMessageId: payload.messageId,
|
|
8758
|
-
// ask, wake and
|
|
8957
|
+
// ask, wake and send must survive a wordless turn exactly as
|
|
8759
8958
|
// they survive a textual final; dropping one can strand a person
|
|
8760
8959
|
// or the next actor with no visible failure.
|
|
8761
|
-
...committer.turnControlFields()
|
|
8960
|
+
...committer.turnControlFields("silent")
|
|
8762
8961
|
});
|
|
8763
8962
|
silentMarkerEmitted = true;
|
|
8764
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,15 +6597,17 @@ 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}`;
|
|
6480
6611
|
var MAX_ASK_ITEMS = 20;
|
|
6481
6612
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6482
6613
|
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
@@ -6484,8 +6615,11 @@ var WAKE_ME_TOOL = "wake_me";
|
|
|
6484
6615
|
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6485
6616
|
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
6486
6617
|
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
6487
|
-
function
|
|
6488
|
-
return {
|
|
6618
|
+
function createReplyState() {
|
|
6619
|
+
return { answersMessageId: null };
|
|
6620
|
+
}
|
|
6621
|
+
function createSendState() {
|
|
6622
|
+
return { agentId: null, message: null };
|
|
6489
6623
|
}
|
|
6490
6624
|
function createSkipState() {
|
|
6491
6625
|
return { skipped: false, reason: null };
|
|
@@ -6496,6 +6630,19 @@ function createAskState() {
|
|
|
6496
6630
|
function createWakeState() {
|
|
6497
6631
|
return { afterSeconds: null, at: null, note: null, cancelled: false };
|
|
6498
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
|
+
}
|
|
6499
6646
|
function wakeCommitField(state) {
|
|
6500
6647
|
if (state.cancelled) return { wake: { cancel: true } };
|
|
6501
6648
|
const { afterSeconds, at, note } = state;
|
|
@@ -6508,27 +6655,50 @@ function wakeCommitField(state) {
|
|
|
6508
6655
|
}
|
|
6509
6656
|
};
|
|
6510
6657
|
}
|
|
6511
|
-
function
|
|
6658
|
+
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
|
|
6512
6659
|
return createSdkMcpServer({
|
|
6513
6660
|
name: COMPANION_LOCAL_MCP_SERVER,
|
|
6514
6661
|
version: "0.0.0",
|
|
6515
6662
|
tools: [
|
|
6516
6663
|
tool(
|
|
6517
|
-
|
|
6518
|
-
"
|
|
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.",
|
|
6519
6666
|
{
|
|
6520
6667
|
agentId: z13.string().uuid().describe(
|
|
6521
|
-
"The peer agent to
|
|
6522
|
-
)
|
|
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.")
|
|
6523
6671
|
},
|
|
6524
6672
|
async (args) => {
|
|
6525
|
-
|
|
6673
|
+
sendState.agentId = args.agentId;
|
|
6674
|
+
sendState.message = args.message;
|
|
6526
6675
|
return {
|
|
6527
|
-
content: [{ type: "text", text: JSON.stringify({
|
|
6676
|
+
content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
|
|
6528
6677
|
};
|
|
6529
6678
|
},
|
|
6530
6679
|
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
6531
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
|
+
] : [],
|
|
6532
6702
|
...skipState ? [
|
|
6533
6703
|
tool(
|
|
6534
6704
|
SKIP_TURN_TOOL,
|
|
@@ -6549,7 +6719,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6549
6719
|
...askState ? [
|
|
6550
6720
|
tool(
|
|
6551
6721
|
ASK_TOOL,
|
|
6552
|
-
`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).
|
|
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). The human sends ONE response: supplied answers are recorded, omissions become terminally Unanswered; later answers require a fresh ask. Ask only when blocked \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.`,
|
|
6553
6723
|
{
|
|
6554
6724
|
targetUserId: z13.string().uuid().describe(
|
|
6555
6725
|
"The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
|
|
@@ -6622,10 +6792,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6622
6792
|
...subAgentCreate ? [
|
|
6623
6793
|
tool(
|
|
6624
6794
|
SUB_AGENT_TOOL,
|
|
6625
|
-
"
|
|
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.",
|
|
6626
6796
|
{
|
|
6627
6797
|
prompt: z13.string().min(1).max(65536).describe(
|
|
6628
|
-
"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."
|
|
6629
6799
|
),
|
|
6630
6800
|
agentId: z13.string().uuid().optional().describe(
|
|
6631
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."
|
|
@@ -6646,7 +6816,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6646
6816
|
type: "text",
|
|
6647
6817
|
text: JSON.stringify({
|
|
6648
6818
|
conversationId: result.conversationId,
|
|
6649
|
-
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."
|
|
6650
6820
|
})
|
|
6651
6821
|
}
|
|
6652
6822
|
]
|
|
@@ -6785,10 +6955,9 @@ function buildCompanionTurnRequest(params) {
|
|
|
6785
6955
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
6786
6956
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
6787
6957
|
},
|
|
6788
|
-
// Host-injected
|
|
6789
|
-
// under its own namespace).
|
|
6958
|
+
// Host-injected turn-control tools, under their own namespace.
|
|
6790
6959
|
extra: {
|
|
6791
|
-
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.
|
|
6960
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
|
|
6792
6961
|
}
|
|
6793
6962
|
};
|
|
6794
6963
|
}
|
|
@@ -7063,7 +7232,7 @@ var TurnCommitter = class {
|
|
|
7063
7232
|
turnId: deps.turnId,
|
|
7064
7233
|
seq,
|
|
7065
7234
|
parentMessageId: deps.parentMessageId,
|
|
7066
|
-
...kind === "final" ? this.turnControlFields() : {}
|
|
7235
|
+
...kind === "final" ? this.turnControlFields("final") : {}
|
|
7067
7236
|
},
|
|
7068
7237
|
deps.signal
|
|
7069
7238
|
);
|
|
@@ -7158,27 +7327,45 @@ var TurnCommitter = class {
|
|
|
7158
7327
|
return this.pump.finalEmitted;
|
|
7159
7328
|
}
|
|
7160
7329
|
// A closing textual reply and a wordless terminal marker carry the same
|
|
7161
|
-
// per-turn control state. Keep this one projection so ask/wake/
|
|
7162
|
-
// silently diverge when the agent ends without words.
|
|
7163
|
-
|
|
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) {
|
|
7164
7336
|
return {
|
|
7165
|
-
...this.
|
|
7337
|
+
...this.answersField(kind),
|
|
7338
|
+
...this.sendField(),
|
|
7166
7339
|
...this.askField(),
|
|
7167
7340
|
...this.wakeField()
|
|
7168
7341
|
};
|
|
7169
7342
|
}
|
|
7170
|
-
//
|
|
7171
|
-
//
|
|
7172
|
-
//
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
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 };
|
|
7177
7364
|
}
|
|
7178
7365
|
// CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
|
|
7179
7366
|
// The server validates the target (must be a workspace member/owner) and
|
|
7180
7367
|
// creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
|
|
7181
|
-
// independent of `
|
|
7368
|
+
// independent of `sendField` — a turn can send and ask independently.
|
|
7182
7369
|
askField() {
|
|
7183
7370
|
const { targetUserId, question, headline, options, questions } = this.deps.askState;
|
|
7184
7371
|
if (!targetUserId) return {};
|
|
@@ -7208,7 +7395,7 @@ var TurnCommitter = class {
|
|
|
7208
7395
|
// CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
|
|
7209
7396
|
// The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
|
|
7210
7397
|
// null = no wake this turn → attach nothing). The server computes `fire_at` and
|
|
7211
|
-
// arms the CT441 schedule. Kept independent of
|
|
7398
|
+
// arms the CT441 schedule. Kept independent of send/ask — a turn could
|
|
7212
7399
|
// conceivably ask AND arm a wake.
|
|
7213
7400
|
//
|
|
7214
7401
|
// CT990: the field now carries three states, and the third can't be spelled by
|
|
@@ -7445,15 +7632,9 @@ function describeErrorBody(body) {
|
|
|
7445
7632
|
function describeSubAgentError(status, body) {
|
|
7446
7633
|
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
7447
7634
|
switch (code) {
|
|
7448
|
-
case "callout_cap_exceeded":
|
|
7449
|
-
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.";
|
|
7450
|
-
case "callout_depth_exceeded":
|
|
7451
|
-
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
7452
7635
|
case "dispatch_agent_not_found":
|
|
7453
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.";
|
|
7454
|
-
case "
|
|
7455
|
-
case "dispatch_return_requires_agent":
|
|
7456
|
-
case "dispatch_return_requires_dispatch":
|
|
7637
|
+
case "dispatch_ask_requires_dispatch":
|
|
7457
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.`;
|
|
7458
7639
|
default:
|
|
7459
7640
|
return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
|
|
@@ -7619,7 +7800,12 @@ var Dispatcher = class {
|
|
|
7619
7800
|
}
|
|
7620
7801
|
let hookEnv;
|
|
7621
7802
|
const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
|
|
7622
|
-
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
|
+
} : {};
|
|
7623
7809
|
if (prepareHook) {
|
|
7624
7810
|
let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
|
|
7625
7811
|
if (cached2 && !checkoutState(cached2.cwd).ok) {
|
|
@@ -7773,10 +7959,12 @@ ${reason}`,
|
|
|
7773
7959
|
"dispatcher: active-run flag set failed terminally; proceeding"
|
|
7774
7960
|
);
|
|
7775
7961
|
}
|
|
7776
|
-
const
|
|
7962
|
+
const sendState = createSendState();
|
|
7777
7963
|
const skipState = createSkipState();
|
|
7778
7964
|
const askState = createAskState();
|
|
7779
7965
|
const wakeState = createWakeState();
|
|
7966
|
+
const replyState = createReplyState();
|
|
7967
|
+
let spawnedSubAgent = false;
|
|
7780
7968
|
const subAgentCreate = async (args) => {
|
|
7781
7969
|
const dispatchTarget = args.agentId ?? payload.agentId;
|
|
7782
7970
|
const projectId = turnContext.conversation.projectId;
|
|
@@ -7789,7 +7977,7 @@ ${reason}`,
|
|
|
7789
7977
|
...args.title ? { title: args.title } : {},
|
|
7790
7978
|
body: args.prompt,
|
|
7791
7979
|
dispatch: dispatchTarget,
|
|
7792
|
-
|
|
7980
|
+
dispatchAsk: true
|
|
7793
7981
|
}
|
|
7794
7982
|
);
|
|
7795
7983
|
if (status >= 400) return { ok: false, error: describeSubAgentError(status, body) };
|
|
@@ -7797,14 +7985,16 @@ ${reason}`,
|
|
|
7797
7985
|
if (!conversationId) {
|
|
7798
7986
|
return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
|
|
7799
7987
|
}
|
|
7988
|
+
spawnedSubAgent = true;
|
|
7800
7989
|
return { ok: true, conversationId };
|
|
7801
7990
|
};
|
|
7802
|
-
const
|
|
7803
|
-
|
|
7991
|
+
const turnControlServer = createTurnControlMcpServer(
|
|
7992
|
+
sendState,
|
|
7804
7993
|
skipState,
|
|
7805
7994
|
askState,
|
|
7806
7995
|
subAgentCreate,
|
|
7807
|
-
wakeState
|
|
7996
|
+
wakeState,
|
|
7997
|
+
replyState
|
|
7808
7998
|
);
|
|
7809
7999
|
const request = buildCompanionTurnRequest({
|
|
7810
8000
|
turnContext,
|
|
@@ -7818,7 +8008,7 @@ ${reason}`,
|
|
|
7818
8008
|
// CT1103: the prepare-hook env plus the authoritative host-access token.
|
|
7819
8009
|
env: turnEnv,
|
|
7820
8010
|
mcpServers: resolvedMcpServers,
|
|
7821
|
-
|
|
8011
|
+
turnControlServer,
|
|
7822
8012
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
7823
8013
|
// header so a cross-thread post/spawn stamps its origin.
|
|
7824
8014
|
activeConversationId: payload.conversationId,
|
|
@@ -8067,15 +8257,20 @@ ${reason}`,
|
|
|
8067
8257
|
signal: abortController.signal,
|
|
8068
8258
|
log: turnLog,
|
|
8069
8259
|
nextSeq,
|
|
8070
|
-
//
|
|
8071
|
-
//
|
|
8072
|
-
|
|
8260
|
+
// The committer reads this at commit to attach the addressed send onto the
|
|
8261
|
+
// turn's `final` row.
|
|
8262
|
+
sendState,
|
|
8073
8263
|
// CT326: likewise the ask payload — attached to the `final` row so the
|
|
8074
8264
|
// server creates the `asks` row atomically with the message it rides on.
|
|
8075
8265
|
askState,
|
|
8076
8266
|
// CT442: likewise the wake payload — attached to the `final` row so the
|
|
8077
8267
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
8078
|
-
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
|
|
8079
8274
|
});
|
|
8080
8275
|
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
8081
8276
|
let turnControlIntentFetched = false;
|
|
@@ -8116,7 +8311,11 @@ ${reason}`,
|
|
|
8116
8311
|
wakeState.note = intent.wake.note;
|
|
8117
8312
|
}
|
|
8118
8313
|
}
|
|
8119
|
-
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;
|
|
8120
8319
|
if (intent.skipped) {
|
|
8121
8320
|
skipState.skipped = true;
|
|
8122
8321
|
skipState.reason = intent.skipReason;
|
|
@@ -8254,10 +8453,10 @@ ${reason}`,
|
|
|
8254
8453
|
turnId,
|
|
8255
8454
|
seq: nextSeq(),
|
|
8256
8455
|
parentMessageId: payload.messageId,
|
|
8257
|
-
// ask, wake and
|
|
8456
|
+
// ask, wake and send must survive a wordless turn exactly as
|
|
8258
8457
|
// they survive a textual final; dropping one can strand a person
|
|
8259
8458
|
// or the next actor with no visible failure.
|
|
8260
|
-
...committer.turnControlFields()
|
|
8459
|
+
...committer.turnControlFields("silent")
|
|
8261
8460
|
});
|
|
8262
8461
|
silentMarkerEmitted = true;
|
|
8263
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.60",
|
|
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",
|